here.platform.api.base_api

Source code for here.platform.api.base_api

Copyright (C) 2020-2022 HERE Global B.V. and its affiliate(s).

All rights reserved.

This software and other materials contain proprietary information

controlled by HERE and are protected by applicable copyright legislation.

Any use and utilization of this software and other materials and

disclosure to any third parties is conditional upon having a separate

agreement with HERE for the access, use, utilization or disclosure of this

software. In the absence of such agreement, the use of the software is not

allowed.

"""
This module contains a :class:BaseApi class to perform API operations.
"""
import logging
import urllib.request
from collections.abc import MutableMapping
from datetime import datetime
from typing import TYPE_CHECKING, Optional, Union
from urllib.parse import urlparse
from uuid import uuid4

import backoff
import requests
import requests.adapters

from importlib.metadata import version # isort:skip

version = version("here-platform") # isort:skip

from here.platform.config import ApplicationConfig, PlatformConfig
from here.platform.exceptions import (
AuthenticationException,
DigestMismatchException,
InequalReadsException,
NonceAlreadyUsedException,
ResourceLimitExceededException,
ServiceUnavailableException,
TooManyRequestsException,
)
from requests import ConnectionError, Response

if TYPE_CHECKING:
from here.platform.auth import Auth

[docs]
class BaseApi:
"""
This class provides access to some Restful API operations.
"""

def init(
self,
platform_config: PlatformConfig,
application_config: ApplicationConfig,
auth: Optional["Auth"],
proxies: Optional[dict] = None,
):
"""
Instantiate API with auth token.

:param platform_config: a mandatory :class:PlatformConfig object to provide
configuration information for the API.
:param application_config: a mandatory :class:ApplicationConfig object to provide
configuration information for the API.
:param auth: an Authentication instance.
:param proxies: an optional proxy configuration. Defaults to the environment proxy
configuration.
"""
self.auth = auth
self.platform_config = platform_config
self.application_config = application_config
self.proxies = proxies or urllib.request.getproxies()

Full URL is provided for platform config api when using Local DataService.

TODO: Provide a more direct way to check for LDS.

self.is_local = bool(urlparse(self.platform_config.api).scheme)

self._user_agent = "PySDK/" + version

create our own dedicated HTTP connection pool.

self._http_pool = requests.Session()
adapter = requests.adapters.HTTPAdapter()

use our pool for both http and https

self._http_pool.mount("http://", adapter)
self._http_pool.mount("https://", adapter)

self._is_recording = (
bool(self.application_config.additional_parameters["is_recording"])
if self.application_config
and "is_recording" in self.application_config.additional_parameters
else False
)

@property
def headers(self) -> dict:
"""
Return HTTP request headers with Bearer token in Authorization
field.

:return: authorization tokens
"""
return {"Authorization": f"Bearer {self.auth.token}"} if self.auth else

[docs]
def format_url(self, base_url: str, path: str) -> str:
"""
Formats the URL based on the base URL and path.

:param base_url: The base URL for the request.
:param path: The path for the URL. This is expected to have the leading '/'.
:return: The formatted URL.
"""
assert path.startswith("/")
return f"{base_url}{path}"

[docs]
def get(
self,
url: str,
params: Optional[dict] = None,
headers: Optional[MutableMapping] = None,
**kwargs,
) -> Response:
"""
Perform a get request of an API at a specified URL with backoff.

:param url: URL of the API.
:param params: Parameters to pass to the API.
:param headers: Request headers. Defaults to the Api headers property.
:param kwargs: Optional arguments that request takes.
:return: response from the API.
"""
return self.request("GET", url, params=params, headers=headers, **kwargs)

[docs]
def head(
self,
url: str,
params: Optional[dict] = None,
headers: Optional[MutableMapping] = None,
**kwargs,
) -> Response:
"""
Perform a head request of an API at specified URL with backoff.

:param url: URL of the API.
:param params: Parameters to pass to the API.
:param headers: Request headers. Defaults to the api headers property.
:param kwargs: Optional arguments that request takes.
:return: response from the API.
"""
return self.request("HEAD", url, params=params, headers=headers, **kwargs)

[docs]
def post(
self,
url: str,
data: Optional[Union[dict, list, bytes, str]] = None,
params: Optional[dict] = None,
headers: Optional[MutableMapping] = None,
**kwargs,
) -> Response:
"""
Perform a post request of an API at a specified URL with backoff.

:param url: URL of the API.
:param data: Post data for http request.
:param params: Parameters to pass to the API.
:param headers: Request headers. Defaults to the api headers property.
:param kwargs: Optional arguments that request takes.
:return: response from the API.
"""
if isinstance(data, (dict, list)):
return self.request("POST", url, json=data, params=params, headers=headers, **kwargs)
else:
return self.request("POST", url, data=data, params=params, headers=headers, **kwargs)

[docs]
def put(
self,
url: str,
data: Optional[Union[dict, list, bytes, str]] = None,
params: Optional[dict] = None,
headers: Optional[MutableMapping] = None,
**kwargs,
) -> Response:
"""
Perform a put request of an API at a specified URL with backoff.

:param url: URL of the API
:param data: Put data for http request.
:param params: Parameters to pass to the API.
:param headers: Request headers. Defaults to the api headers property.
:param kwargs: Optional arguments that request takes.
:return: response from the API.
"""
if isinstance(data, (dict, list)):
return self.request("PUT", url, json=data, params=params, headers=headers, **kwargs)
else:
return self.request("PUT", url, data=data, params=params, headers=headers, **kwargs)

[docs]
def patch(
self,
url: str,
data: Optional[Union[dict, list, bytes, str]] = None,
params: Optional[dict] = None,
headers: Optional[MutableMapping] = None,
**kwargs,
) -> Response:
"""
Perform a patch request of an API at a specified URL with backoff.

:param url: URL of the API
:param data: Patch data for http request.
:param params: Parameters to pass to the API.
:param headers: Request headers. Defaults to the api headers property.
:param kwargs: Optional arguments that request takes.
:return: response from the API.
"""
if isinstance(data, (dict, list)):
return self.request("PATCH", url, json=data, params=params, headers=headers, **kwargs)
else:
return self.request("PATCH", url, data=data, params=params, headers=headers, **kwargs)

[docs]
def delete(
self,
url: str,
params: Optional[dict] = None,
headers: Optional[MutableMapping] = None,
**kwargs,
) -> Response:
"""
Perform a delete request of an API at a specified URL with backoff.

:param url: URL of the API
:param params: parameters to pass to the API.
:param headers: Request headers. Defaults to the api headers property.
:param kwargs: Optional arguments that request takes.
:return: response from the API.
"""
return self.request("DELETE", url, params=params, headers=headers, **kwargs)

[docs]
def request(
self,
method: str,
url: str,
headers: Optional[MutableMapping] = None,
stream: bool = False,
**kwargs,
) -> Response:
"""
Performs a request of an API at a specified URL with a backoff.
:param method: The HTTP method to perform.
:param url: URL of the API
:param headers: Request headers. Defaults to the api headers property.
:param stream: whether to stream data.
:param kwargs: Optional arguments that request takes.
:return: response from the API.
"""

headers = headers or self.headers
headers["User-Agent"] = self._user_agent

x-idempotency-key is used by multiple APIs to identify a request and detect retries where

otherwise errors such as 409 (conflict) may be returned if a retry is interpreted as a

completely separate request.

headers["x-idempotency-key"] = str(uuid4())

def validate_resp_content_length(resp):
"""
Validate content actual length vs expected length.
If length differs raise InequalReadsException.

:param resp: Response object.
:raises InequalReadsException: if content actual vs expected length differs.
"""
expected_length = resp.headers.get("Content-Length")
if expected_length:
expected_length = int(expected_length)
actual_length = int(resp.raw.tell())

The length check must not be done if runnung pytest recording.

if not self._is_recording:
if actual_length != expected_length:
raise InequalReadsException(resp)

Inner backoff loop retries on connection error. Outer backoff loop retries based on too

many requests.

@backoff.on_exception(
backoff.expo,
(
DigestMismatchException,
InequalReadsException,
NonceAlreadyUsedException,
ServiceUnavailableException,
TooManyRequestsException,
requests.exceptions.ChunkedEncodingError,
requests.exceptions.ReadTimeout,
),
max_time=self.application_config.retry_max_time,
max_value=self.application_config.retry_max_wait,
)
@backoff.on_exception(
backoff.constant,
(ConnectionError, requests.exceptions.ConnectTimeout),
max_time=self.application_config.connect_retry_max_time,
)
def request_impl() -> Response:
"""
Perform a request of an API at a specified URL.

:raises TooManyRequestsException: If platform responds with HTTP 429.
:raises ServiceUnavailableException: If platform responds with HTTP 408, 500, 502, 503,
or 504.
:raises AuthenticationException: If platform responds with HTTP 401 or 403.
:raises NonceAlreadyUsedException: If platform responds with HTTP 401 due to nonce
re-use and retries have been exceeded.
:raises ResourceLimitExceededException: If platform responds with HTTP 402.
:raises DigestMismatchException: If platform responds with HTTP 400 due to the provided
digest not matching the data.
:return: response from the API.
"""
logging.debug(f"{method} {url}")
start_time = datetime.now()
resp = self._http_pool.request(
method,
url,
headers=headers,
proxies=self.proxies,
timeout=(
self.application_config.connect_timeout,
self.application_config.read_timeout,
),
stream=stream,
**kwargs,
)
run_time = datetime.now() - start_time
logging.debug(f"{method} {url} - {resp.status_code} - {run_time.total_seconds()} sec.")

By-Passing HEAD method because Response will have headers['content-length']

but no message body. So that InequalReadsException is not raised.

The HTTP HEAD method requests the headers that would be returned

if the HEAD request's URL was instead requested with the HTTP GET method.

For example,if a URL might produce a large download,

a HEAD request could read its Content-Length header to check the filesize

without actually downloading the file.

if (
not stream
and method not in ["HEAD"]
and resp.status_code not in [404, 429, 500, 502, 503, 504]
):
validate_resp_content_length(resp=resp)

if resp.status_code == 429:
raise TooManyRequestsException(resp)

Request timeout, internal server error, invalid gateway, service unavailable,

or gateway timeout.

elif resp.status_code in [408, 500, 502, 503, 504]:
raise ServiceUnavailableException(resp)
elif resp.status_code == 402:
raise ResourceLimitExceededException(resp)
elif resp.status_code in [401, 403]:
try:

Special error case for the nonce already used, which just needs a retry.

is_nonce_error = (
resp.json()["errorCode"] == NonceAlreadyUsedException.AAA_ERROR_CODE
)
except Exception:

If the error code couldn't be retrieved, assume it's not a nonce error.

is_nonce_error = False
if is_nonce_error:
raise NonceAlreadyUsedException(resp)
raise AuthenticationException(resp)
elif resp.status_code == 400:

Check for digest mismatch.

is_digest_error = False
try:
for detail in resp.json()["detail"]:
if detail.get("name") == "digest":
is_digest_error = True
break
except Exception:

If the error name couldn't be retrieved, assume it's not a digest error.

pass
if is_digest_error:
raise DigestMismatchException(resp)
return resp

result_resp: Response = request_impl()
return result_resp