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, Optional, Tuple, Union
 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        auth: Optional[Tuple[str, str]] = None,
 26        session: Optional[requests.Session] = None,
 27        timeout: int = 30,
 28    ) -> None:
 29        self._api = base_url
 30        self._created_by = created_by
 31        self._timeout = timeout
 32
 33        try:
 34            self._auth: Optional[Any] = auth
 35            if not auth and session.auth:  # type: ignore[union-attr]
 36                self._auth = session.auth  # type: ignore[union-attr]
 37        except AttributeError:
 38            pass
 39
 40        self._http_session = session
 41        self._session = self._get_http_session()
 42
 43    def close(self) -> None:
 44        if self._session:
 45            self._session.close()
 46
 47    def _http_request(  # noqa: C901
 48        self,
 49        method: str,
 50        path: str,
 51        auth: bool,
 52        send: Optional[Union[str, bytes]],
 53        return_value: bool = True,
 54        params: Optional[dict] = None,
 55    ) -> bytes:
 56        """
 57        Returns the response generated by an HTTP request.
 58
 59        `method` is a HTTP method to be executed
 60        with the request data. For example: 'GET' or 'POST'.
 61        `path` is the path to the requested resource relative to the
 62        base API address stored in self._api. Should start with a
 63        slash character to separate the URL.
 64        `auth` is a boolean indicating whether authentication should
 65        be preformed on this request.
 66        `send` contains additional data that might be sent in a
 67        request.
 68        `return_value` indicates wheter this request should return
 69        any data or not.
 70
 71        If the username or password is missing,
 72        `OsmApi.UsernamePasswordMissingError` is raised.
 73
 74        If the requested element has been deleted,
 75        `OsmApi.ElementDeletedApiError` is raised.
 76
 77        If the requested element can not be found,
 78        `OsmApi.ElementNotFoundApiError` is raised.
 79
 80        If the response status code indicates an error,
 81        `OsmApi.ApiError` is raised.
 82        """
 83        logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}")
 84
 85        # Add API base URL to path
 86        path = self._api + path
 87
 88        if auth and not self._auth:
 89            raise errors.UsernamePasswordMissingError("Username/Password missing")
 90
 91        try:
 92            response = self._session.request(
 93                method, path, data=send, timeout=self._timeout, params=params
 94            )
 95        except requests.exceptions.Timeout as e:
 96            raise errors.TimeoutApiError(
 97                0, f"Request timed out (timeout={self._timeout})", ""
 98            ) from e
 99        except requests.exceptions.ConnectionError as e:
100            raise errors.ConnectionApiError(0, f"Connection error: {str(e)}", "") from e
101        except requests.exceptions.RequestException as e:
102            raise errors.ApiError(0, str(e), "") from e
103
104        if response.status_code != 200:
105            payload = response.content.strip()
106            if response.status_code == 401:
107                raise errors.UnauthorizedApiError(
108                    response.status_code, response.reason, payload
109                )
110            if response.status_code == 404:
111                raise errors.ElementNotFoundApiError(
112                    response.status_code, response.reason, payload
113                )
114            elif response.status_code == 410:
115                raise errors.ElementDeletedApiError(
116                    response.status_code, response.reason, payload
117                )
118            raise errors.ApiError(response.status_code, response.reason, payload)
119        if return_value and not response.content:
120            raise errors.ResponseEmptyApiError(
121                response.status_code, response.reason, ""
122            )
123
124        logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}")
125        return response.content
126
127    def _http(  # type: ignore[return-value]  # noqa: C901
128        self,
129        cmd: str,
130        path: str,
131        auth: bool,
132        send: Optional[Union[str, bytes]],
133        return_value: bool = True,
134        params: Optional[dict] = None,
135    ) -> bytes:
136        for i in it.count(1):
137            try:
138                return self._http_request(
139                    cmd, path, auth, send, return_value=return_value, params=params
140                )
141            except errors.ApiError as e:
142                if e.status >= 500:
143                    if i == self.MAX_RETRY_LIMIT:
144                        raise
145                    if i != 1:
146                        self._sleep()
147                    self._session = self._get_http_session()
148                else:
149                    logger.debug("ApiError Exception occured")
150                    raise
151            except errors.UsernamePasswordMissingError:
152                raise
153            except Exception as e:
154                logger.exception("General exception occured")
155                if i == self.MAX_RETRY_LIMIT:
156                    if isinstance(e, errors.OsmApiError):
157                        raise
158                    raise errors.MaximumRetryLimitReachedError(
159                        f"Give up after {i} retries"
160                    ) from e
161                if i != 1:
162                    self._sleep()
163                self._session = self._get_http_session()
164
165    def _get_http_session(self) -> requests.Session:
166        """
167        Creates a requests session for connection pooling.
168        """
169        if self._http_session:
170            session = self._http_session
171        else:
172            session = requests.Session()
173
174        session.auth = self._auth
175        session.headers.update({"user-agent": self._created_by})
176        return session
177
178    def _sleep(self) -> None:
179        time.sleep(5)
180
181    def _get(self, path: str, params: Optional[dict] = None) -> bytes:
182        return self._http("GET", path, False, None, params=params)
183
184    def _put(
185        self, path: str, data: Optional[Union[str, bytes]], return_value: bool = True
186    ) -> bytes:
187        return self._http("PUT", path, True, data, return_value=return_value)
188
189    def _post(
190        self,
191        path: str,
192        data: Optional[Union[str, bytes]],
193        optionalAuth: bool = False,
194        forceAuth: bool = False,
195        params: Optional[dict] = None,
196    ) -> bytes:
197        # the Notes API allows certain POSTs by non-authenticated users
198        auth = optionalAuth and self._auth
199        if forceAuth:
200            auth = True
201        return self._http("POST", path, bool(auth), data, params=params)
202
203    def _delete(self, path: str, data: Optional[Union[str, bytes]]) -> bytes:
204        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        auth: Optional[Tuple[str, str]] = None,
 27        session: Optional[requests.Session] = None,
 28        timeout: int = 30,
 29    ) -> None:
 30        self._api = base_url
 31        self._created_by = created_by
 32        self._timeout = timeout
 33
 34        try:
 35            self._auth: Optional[Any] = auth
 36            if not auth and session.auth:  # type: ignore[union-attr]
 37                self._auth = session.auth  # type: ignore[union-attr]
 38        except AttributeError:
 39            pass
 40
 41        self._http_session = session
 42        self._session = self._get_http_session()
 43
 44    def close(self) -> None:
 45        if self._session:
 46            self._session.close()
 47
 48    def _http_request(  # noqa: C901
 49        self,
 50        method: str,
 51        path: str,
 52        auth: bool,
 53        send: Optional[Union[str, bytes]],
 54        return_value: bool = True,
 55        params: Optional[dict] = None,
 56    ) -> bytes:
 57        """
 58        Returns the response generated by an HTTP request.
 59
 60        `method` is a HTTP method to be executed
 61        with the request data. For example: 'GET' or 'POST'.
 62        `path` is the path to the requested resource relative to the
 63        base API address stored in self._api. Should start with a
 64        slash character to separate the URL.
 65        `auth` is a boolean indicating whether authentication should
 66        be preformed on this request.
 67        `send` contains additional data that might be sent in a
 68        request.
 69        `return_value` indicates wheter this request should return
 70        any data or not.
 71
 72        If the username or password is missing,
 73        `OsmApi.UsernamePasswordMissingError` is raised.
 74
 75        If the requested element has been deleted,
 76        `OsmApi.ElementDeletedApiError` is raised.
 77
 78        If the requested element can not be found,
 79        `OsmApi.ElementNotFoundApiError` is raised.
 80
 81        If the response status code indicates an error,
 82        `OsmApi.ApiError` is raised.
 83        """
 84        logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}")
 85
 86        # Add API base URL to path
 87        path = self._api + path
 88
 89        if auth and not self._auth:
 90            raise errors.UsernamePasswordMissingError("Username/Password missing")
 91
 92        try:
 93            response = self._session.request(
 94                method, path, data=send, timeout=self._timeout, params=params
 95            )
 96        except requests.exceptions.Timeout as e:
 97            raise errors.TimeoutApiError(
 98                0, f"Request timed out (timeout={self._timeout})", ""
 99            ) from e
100        except requests.exceptions.ConnectionError as e:
101            raise errors.ConnectionApiError(0, f"Connection error: {str(e)}", "") from e
102        except requests.exceptions.RequestException as e:
103            raise errors.ApiError(0, str(e), "") from e
104
105        if response.status_code != 200:
106            payload = response.content.strip()
107            if response.status_code == 401:
108                raise errors.UnauthorizedApiError(
109                    response.status_code, response.reason, payload
110                )
111            if response.status_code == 404:
112                raise errors.ElementNotFoundApiError(
113                    response.status_code, response.reason, payload
114                )
115            elif response.status_code == 410:
116                raise errors.ElementDeletedApiError(
117                    response.status_code, response.reason, payload
118                )
119            raise errors.ApiError(response.status_code, response.reason, payload)
120        if return_value and not response.content:
121            raise errors.ResponseEmptyApiError(
122                response.status_code, response.reason, ""
123            )
124
125        logger.debug(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {method} {path}")
126        return response.content
127
128    def _http(  # type: ignore[return-value]  # noqa: C901
129        self,
130        cmd: str,
131        path: str,
132        auth: bool,
133        send: Optional[Union[str, bytes]],
134        return_value: bool = True,
135        params: Optional[dict] = None,
136    ) -> bytes:
137        for i in it.count(1):
138            try:
139                return self._http_request(
140                    cmd, path, auth, send, return_value=return_value, params=params
141                )
142            except errors.ApiError as e:
143                if e.status >= 500:
144                    if i == self.MAX_RETRY_LIMIT:
145                        raise
146                    if i != 1:
147                        self._sleep()
148                    self._session = self._get_http_session()
149                else:
150                    logger.debug("ApiError Exception occured")
151                    raise
152            except errors.UsernamePasswordMissingError:
153                raise
154            except Exception as e:
155                logger.exception("General exception occured")
156                if i == self.MAX_RETRY_LIMIT:
157                    if isinstance(e, errors.OsmApiError):
158                        raise
159                    raise errors.MaximumRetryLimitReachedError(
160                        f"Give up after {i} retries"
161                    ) from e
162                if i != 1:
163                    self._sleep()
164                self._session = self._get_http_session()
165
166    def _get_http_session(self) -> requests.Session:
167        """
168        Creates a requests session for connection pooling.
169        """
170        if self._http_session:
171            session = self._http_session
172        else:
173            session = requests.Session()
174
175        session.auth = self._auth
176        session.headers.update({"user-agent": self._created_by})
177        return session
178
179    def _sleep(self) -> None:
180        time.sleep(5)
181
182    def _get(self, path: str, params: Optional[dict] = None) -> bytes:
183        return self._http("GET", path, False, None, params=params)
184
185    def _put(
186        self, path: str, data: Optional[Union[str, bytes]], return_value: bool = True
187    ) -> bytes:
188        return self._http("PUT", path, True, data, return_value=return_value)
189
190    def _post(
191        self,
192        path: str,
193        data: Optional[Union[str, bytes]],
194        optionalAuth: bool = False,
195        forceAuth: bool = False,
196        params: Optional[dict] = None,
197    ) -> bytes:
198        # the Notes API allows certain POSTs by non-authenticated users
199        auth = optionalAuth and self._auth
200        if forceAuth:
201            auth = True
202        return self._http("POST", path, bool(auth), data, params=params)
203
204    def _delete(self, path: str, data: Optional[Union[str, bytes]]) -> bytes:
205        return self._http("DELETE", path, True, data)
OsmApiSession( base_url: str, created_by: str, auth: Optional[Tuple[str, str]] = None, session: Optional[requests.sessions.Session] = None, timeout: int = 30)
22    def __init__(
23        self,
24        base_url: str,
25        created_by: str,
26        auth: Optional[Tuple[str, str]] = None,
27        session: Optional[requests.Session] = None,
28        timeout: int = 30,
29    ) -> None:
30        self._api = base_url
31        self._created_by = created_by
32        self._timeout = timeout
33
34        try:
35            self._auth: Optional[Any] = auth
36            if not auth and session.auth:  # type: ignore[union-attr]
37                self._auth = session.auth  # type: ignore[union-attr]
38        except AttributeError:
39            pass
40
41        self._http_session = session
42        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:
44    def close(self) -> None:
45        if self._session:
46            self._session.close()