osmapi.http
HTTP session management for the OpenStreetMap API.
1""" 2HTTP session management for the OpenStreetMap API. 3""" 4 5import datetime 6import itertools as it 7import logging 8import requests 9import time 10from typing import Any 11 12from . import errors 13 14logger = logging.getLogger(__name__) 15 16 17class OsmApiSession: 18 MAX_RETRY_LIMIT = 5 19 """Maximum retries if a call to the remote API fails (default: 5)""" 20 21 def __init__( 22 self, 23 base_url: str, 24 created_by: str, 25 session: requests.Session | None = None, 26 timeout: int = 30, 27 ) -> None: 28 self._api = base_url 29 self._created_by = created_by 30 self._timeout = timeout 31 32 # authentication is taken from the session (e.g. an OAuth 2.0 session) 33 self._auth: Any = getattr(session, "auth", None) 34 35 # A caller-provided session can carry credentials in ways that are not 36 # visible from the outside: `session.auth`, an `Authorization` header, 37 # a custom transport adapter, or a `Session` subclass adding the token 38 # per request (this is what requests-oauthlib does). Sniffing 39 # `session.auth` therefore both rejects valid setups and accepts 40 # sessions without any token, so it is not used to make this decision: 41 # the only case in which authentication is certainly missing is a 42 # session that was built here, i.e. one the caller didn't provide. 43 # Everything else is sent, and the API answers with a 401 -> 44 # `UnauthorizedApiError` if the authorization really was missing. 45 self._can_authenticate: bool = session is not None 46 47 self._http_session = session 48 self._session = self._get_http_session() 49 50 def close(self) -> None: 51 if self._session: 52 self._session.close() 53 54 def _http_request( # noqa: C901 55 self, 56 method: str, 57 path: str, 58 auth: bool, 59 send: str | bytes | None, 60 return_value: bool = True, 61 params: dict | None = None, 62 ) -> bytes: 63 """ 64 Returns the response generated by an HTTP request. 65 66 `method` is a HTTP method to be executed 67 with the request data. For example: 'GET' or 'POST'. 68 `path` is the path to the requested resource relative to the 69 base API address stored in self._api. Should start with a 70 slash character to separate the URL. 71 `auth` is a boolean indicating whether authentication should 72 be preformed on this request. 73 `send` contains additional data that might be sent in a 74 request. 75 `return_value` indicates wheter this request should return 76 any data or not. 77 78 If the request requires authentication and no session was provided to 79 carry credentials, `OsmApi.AuthenticationMissingError` is raised. With 80 a session, the request is sent and a rejected authorization surfaces 81 as `OsmApi.UnauthorizedApiError` (HTTP 401). 82 83 If the requested element has been deleted, 84 `OsmApi.ElementDeletedApiError` is raised. 85 86 If the requested element can not be found, 87 `OsmApi.ElementNotFoundApiError` is raised. 88 89 If the response status code indicates an error, 90 `OsmApi.ApiError` is raised. 91 """ 92 logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}") 93 94 # Add API base URL to path 95 path = self._api + path 96 97 if auth and not self._can_authenticate: 98 raise errors.AuthenticationMissingError( 99 "Authentication missing, this request requires an " 100 "authenticated session, but no session was provided " 101 "(see the OAuth 2.0 examples)" 102 ) 103 104 try: 105 response = self._session.request( 106 method, path, data=send, timeout=self._timeout, params=params 107 ) 108 except requests.exceptions.Timeout as e: 109 raise errors.TimeoutApiError( 110 0, f"Request timed out (timeout={self._timeout})", "" 111 ) from e 112 except requests.exceptions.ConnectionError as e: 113 raise errors.ConnectionApiError(0, f"Connection error: {str(e)}", "") from e 114 except requests.exceptions.RequestException as e: 115 raise errors.ApiError(0, str(e), "") from e 116 117 if response.status_code != 200: 118 payload = response.content.strip() 119 if response.status_code == 401: 120 raise errors.UnauthorizedApiError( 121 response.status_code, response.reason, payload 122 ) 123 if response.status_code == 404: 124 raise errors.ElementNotFoundApiError( 125 response.status_code, response.reason, payload 126 ) 127 elif response.status_code == 410: 128 raise errors.ElementDeletedApiError( 129 response.status_code, response.reason, payload 130 ) 131 raise errors.ApiError(response.status_code, response.reason, payload) 132 if return_value and not response.content: 133 raise errors.ResponseEmptyApiError( 134 response.status_code, response.reason, "" 135 ) 136 137 logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}") 138 return response.content 139 140 def _http( # type: ignore[return-value] # noqa: C901 141 self, 142 cmd: str, 143 path: str, 144 auth: bool, 145 send: str | bytes | None, 146 return_value: bool = True, 147 params: dict | None = None, 148 ) -> bytes: 149 for i in it.count(1): 150 try: 151 return self._http_request( 152 cmd, path, auth, send, return_value=return_value, params=params 153 ) 154 except errors.ApiError as e: 155 if e.status >= 500: 156 if i == self.MAX_RETRY_LIMIT: 157 raise 158 if i != 1: 159 self._sleep() 160 self._session = self._get_http_session() 161 else: 162 logger.debug("ApiError Exception occured") 163 raise 164 except errors.AuthenticationMissingError: 165 raise 166 except Exception as e: 167 logger.exception("General exception occured") 168 if i == self.MAX_RETRY_LIMIT: 169 if isinstance(e, errors.OsmApiError): 170 raise 171 raise errors.MaximumRetryLimitReachedError( 172 f"Give up after {i} retries" 173 ) from e 174 if i != 1: 175 self._sleep() 176 self._session = self._get_http_session() 177 178 def _get_http_session(self) -> requests.Session: 179 """ 180 Creates a requests session for connection pooling. 181 """ 182 if self._http_session: 183 session = self._http_session 184 else: 185 session = requests.Session() 186 187 session.auth = self._auth 188 session.headers.update({"user-agent": self._created_by}) 189 return session 190 191 def _sleep(self) -> None: 192 time.sleep(5) 193 194 def _get(self, path: str, params: dict | None = None) -> bytes: 195 return self._http("GET", path, False, None, params=params) 196 197 def _put( 198 self, path: str, data: str | bytes | None, return_value: bool = True 199 ) -> bytes: 200 return self._http("PUT", path, True, data, return_value=return_value) 201 202 def _post( 203 self, 204 path: str, 205 data: str | bytes | None, 206 optionalAuth: bool = False, 207 forceAuth: bool = False, 208 params: dict | None = None, 209 ) -> bytes: 210 # the Notes API allows certain POSTs by non-authenticated users 211 auth = optionalAuth and self._can_authenticate 212 if forceAuth: 213 auth = True 214 return self._http("POST", path, bool(auth), data, params=params) 215 216 def _delete(self, path: str, data: str | bytes | None) -> bytes: 217 return self._http("DELETE", path, True, data)
logger =
<Logger osmapi.http (WARNING)>
class
OsmApiSession:
18class OsmApiSession: 19 MAX_RETRY_LIMIT = 5 20 """Maximum retries if a call to the remote API fails (default: 5)""" 21 22 def __init__( 23 self, 24 base_url: str, 25 created_by: str, 26 session: requests.Session | None = None, 27 timeout: int = 30, 28 ) -> None: 29 self._api = base_url 30 self._created_by = created_by 31 self._timeout = timeout 32 33 # authentication is taken from the session (e.g. an OAuth 2.0 session) 34 self._auth: Any = getattr(session, "auth", None) 35 36 # A caller-provided session can carry credentials in ways that are not 37 # visible from the outside: `session.auth`, an `Authorization` header, 38 # a custom transport adapter, or a `Session` subclass adding the token 39 # per request (this is what requests-oauthlib does). Sniffing 40 # `session.auth` therefore both rejects valid setups and accepts 41 # sessions without any token, so it is not used to make this decision: 42 # the only case in which authentication is certainly missing is a 43 # session that was built here, i.e. one the caller didn't provide. 44 # Everything else is sent, and the API answers with a 401 -> 45 # `UnauthorizedApiError` if the authorization really was missing. 46 self._can_authenticate: bool = session is not None 47 48 self._http_session = session 49 self._session = self._get_http_session() 50 51 def close(self) -> None: 52 if self._session: 53 self._session.close() 54 55 def _http_request( # noqa: C901 56 self, 57 method: str, 58 path: str, 59 auth: bool, 60 send: str | bytes | None, 61 return_value: bool = True, 62 params: dict | None = None, 63 ) -> bytes: 64 """ 65 Returns the response generated by an HTTP request. 66 67 `method` is a HTTP method to be executed 68 with the request data. For example: 'GET' or 'POST'. 69 `path` is the path to the requested resource relative to the 70 base API address stored in self._api. Should start with a 71 slash character to separate the URL. 72 `auth` is a boolean indicating whether authentication should 73 be preformed on this request. 74 `send` contains additional data that might be sent in a 75 request. 76 `return_value` indicates wheter this request should return 77 any data or not. 78 79 If the request requires authentication and no session was provided to 80 carry credentials, `OsmApi.AuthenticationMissingError` is raised. With 81 a session, the request is sent and a rejected authorization surfaces 82 as `OsmApi.UnauthorizedApiError` (HTTP 401). 83 84 If the requested element has been deleted, 85 `OsmApi.ElementDeletedApiError` is raised. 86 87 If the requested element can not be found, 88 `OsmApi.ElementNotFoundApiError` is raised. 89 90 If the response status code indicates an error, 91 `OsmApi.ApiError` is raised. 92 """ 93 logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}") 94 95 # Add API base URL to path 96 path = self._api + path 97 98 if auth and not self._can_authenticate: 99 raise errors.AuthenticationMissingError( 100 "Authentication missing, this request requires an " 101 "authenticated session, but no session was provided " 102 "(see the OAuth 2.0 examples)" 103 ) 104 105 try: 106 response = self._session.request( 107 method, path, data=send, timeout=self._timeout, params=params 108 ) 109 except requests.exceptions.Timeout as e: 110 raise errors.TimeoutApiError( 111 0, f"Request timed out (timeout={self._timeout})", "" 112 ) from e 113 except requests.exceptions.ConnectionError as e: 114 raise errors.ConnectionApiError(0, f"Connection error: {str(e)}", "") from e 115 except requests.exceptions.RequestException as e: 116 raise errors.ApiError(0, str(e), "") from e 117 118 if response.status_code != 200: 119 payload = response.content.strip() 120 if response.status_code == 401: 121 raise errors.UnauthorizedApiError( 122 response.status_code, response.reason, payload 123 ) 124 if response.status_code == 404: 125 raise errors.ElementNotFoundApiError( 126 response.status_code, response.reason, payload 127 ) 128 elif response.status_code == 410: 129 raise errors.ElementDeletedApiError( 130 response.status_code, response.reason, payload 131 ) 132 raise errors.ApiError(response.status_code, response.reason, payload) 133 if return_value and not response.content: 134 raise errors.ResponseEmptyApiError( 135 response.status_code, response.reason, "" 136 ) 137 138 logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}") 139 return response.content 140 141 def _http( # type: ignore[return-value] # noqa: C901 142 self, 143 cmd: str, 144 path: str, 145 auth: bool, 146 send: str | bytes | None, 147 return_value: bool = True, 148 params: dict | None = None, 149 ) -> bytes: 150 for i in it.count(1): 151 try: 152 return self._http_request( 153 cmd, path, auth, send, return_value=return_value, params=params 154 ) 155 except errors.ApiError as e: 156 if e.status >= 500: 157 if i == self.MAX_RETRY_LIMIT: 158 raise 159 if i != 1: 160 self._sleep() 161 self._session = self._get_http_session() 162 else: 163 logger.debug("ApiError Exception occured") 164 raise 165 except errors.AuthenticationMissingError: 166 raise 167 except Exception as e: 168 logger.exception("General exception occured") 169 if i == self.MAX_RETRY_LIMIT: 170 if isinstance(e, errors.OsmApiError): 171 raise 172 raise errors.MaximumRetryLimitReachedError( 173 f"Give up after {i} retries" 174 ) from e 175 if i != 1: 176 self._sleep() 177 self._session = self._get_http_session() 178 179 def _get_http_session(self) -> requests.Session: 180 """ 181 Creates a requests session for connection pooling. 182 """ 183 if self._http_session: 184 session = self._http_session 185 else: 186 session = requests.Session() 187 188 session.auth = self._auth 189 session.headers.update({"user-agent": self._created_by}) 190 return session 191 192 def _sleep(self) -> None: 193 time.sleep(5) 194 195 def _get(self, path: str, params: dict | None = None) -> bytes: 196 return self._http("GET", path, False, None, params=params) 197 198 def _put( 199 self, path: str, data: str | bytes | None, return_value: bool = True 200 ) -> bytes: 201 return self._http("PUT", path, True, data, return_value=return_value) 202 203 def _post( 204 self, 205 path: str, 206 data: str | bytes | None, 207 optionalAuth: bool = False, 208 forceAuth: bool = False, 209 params: dict | None = None, 210 ) -> bytes: 211 # the Notes API allows certain POSTs by non-authenticated users 212 auth = optionalAuth and self._can_authenticate 213 if forceAuth: 214 auth = True 215 return self._http("POST", path, bool(auth), data, params=params) 216 217 def _delete(self, path: str, data: str | bytes | None) -> bytes: 218 return self._http("DELETE", path, True, data)
OsmApiSession( base_url: str, created_by: str, session: requests.sessions.Session | None = None, timeout: int = 30)
22 def __init__( 23 self, 24 base_url: str, 25 created_by: str, 26 session: requests.Session | None = None, 27 timeout: int = 30, 28 ) -> None: 29 self._api = base_url 30 self._created_by = created_by 31 self._timeout = timeout 32 33 # authentication is taken from the session (e.g. an OAuth 2.0 session) 34 self._auth: Any = getattr(session, "auth", None) 35 36 # A caller-provided session can carry credentials in ways that are not 37 # visible from the outside: `session.auth`, an `Authorization` header, 38 # a custom transport adapter, or a `Session` subclass adding the token 39 # per request (this is what requests-oauthlib does). Sniffing 40 # `session.auth` therefore both rejects valid setups and accepts 41 # sessions without any token, so it is not used to make this decision: 42 # the only case in which authentication is certainly missing is a 43 # session that was built here, i.e. one the caller didn't provide. 44 # Everything else is sent, and the API answers with a 401 -> 45 # `UnauthorizedApiError` if the authorization really was missing. 46 self._can_authenticate: bool = session is not None 47 48 self._http_session = session 49 self._session = self._get_http_session()