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()
MAX_RETRY_LIMIT = 5

Maximum retries if a call to the remote API fails (default: 5)

def close(self) -> None:
51    def close(self) -> None:
52        if self._session:
53            self._session.close()