aio_overpass.error

Error types.

                            (ClientError)
                                  ╷
                   ┌──────────────┼────────────┬────────────────┬────────────────┐
                   ╵              ╵            ╵                ╵                ╵
               RunnerError   (QueryError)   GiveupError    ResponseError     CallError
                                  ╷                             ╷                ╷
         ┌────────────────────────┼──────────────────────┐      │                │
         ╵                        ╵                      ╵      ╵                ╵
QueryLanguageError         QueryRejectError          QueryResponseError   CallTimeoutError
  1"""
  2Error types.
  3
  4```
  5                            (ClientError)
  6
  7                   ┌──────────────┼────────────┬────────────────┬────────────────┐
  8                   ╵              ╵            ╵                ╵                ╵
  9               RunnerError   (QueryError)   GiveupError    ResponseError     CallError
 10                                  ╷                             ╷                ╷
 11         ┌────────────────────────┼──────────────────────┐      │                │
 12         ╵                        ╵                      ╵      ╵                ╵
 13QueryLanguageError         QueryRejectError          QueryResponseError   CallTimeoutError
 14```
 15"""
 16
 17import asyncio
 18import html
 19import logging
 20import math
 21import re
 22from dataclasses import dataclass
 23from enum import Enum, auto
 24from json import JSONDecodeError
 25from typing import NoReturn, TypeAlias, TypeGuard
 26
 27import aiohttp
 28import aiohttp.typedefs
 29
 30
 31__docformat__ = "google"
 32__all__ = (
 33    "AlreadyRunningError",
 34    "ClientError",
 35    "CallError",
 36    "CallTimeoutError",
 37    "ResponseError",
 38    "ResponseErrorCause",
 39    "GiveupError",
 40    "GiveupCause",
 41    "QueryError",
 42    "QueryLanguageError",
 43    "QueryRejectError",
 44    "QueryRejectCause",
 45    "QueryResponseError",
 46    "RunnerError",
 47    "is_call_err",
 48    "is_call_timeout",
 49    "is_exceeding_maxsize",
 50    "is_exceeding_timeout",
 51    "is_gateway_rejection",
 52    "is_giveup_err",
 53    "is_rejection",
 54    "is_runtime_rejection",
 55    "is_server_error",
 56    "is_too_busy",
 57    "is_too_many_queries",
 58)
 59
 60
 61@dataclass(kw_only=True)
 62class AlreadyRunningError(ValueError):
 63    """
 64    Raised when trying to run a query while it's already being run.
 65
 66    Attributes:
 67        kwargs: the query's ``kwargs``
 68    """
 69
 70    kwargs: dict
 71
 72    def __str__(self) -> str:
 73        return f"query{self.kwargs!r} is already being run"
 74
 75
 76class ClientError(Exception):
 77    """Base exception for failed Overpass API requests and queries."""
 78
 79    @property
 80    def should_retry(self) -> bool:
 81        """Returns ``True`` if it's worth retrying when encountering this error."""
 82        return False
 83
 84
 85@dataclass(kw_only=True)
 86class RunnerError(ClientError):
 87    """
 88    The query runner raised an exception.
 89
 90    This is an unexpected error, in contrast to the runner intentionally
 91    raising ``query.error`` to stop retrying. The cause of this error
 92    is therefore anything but a ``ClientError``.
 93
 94    Attributes:
 95        cause: the exception that caused this error
 96    """
 97
 98    cause: BaseException
 99
100    @property
101    def should_retry(self) -> bool:
102        """Returns ``True`` if it's worth retrying when encountering this error."""
103        return False
104
105    def __str__(self) -> str:
106        return str(self.cause)
107
108
109@dataclass(kw_only=True)
110class CallError(ClientError):
111    """
112    Failed to make an API request.
113
114    This error is raised when the client failed to get any response,
115    f.e. due to connection issues.
116
117    Attributes:
118        cause: the exception that caused this error
119    """
120
121    cause: aiohttp.ClientError | asyncio.TimeoutError
122
123    @property
124    def should_retry(self) -> bool:
125        """Returns ``True`` if it's worth retrying when encountering this error."""
126        return True
127
128    def __str__(self) -> str:
129        return str(self.cause)
130
131
132@dataclass(kw_only=True)
133class CallTimeoutError(CallError):
134    """
135    An API request timed out.
136
137    Attributes:
138        cause: the exception that caused this error
139        after_secs: the configured timeout for the request
140    """
141
142    cause: asyncio.TimeoutError
143    after_secs: float
144
145    @property
146    def should_retry(self) -> bool:
147        """Returns ``True`` if it's worth retrying when encountering this error."""
148        return True
149
150    def __str__(self) -> str:
151        return str(self.cause)
152
153
154ResponseErrorCause: TypeAlias = aiohttp.ClientResponseError | JSONDecodeError | ValueError
155"""Causes for a ``ResponseError``."""
156
157
158@dataclass(kw_only=True)
159class ResponseError(ClientError):
160    """
161    Unexpected API response.
162
163    On the one hand, this can be an error that happened on the Overpass API instance,
164    which is usually signalled by a status code ``>= 500``. In rare cases,
165    the ``cause`` being a ``JSONDecodeError`` also signals this, since it can happen
166    that the API returns a cutoff JSON response. In both of these cases,
167    ``is_server_error()`` will return ``True``.
168
169    On the other hand, this error may indicate that a request failed, but we can't
170    specifically say why. This could be a bug on our end, since the client
171    is meant to process almost any response of an Overpass API instance.
172    Here, ``is_server_error()`` will return ``False``, and the default query runner
173    will log the response body.
174
175    Attributes:
176        response: the unexpected response
177        body: the response body
178        cause: an optional exception that may have caused this error
179    """
180
181    response: aiohttp.ClientResponse
182    body: str
183    cause: ResponseErrorCause | None
184
185    @property
186    def should_retry(self) -> bool:
187        """Returns ``True`` if it's worth retrying when encountering this error."""
188        return True
189
190    @property
191    def is_server_error(self) -> bool:
192        """Returns ``True`` if this presumably a server-side error."""
193        # see class doc for details
194        return self.response.status >= 500 or isinstance(self.cause, JSONDecodeError)
195
196    def __str__(self) -> str:
197        if self.cause is None:
198            return f"unexpected response ({self.response.status})"
199        return f"unexpected response ({self.response.status}): {self.cause}"
200
201
202class GiveupCause(Enum):
203    """Details on why a query was given up on."""
204
205    RUN_TIMEOUT_BEFORE_STATUS_CALL = auto()
206    """Query's ``run_timeout_secs`` elapsed before a status call before the actual query call."""
207
208    RUN_TIMEOUT_BY_COOLDOWN = auto()
209    """Query received a timeout that would exceed its remaining ``run_timeout_secs``."""
210
211    RUN_TIMEOUT_BEFORE_QUERY_CALL = auto()
212    """Query's ``run_timeout_secs`` elapsed before the query call."""
213
214    EXPECTING_QUERY_TIMEOUT = auto()
215    """
216    The query's limited [timeout:*] setting is likely too low.
217
218    A query's [timeout:*] setting might be lowered by the client to the remainder of
219    ``run_timeout_secs``. If that happens, and that duration is also lower than a duration
220    after which it was cancelled at the server previously, then this is the cause to give
221    up on the query.
222
223    See Also:
224        ``QueryRejectError.timed_out_after_secs``
225    """
226
227    RUN_TIMEOUT_DURING_QUERY_CALL = auto()
228    """Query's ``run_timeout_secs`` elapsed while making the query call, before any response."""
229
230
231@dataclass(kw_only=True)
232class GiveupError(ClientError):
233    """
234    The client spent or would spend too long running a query, and gave up.
235
236    This error is raised when the run timeout duration set by a query runner
237    is or would be exceeded.
238
239    Attributes:
240        kwargs: the query's ``kwargs``
241        after_secs: the total time spent on the query
242        cause: why the query was given up on
243    """
244
245    kwargs: dict
246    after_secs: float
247    cause: GiveupCause
248
249    @property
250    def should_retry(self) -> bool:
251        """Returns ``True`` if it's worth retrying when encountering this error."""
252        return False
253
254    def __str__(self) -> str:
255        return f"gave up on query{self.kwargs!r} after {self.after_secs:.01f} seconds"
256
257
258@dataclass(kw_only=True)
259class QueryError(ClientError):
260    """
261    Base exception for queries that failed at the Overpass API server.
262
263    Attributes:
264        kwargs: the query's ``kwargs``
265        remarks: the error remarks provided by the API
266    """
267
268    kwargs: dict
269    remarks: list[str]
270
271    @property
272    def should_retry(self) -> bool:
273        """Returns ``True`` if it's worth retrying when encountering this error."""
274        return False
275
276    def __str__(self) -> str:
277        first = f"'{self.remarks[0]}'"
278        rest = f" (+{len(self.remarks) - 1} more)" if len(self.remarks) > 1 else ""
279        return f"query{self.kwargs!r} failed: {first}{rest}"
280
281
282@dataclass(kw_only=True)
283class QueryResponseError(ResponseError, QueryError):
284    """
285    Unexpected query response.
286
287    This error is raised when a query fails (thus extends ``QueryError``),
288    but we can't specifically say why (thus also extends ``ResponseError``).
289    """
290
291    @property
292    def should_retry(self) -> bool:
293        """Returns ``True`` if it's worth retrying when encountering this error."""
294        return ResponseError.should_retry.fget(self)  # type: ignore[attr-defined]
295
296    def __str__(self) -> str:
297        if self.remarks:
298            first = f"'{self.remarks[0]}'"
299            rest = f" (+{len(self.remarks) - 1} more)" if len(self.remarks) > 1 else ""
300            return f"query{self.kwargs!r} failed: {first}{rest}"
301
302        return f"query{self.kwargs!r} failed with status {self.response.status}"
303
304
305@dataclass(kw_only=True)
306class QueryLanguageError(QueryError):
307    """
308    Indicates the query's QL code is not valid.
309
310    Retrying is pointless when encountering this error.
311    """
312
313    @property
314    def should_retry(self) -> bool:
315        """Returns ``True`` if it's worth retrying when encountering this error."""
316        return False
317
318
319class QueryRejectCause(Enum):
320    """Details why a query was rejected or cancelled by an API server."""
321
322    TOO_BUSY = auto()
323    """
324    Gateway rejection. The server has already so much load that the request cannot be executed.
325
326    Smaller ``[timeout:*]`` and/or ``[maxsize:*]`` values might make the request acceptable.
327    """
328
329    TOO_MANY_QUERIES = auto()
330    """
331    Gateway rejection. There are no open slots for queries from your IP address.
332
333    Running queries take up a slot, and the number of slots is limited. A client
334    will only run as many concurrent requests as there are slots, which should make this
335    a rare error, assuming you are not making requests through another client.
336    """
337
338    EXCEEDED_TIMEOUT = auto()
339    """
340    Runtime rejection. The query has been (or surely will be) running longer than its proposed
341    ``[timeout:*]``, and has been cancelled by the server.
342
343    A higher ``[timeout:*]`` value might allow the query run to completion, but also makes it
344    more likely to be rejected by a server under heavy load, before executing it (see ``TOO_BUSY``).
345    """
346
347    EXCEEDED_MAXSIZE = auto()
348    """
349    Runtime rejection. The memory required to execute the query has (or surely will) exceed
350    its proposed ``[maxsize:*]``, and has been cancelled by the server.
351
352    A higher ``[maxsize:*]`` value might allow the query run to completion, but also makes it
353    more likely to be rejected by a server under heavy load, before executing it (see ``TOO_BUSY``).
354    """
355
356    def __str__(self) -> str:
357        match self:
358            case QueryRejectCause.TOO_BUSY:
359                return "server too busy"
360            case QueryRejectCause.TOO_MANY_QUERIES:
361                return "too many queries"
362            case QueryRejectCause.EXCEEDED_TIMEOUT:
363                return "exceeded 'timeout'"
364            case QueryRejectCause.EXCEEDED_MAXSIZE:
365                return "exceeded 'maxsize'"
366            case _:
367                raise AssertionError
368
369
370@dataclass(kw_only=True)
371class QueryRejectError(QueryError):
372    """
373    A query was rejected or cancelled by the API server.
374
375    Attributes:
376        cause: why the query was rejected or cancelled
377        timed_out_after_secs: if ``cause`` is ``EXCEEDED_TIMEOUT``, this is the amount
378                              of seconds after which the query timed out
379        oom_using_mib: if ```cause`` is ``EXCEEDED_MAXSIZE``, this is the amount of RAM
380                       the query used before it ran out
381    """
382
383    cause: QueryRejectCause
384    timed_out_after_secs: int | None
385    oom_using_mib: int | None
386
387    @property
388    def should_retry(self) -> bool:
389        """Returns ``True`` if it's worth retrying when encountering this error."""
390        return True
391
392    def __str__(self) -> str:
393        match self.cause:
394            case QueryRejectCause.TOO_BUSY | QueryRejectCause.TOO_MANY_QUERIES:
395                rejection = "rejected"
396            case QueryRejectCause.EXCEEDED_TIMEOUT:
397                rejection = f"cancelled after {self.timed_out_after_secs}s"
398            case QueryRejectCause.EXCEEDED_MAXSIZE:
399                rejection = f"cancelled using {self.oom_using_mib}"
400            case _:
401                raise AssertionError(self.cause)
402
403        return f"{rejection}: {self.cause}"
404
405
406async def _raise_for_request_error(err: aiohttp.ClientError) -> NoReturn:
407    """
408    Raise an exception caused by the given request error.
409
410    Raises:
411        - ``CallError`` if ``obj`` is an ``aiohttp.ClientError``,
412          but not an ``aiohttp.ClientResponseError``.
413        - ``ResponseError`` otherwise.
414    """
415    if isinstance(err, aiohttp.ClientResponseError):
416        response = err.history[-1]
417        await _raise_for_response(response, err)
418
419    raise CallError(cause=err) from err
420
421
422async def _raise_for_response(
423    response: aiohttp.ClientResponse,
424    cause: ResponseErrorCause | None,
425) -> NoReturn:
426    """Raise a ``ResponseError`` with an optional cause."""
427    err = ResponseError(
428        response=response,
429        body=await response.text(),
430        cause=cause,
431    )
432    if cause:
433        raise err from cause
434    raise err
435
436
437async def _result_or_raise(
438    response: aiohttp.ClientResponse,
439    query_kwargs: dict,
440    query_logger: logging.Logger,
441) -> dict:
442    """
443    Try to extract the query result set from a response.
444
445    Raises:
446        CallError: When there is any sort of connection error.
447        RejectError: When encountering "Too Many Requests" or "Gateway Timeout";
448                     when there's a JSON remark indicating query rejection or cancellation;
449                     when there's an HTML error message indicating query rejection or cancellation.
450        QueryError: When there's any other JSON remark or HTML error message.
451        ResponseError: When encountering an unexpected response.
452    """
453    await __raise_for_plaintext_result(response)
454
455    await __raise_for_html_result(response, query_kwargs, query_logger)
456
457    return await __raise_for_json_result(response, query_kwargs, query_logger)
458
459
460async def __raise_for_json_result(
461    response: aiohttp.ClientResponse,
462    query_kwargs: dict,
463    query_logger: logging.Logger,
464) -> dict:
465    try:
466        json = await response.json()
467        if json is None:
468            await _raise_for_response(response, cause=None)
469    except aiohttp.ClientResponseError as err:
470        await _raise_for_response(response, cause=err)
471    except JSONDecodeError as err:
472        await _raise_for_response(response, cause=err)
473
474    if remark := json.get("remark"):
475        if timeout_cause := __match_reject_cause(remark, query_logger):
476            raise QueryRejectError(
477                kwargs=query_kwargs,
478                remarks=[remark],
479                cause=timeout_cause,
480                oom_using_mib=__match_oom_after(remark),
481                timed_out_after_secs=__match_timeout_after(remark),
482            )
483
484        raise QueryResponseError(
485            response=response,
486            body=await response.text(),
487            cause=None,
488            kwargs=query_kwargs,
489            remarks=[remark],
490        )
491
492    expected_fields = ("version", "generator", "osm3s", "elements")
493    expected_osm3s_fields = ("timestamp_osm_base", "copyright")
494    if any(f not in json for f in expected_fields) or any(
495        f not in json["osm3s"] for f in expected_osm3s_fields
496    ):
497        await _raise_for_response(response, cause=None)
498
499    return json
500
501
502async def __raise_for_html_result(
503    response: aiohttp.ClientResponse,
504    query_kwargs: dict,
505    query_logger: logging.Logger,
506) -> None:
507    """
508    Raise a fitting exception based on error remarks in an HTML response.
509
510    Raises:
511        RejectError: if one of the error remarks indicate that the query was rejected or cancelled
512        QueryError: when encountering other error remarks
513        ResponseError: when error remarks cannot be extracted from the response
514    """
515    if response.content_type != "text/html":
516        return
517
518    text = await response.text()
519
520    pattern = re.compile("Error</strong>: (.+?)</p>", re.DOTALL)
521    errors = [html.unescape(err.strip()) for err in pattern.findall(text)]
522
523    if not errors:  # unexpected format
524        await _raise_for_response(response, cause=None)
525
526    if any(__is_ql_error(msg, query_logger) for msg in errors):
527        raise QueryLanguageError(kwargs=query_kwargs, remarks=errors)
528
529    reject_causes = [cause for err in errors if (cause := __match_reject_cause(err, query_logger))]
530    oom_using_mib = next((mib for err in errors if (mib := __match_oom_after(err))), None)
531    timed_out_after_secs = next(
532        (secs for err in errors if (secs := __match_timeout_after(err))), None
533    )
534
535    if reject_causes:
536        raise QueryRejectError(
537            kwargs=query_kwargs,
538            remarks=errors,
539            cause=reject_causes[0],
540            oom_using_mib=oom_using_mib,
541            timed_out_after_secs=timed_out_after_secs,
542        )
543
544    raise QueryResponseError(
545        response=response,
546        body=await response.text(),
547        cause=None,
548        kwargs=query_kwargs,
549        remarks=errors,
550    )
551
552
553async def __raise_for_plaintext_result(response: aiohttp.ClientResponse) -> None:
554    if response.content_type != "text/plain":
555        return
556    await _raise_for_response(response, cause=None)
557
558
559def __match_reject_cause(error_msg: str, query_logger: logging.Logger) -> QueryRejectCause | None:
560    """
561    Check if the given error message indicates that a query was rejected or cancelled.
562
563    AFAIK, neither the 'remarks' in JSON responses, nor the errors listed in HTML responses
564    are neatly listed somewhere, but it seems matching a small subset of remarks is enough
565    to identify recoverable errors.
566
567    References:
568        - Related: https://github.com/DinoTools/python-overpy/issues/62
569        - Examples in the API source: https://github.com/drolbr/Overpass-API/search?q=runtime_error
570    """
571    if "Please check /api/status for the quota of your IP address" in error_msg:
572        cause = QueryRejectCause.TOO_MANY_QUERIES
573
574    elif "The server is probably too busy to handle your request" in error_msg:
575        cause = QueryRejectCause.TOO_BUSY
576
577    elif "Query timed out" in error_msg:
578        cause = QueryRejectCause.EXCEEDED_TIMEOUT
579
580    elif "out of memory" in error_msg:
581        cause = QueryRejectCause.EXCEEDED_MAXSIZE
582
583    else:
584        cause_cls = QueryRejectCause.__class__.__name__
585        query_logger.debug(f"does not match any {cause_cls}: {error_msg!r}")
586        return None
587
588    query_logger.debug(f"matches {cause}: {error_msg!r}")
589    return cause
590
591
592def __match_oom_after(error_msg: str) -> int | None:
593    pattern = re.compile(
594        r"^runtime error: Query run out of memory in \".+\""
595        r" at line \d+ using about (\d+) MB of RAM\.$"
596    )
597    if m := pattern.match(error_msg):
598        mb = int(m.group(1))
599        return math.ceil((mb * 1000**2) / 1024**2)
600    return None
601
602
603def __match_timeout_after(error_msg: str) -> int | None:
604    pattern = re.compile(
605        r"^runtime error: Query timed out in \".+\" at line \d+ after (\d+) seconds\.$"
606    )
607    if m := pattern.match(error_msg):
608        return int(m.group(1))
609    return None
610
611
612def __is_ql_error(error_msg: str, query_logger: logging.Logger) -> bool:
613    """Check if the given error message indicates that a query has bad QL code."""
614    is_ql_error = (
615        "encoding error:" in error_msg
616        or "parse error:" in error_msg
617        or "static error:" in error_msg
618    )
619
620    if is_ql_error:
621        query_logger.debug(f"is a QL error: {error_msg!r}")
622        return True
623
624    query_logger.debug(f"not a QL error: {error_msg!r}")
625    return False
626
627
628def is_call_err(err: ClientError | None) -> TypeGuard[CallError]:
629    """``True`` if this is a ``CallError``."""
630    return isinstance(err, CallError)
631
632
633def is_call_timeout(err: ClientError | None) -> TypeGuard[CallTimeoutError]:
634    """``True`` if this is a ``CallTimeoutError``."""
635    return isinstance(err, CallTimeoutError)
636
637
638def is_giveup_err(err: ClientError | None) -> TypeGuard[GiveupError]:
639    """``True`` if this is a ``GiveupError``."""
640    return isinstance(err, GiveupError)
641
642
643def is_server_error(err: ClientError | None) -> TypeGuard[ResponseError]:
644    """``True`` if this is a ``ResponseError`` presumably cause by a server-side error."""
645    return isinstance(err, ResponseError) and err.is_server_error
646
647
648def is_rejection(err: ClientError | None) -> TypeGuard[QueryRejectError]:
649    """``True`` if this is a ``QueryRejectError``."""
650    return isinstance(err, QueryRejectError)
651
652
653def is_gateway_rejection(err: ClientError | None) -> TypeGuard[QueryRejectError]:
654    """``True`` if this is a ``QueryRejectError`` with gateway rejection."""
655    return isinstance(err, QueryRejectError) and err.cause in {
656        QueryRejectCause.TOO_MANY_QUERIES,
657        QueryRejectCause.TOO_BUSY,
658    }
659
660
661def is_too_many_queries(err: ClientError | None) -> TypeGuard[QueryRejectError]:
662    """``True`` if this is a ``QueryRejectError`` with cause ``TOO_MANY_QUERIES``."""
663    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.TOO_MANY_QUERIES
664
665
666def is_too_busy(err: ClientError | None) -> TypeGuard[QueryRejectError]:
667    """``True`` if this is a ``QueryRejectError`` with cause ``TOO_BUSY``."""
668    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.TOO_BUSY
669
670
671def is_runtime_rejection(err: ClientError | None) -> TypeGuard[QueryRejectError]:
672    """``True`` if this is a ``QueryRejectError`` with runtime rejection."""
673    return isinstance(err, QueryRejectError) and err.cause in {
674        QueryRejectCause.EXCEEDED_MAXSIZE,
675        QueryRejectCause.EXCEEDED_TIMEOUT,
676    }
677
678
679def is_exceeding_maxsize(err: ClientError | None) -> TypeGuard[QueryRejectError]:
680    """``True`` if this is a ``GiveupError`` with cause ``EXCEEDED_MAXSIZE``."""
681    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.EXCEEDED_MAXSIZE
682
683
684def is_exceeding_timeout(err: ClientError | None) -> TypeGuard[QueryRejectError]:
685    """``True`` if this is a ``GiveupError`` with cause ``EXCEEDED_TIMEOUT``."""
686    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.EXCEEDED_TIMEOUT
@dataclass(kw_only=True)
class AlreadyRunningError(builtins.ValueError):
62@dataclass(kw_only=True)
63class AlreadyRunningError(ValueError):
64    """
65    Raised when trying to run a query while it's already being run.
66
67    Attributes:
68        kwargs: the query's ``kwargs``
69    """
70
71    kwargs: dict
72
73    def __str__(self) -> str:
74        return f"query{self.kwargs!r} is already being run"

Raised when trying to run a query while it's already being run.

Attributes:
AlreadyRunningError(*, kwargs: dict)
kwargs: dict
class ClientError(builtins.Exception):
77class ClientError(Exception):
78    """Base exception for failed Overpass API requests and queries."""
79
80    @property
81    def should_retry(self) -> bool:
82        """Returns ``True`` if it's worth retrying when encountering this error."""
83        return False

Base exception for failed Overpass API requests and queries.

should_retry: bool
80    @property
81    def should_retry(self) -> bool:
82        """Returns ``True`` if it's worth retrying when encountering this error."""
83        return False

Returns True if it's worth retrying when encountering this error.

@dataclass(kw_only=True)
class CallError(ClientError):
110@dataclass(kw_only=True)
111class CallError(ClientError):
112    """
113    Failed to make an API request.
114
115    This error is raised when the client failed to get any response,
116    f.e. due to connection issues.
117
118    Attributes:
119        cause: the exception that caused this error
120    """
121
122    cause: aiohttp.ClientError | asyncio.TimeoutError
123
124    @property
125    def should_retry(self) -> bool:
126        """Returns ``True`` if it's worth retrying when encountering this error."""
127        return True
128
129    def __str__(self) -> str:
130        return str(self.cause)

Failed to make an API request.

This error is raised when the client failed to get any response, f.e. due to connection issues.

Attributes:
  • cause: the exception that caused this error
CallError(*, cause: aiohttp.client_exceptions.ClientError | TimeoutError)
cause: aiohttp.client_exceptions.ClientError | TimeoutError
should_retry: bool
124    @property
125    def should_retry(self) -> bool:
126        """Returns ``True`` if it's worth retrying when encountering this error."""
127        return True

Returns True if it's worth retrying when encountering this error.

@dataclass(kw_only=True)
class CallTimeoutError(CallError):
133@dataclass(kw_only=True)
134class CallTimeoutError(CallError):
135    """
136    An API request timed out.
137
138    Attributes:
139        cause: the exception that caused this error
140        after_secs: the configured timeout for the request
141    """
142
143    cause: asyncio.TimeoutError
144    after_secs: float
145
146    @property
147    def should_retry(self) -> bool:
148        """Returns ``True`` if it's worth retrying when encountering this error."""
149        return True
150
151    def __str__(self) -> str:
152        return str(self.cause)

An API request timed out.

Attributes:
  • cause: the exception that caused this error
  • after_secs: the configured timeout for the request
CallTimeoutError(*, cause: TimeoutError, after_secs: float)
cause: TimeoutError
after_secs: float
should_retry: bool
146    @property
147    def should_retry(self) -> bool:
148        """Returns ``True`` if it's worth retrying when encountering this error."""
149        return True

Returns True if it's worth retrying when encountering this error.

@dataclass(kw_only=True)
class ResponseError(ClientError):
159@dataclass(kw_only=True)
160class ResponseError(ClientError):
161    """
162    Unexpected API response.
163
164    On the one hand, this can be an error that happened on the Overpass API instance,
165    which is usually signalled by a status code ``>= 500``. In rare cases,
166    the ``cause`` being a ``JSONDecodeError`` also signals this, since it can happen
167    that the API returns a cutoff JSON response. In both of these cases,
168    ``is_server_error()`` will return ``True``.
169
170    On the other hand, this error may indicate that a request failed, but we can't
171    specifically say why. This could be a bug on our end, since the client
172    is meant to process almost any response of an Overpass API instance.
173    Here, ``is_server_error()`` will return ``False``, and the default query runner
174    will log the response body.
175
176    Attributes:
177        response: the unexpected response
178        body: the response body
179        cause: an optional exception that may have caused this error
180    """
181
182    response: aiohttp.ClientResponse
183    body: str
184    cause: ResponseErrorCause | None
185
186    @property
187    def should_retry(self) -> bool:
188        """Returns ``True`` if it's worth retrying when encountering this error."""
189        return True
190
191    @property
192    def is_server_error(self) -> bool:
193        """Returns ``True`` if this presumably a server-side error."""
194        # see class doc for details
195        return self.response.status >= 500 or isinstance(self.cause, JSONDecodeError)
196
197    def __str__(self) -> str:
198        if self.cause is None:
199            return f"unexpected response ({self.response.status})"
200        return f"unexpected response ({self.response.status}): {self.cause}"

Unexpected API response.

On the one hand, this can be an error that happened on the Overpass API instance, which is usually signalled by a status code >= 500. In rare cases, the cause being a JSONDecodeError also signals this, since it can happen that the API returns a cutoff JSON response. In both of these cases, is_server_error() will return True.

On the other hand, this error may indicate that a request failed, but we can't specifically say why. This could be a bug on our end, since the client is meant to process almost any response of an Overpass API instance. Here, is_server_error() will return False, and the default query runner will log the response body.

Attributes:
  • response: the unexpected response
  • body: the response body
  • cause: an optional exception that may have caused this error
ResponseError( *, response: aiohttp.client_reqrep.ClientResponse, body: str, cause: aiohttp.client_exceptions.ClientResponseError | json.decoder.JSONDecodeError | ValueError | None)
response: aiohttp.client_reqrep.ClientResponse
body: str
cause: aiohttp.client_exceptions.ClientResponseError | json.decoder.JSONDecodeError | ValueError | None
should_retry: bool
186    @property
187    def should_retry(self) -> bool:
188        """Returns ``True`` if it's worth retrying when encountering this error."""
189        return True

Returns True if it's worth retrying when encountering this error.

is_server_error: bool
191    @property
192    def is_server_error(self) -> bool:
193        """Returns ``True`` if this presumably a server-side error."""
194        # see class doc for details
195        return self.response.status >= 500 or isinstance(self.cause, JSONDecodeError)

Returns True if this presumably a server-side error.

ResponseErrorCause: TypeAlias = aiohttp.client_exceptions.ClientResponseError | json.decoder.JSONDecodeError | ValueError

Causes for a ResponseError.

@dataclass(kw_only=True)
class GiveupError(ClientError):
232@dataclass(kw_only=True)
233class GiveupError(ClientError):
234    """
235    The client spent or would spend too long running a query, and gave up.
236
237    This error is raised when the run timeout duration set by a query runner
238    is or would be exceeded.
239
240    Attributes:
241        kwargs: the query's ``kwargs``
242        after_secs: the total time spent on the query
243        cause: why the query was given up on
244    """
245
246    kwargs: dict
247    after_secs: float
248    cause: GiveupCause
249
250    @property
251    def should_retry(self) -> bool:
252        """Returns ``True`` if it's worth retrying when encountering this error."""
253        return False
254
255    def __str__(self) -> str:
256        return f"gave up on query{self.kwargs!r} after {self.after_secs:.01f} seconds"

The client spent or would spend too long running a query, and gave up.

This error is raised when the run timeout duration set by a query runner is or would be exceeded.

Attributes:
  • kwargs: the query's kwargs
  • after_secs: the total time spent on the query
  • cause: why the query was given up on
GiveupError( *, kwargs: dict, after_secs: float, cause: GiveupCause)
kwargs: dict
after_secs: float
cause: GiveupCause
should_retry: bool
250    @property
251    def should_retry(self) -> bool:
252        """Returns ``True`` if it's worth retrying when encountering this error."""
253        return False

Returns True if it's worth retrying when encountering this error.

class GiveupCause(enum.Enum):
203class GiveupCause(Enum):
204    """Details on why a query was given up on."""
205
206    RUN_TIMEOUT_BEFORE_STATUS_CALL = auto()
207    """Query's ``run_timeout_secs`` elapsed before a status call before the actual query call."""
208
209    RUN_TIMEOUT_BY_COOLDOWN = auto()
210    """Query received a timeout that would exceed its remaining ``run_timeout_secs``."""
211
212    RUN_TIMEOUT_BEFORE_QUERY_CALL = auto()
213    """Query's ``run_timeout_secs`` elapsed before the query call."""
214
215    EXPECTING_QUERY_TIMEOUT = auto()
216    """
217    The query's limited [timeout:*] setting is likely too low.
218
219    A query's [timeout:*] setting might be lowered by the client to the remainder of
220    ``run_timeout_secs``. If that happens, and that duration is also lower than a duration
221    after which it was cancelled at the server previously, then this is the cause to give
222    up on the query.
223
224    See Also:
225        ``QueryRejectError.timed_out_after_secs``
226    """
227
228    RUN_TIMEOUT_DURING_QUERY_CALL = auto()
229    """Query's ``run_timeout_secs`` elapsed while making the query call, before any response."""

Details on why a query was given up on.

RUN_TIMEOUT_BEFORE_STATUS_CALL = <GiveupCause.RUN_TIMEOUT_BEFORE_STATUS_CALL: 1>

Query's run_timeout_secs elapsed before a status call before the actual query call.

RUN_TIMEOUT_BY_COOLDOWN = <GiveupCause.RUN_TIMEOUT_BY_COOLDOWN: 2>

Query received a timeout that would exceed its remaining run_timeout_secs.

RUN_TIMEOUT_BEFORE_QUERY_CALL = <GiveupCause.RUN_TIMEOUT_BEFORE_QUERY_CALL: 3>

Query's run_timeout_secs elapsed before the query call.

EXPECTING_QUERY_TIMEOUT = <GiveupCause.EXPECTING_QUERY_TIMEOUT: 4>

The query's limited [timeout:*] setting is likely too low.

A query's [timeout:*] setting might be lowered by the client to the remainder of run_timeout_secs. If that happens, and that duration is also lower than a duration after which it was cancelled at the server previously, then this is the cause to give up on the query.

See Also:

QueryRejectError.timed_out_after_secs

RUN_TIMEOUT_DURING_QUERY_CALL = <GiveupCause.RUN_TIMEOUT_DURING_QUERY_CALL: 5>

Query's run_timeout_secs elapsed while making the query call, before any response.

@dataclass(kw_only=True)
class QueryError(ClientError):
259@dataclass(kw_only=True)
260class QueryError(ClientError):
261    """
262    Base exception for queries that failed at the Overpass API server.
263
264    Attributes:
265        kwargs: the query's ``kwargs``
266        remarks: the error remarks provided by the API
267    """
268
269    kwargs: dict
270    remarks: list[str]
271
272    @property
273    def should_retry(self) -> bool:
274        """Returns ``True`` if it's worth retrying when encountering this error."""
275        return False
276
277    def __str__(self) -> str:
278        first = f"'{self.remarks[0]}'"
279        rest = f" (+{len(self.remarks) - 1} more)" if len(self.remarks) > 1 else ""
280        return f"query{self.kwargs!r} failed: {first}{rest}"

Base exception for queries that failed at the Overpass API server.

Attributes:
  • kwargs: the query's kwargs
  • remarks: the error remarks provided by the API
QueryError(*, kwargs: dict, remarks: list[str])
kwargs: dict
remarks: list[str]
should_retry: bool
272    @property
273    def should_retry(self) -> bool:
274        """Returns ``True`` if it's worth retrying when encountering this error."""
275        return False

Returns True if it's worth retrying when encountering this error.

@dataclass(kw_only=True)
class QueryLanguageError(QueryError):
306@dataclass(kw_only=True)
307class QueryLanguageError(QueryError):
308    """
309    Indicates the query's QL code is not valid.
310
311    Retrying is pointless when encountering this error.
312    """
313
314    @property
315    def should_retry(self) -> bool:
316        """Returns ``True`` if it's worth retrying when encountering this error."""
317        return False

Indicates the query's QL code is not valid.

Retrying is pointless when encountering this error.

QueryLanguageError(*, kwargs: dict, remarks: list[str])
should_retry: bool
314    @property
315    def should_retry(self) -> bool:
316        """Returns ``True`` if it's worth retrying when encountering this error."""
317        return False

Returns True if it's worth retrying when encountering this error.

Inherited Members
QueryError
kwargs
remarks
@dataclass(kw_only=True)
class QueryRejectError(QueryError):
371@dataclass(kw_only=True)
372class QueryRejectError(QueryError):
373    """
374    A query was rejected or cancelled by the API server.
375
376    Attributes:
377        cause: why the query was rejected or cancelled
378        timed_out_after_secs: if ``cause`` is ``EXCEEDED_TIMEOUT``, this is the amount
379                              of seconds after which the query timed out
380        oom_using_mib: if ```cause`` is ``EXCEEDED_MAXSIZE``, this is the amount of RAM
381                       the query used before it ran out
382    """
383
384    cause: QueryRejectCause
385    timed_out_after_secs: int | None
386    oom_using_mib: int | None
387
388    @property
389    def should_retry(self) -> bool:
390        """Returns ``True`` if it's worth retrying when encountering this error."""
391        return True
392
393    def __str__(self) -> str:
394        match self.cause:
395            case QueryRejectCause.TOO_BUSY | QueryRejectCause.TOO_MANY_QUERIES:
396                rejection = "rejected"
397            case QueryRejectCause.EXCEEDED_TIMEOUT:
398                rejection = f"cancelled after {self.timed_out_after_secs}s"
399            case QueryRejectCause.EXCEEDED_MAXSIZE:
400                rejection = f"cancelled using {self.oom_using_mib}"
401            case _:
402                raise AssertionError(self.cause)
403
404        return f"{rejection}: {self.cause}"

A query was rejected or cancelled by the API server.

Attributes:
  • cause: why the query was rejected or cancelled
  • timed_out_after_secs: if cause is EXCEEDED_TIMEOUT, this is the amount of seconds after which the query timed out
  • oom_using_mib: if `cause is EXCEEDED_MAXSIZE, this is the amount of RAM the query used before it ran out
QueryRejectError( *, kwargs: dict, remarks: list[str], cause: QueryRejectCause, timed_out_after_secs: int | None, oom_using_mib: int | None)
timed_out_after_secs: int | None
oom_using_mib: int | None
should_retry: bool
388    @property
389    def should_retry(self) -> bool:
390        """Returns ``True`` if it's worth retrying when encountering this error."""
391        return True

Returns True if it's worth retrying when encountering this error.

Inherited Members
QueryError
kwargs
remarks
class QueryRejectCause(enum.Enum):
320class QueryRejectCause(Enum):
321    """Details why a query was rejected or cancelled by an API server."""
322
323    TOO_BUSY = auto()
324    """
325    Gateway rejection. The server has already so much load that the request cannot be executed.
326
327    Smaller ``[timeout:*]`` and/or ``[maxsize:*]`` values might make the request acceptable.
328    """
329
330    TOO_MANY_QUERIES = auto()
331    """
332    Gateway rejection. There are no open slots for queries from your IP address.
333
334    Running queries take up a slot, and the number of slots is limited. A client
335    will only run as many concurrent requests as there are slots, which should make this
336    a rare error, assuming you are not making requests through another client.
337    """
338
339    EXCEEDED_TIMEOUT = auto()
340    """
341    Runtime rejection. The query has been (or surely will be) running longer than its proposed
342    ``[timeout:*]``, and has been cancelled by the server.
343
344    A higher ``[timeout:*]`` value might allow the query run to completion, but also makes it
345    more likely to be rejected by a server under heavy load, before executing it (see ``TOO_BUSY``).
346    """
347
348    EXCEEDED_MAXSIZE = auto()
349    """
350    Runtime rejection. The memory required to execute the query has (or surely will) exceed
351    its proposed ``[maxsize:*]``, and has been cancelled by the server.
352
353    A higher ``[maxsize:*]`` value might allow the query run to completion, but also makes it
354    more likely to be rejected by a server under heavy load, before executing it (see ``TOO_BUSY``).
355    """
356
357    def __str__(self) -> str:
358        match self:
359            case QueryRejectCause.TOO_BUSY:
360                return "server too busy"
361            case QueryRejectCause.TOO_MANY_QUERIES:
362                return "too many queries"
363            case QueryRejectCause.EXCEEDED_TIMEOUT:
364                return "exceeded 'timeout'"
365            case QueryRejectCause.EXCEEDED_MAXSIZE:
366                return "exceeded 'maxsize'"
367            case _:
368                raise AssertionError

Details why a query was rejected or cancelled by an API server.

TOO_BUSY = <QueryRejectCause.TOO_BUSY: 1>

Gateway rejection. The server has already so much load that the request cannot be executed.

Smaller [timeout:*] and/or [maxsize:*] values might make the request acceptable.

TOO_MANY_QUERIES = <QueryRejectCause.TOO_MANY_QUERIES: 2>

Gateway rejection. There are no open slots for queries from your IP address.

Running queries take up a slot, and the number of slots is limited. A client will only run as many concurrent requests as there are slots, which should make this a rare error, assuming you are not making requests through another client.

EXCEEDED_TIMEOUT = <QueryRejectCause.EXCEEDED_TIMEOUT: 3>

Runtime rejection. The query has been (or surely will be) running longer than its proposed [timeout:*], and has been cancelled by the server.

A higher [timeout:*] value might allow the query run to completion, but also makes it more likely to be rejected by a server under heavy load, before executing it (see TOO_BUSY).

EXCEEDED_MAXSIZE = <QueryRejectCause.EXCEEDED_MAXSIZE: 4>

Runtime rejection. The memory required to execute the query has (or surely will) exceed its proposed [maxsize:*], and has been cancelled by the server.

A higher [maxsize:*] value might allow the query run to completion, but also makes it more likely to be rejected by a server under heavy load, before executing it (see TOO_BUSY).

@dataclass(kw_only=True)
class QueryResponseError(ResponseError, QueryError):
283@dataclass(kw_only=True)
284class QueryResponseError(ResponseError, QueryError):
285    """
286    Unexpected query response.
287
288    This error is raised when a query fails (thus extends ``QueryError``),
289    but we can't specifically say why (thus also extends ``ResponseError``).
290    """
291
292    @property
293    def should_retry(self) -> bool:
294        """Returns ``True`` if it's worth retrying when encountering this error."""
295        return ResponseError.should_retry.fget(self)  # type: ignore[attr-defined]
296
297    def __str__(self) -> str:
298        if self.remarks:
299            first = f"'{self.remarks[0]}'"
300            rest = f" (+{len(self.remarks) - 1} more)" if len(self.remarks) > 1 else ""
301            return f"query{self.kwargs!r} failed: {first}{rest}"
302
303        return f"query{self.kwargs!r} failed with status {self.response.status}"

Unexpected query response.

This error is raised when a query fails (thus extends QueryError), but we can't specifically say why (thus also extends ResponseError).

QueryResponseError( *, kwargs: dict, remarks: list[str], response: aiohttp.client_reqrep.ClientResponse, body: str, cause: aiohttp.client_exceptions.ClientResponseError | json.decoder.JSONDecodeError | ValueError | None)
should_retry: bool
292    @property
293    def should_retry(self) -> bool:
294        """Returns ``True`` if it's worth retrying when encountering this error."""
295        return ResponseError.should_retry.fget(self)  # type: ignore[attr-defined]

Returns True if it's worth retrying when encountering this error.

@dataclass(kw_only=True)
class RunnerError(ClientError):
 86@dataclass(kw_only=True)
 87class RunnerError(ClientError):
 88    """
 89    The query runner raised an exception.
 90
 91    This is an unexpected error, in contrast to the runner intentionally
 92    raising ``query.error`` to stop retrying. The cause of this error
 93    is therefore anything but a ``ClientError``.
 94
 95    Attributes:
 96        cause: the exception that caused this error
 97    """
 98
 99    cause: BaseException
100
101    @property
102    def should_retry(self) -> bool:
103        """Returns ``True`` if it's worth retrying when encountering this error."""
104        return False
105
106    def __str__(self) -> str:
107        return str(self.cause)

The query runner raised an exception.

This is an unexpected error, in contrast to the runner intentionally raising query.error to stop retrying. The cause of this error is therefore anything but a ClientError.

Attributes:
  • cause: the exception that caused this error
RunnerError(*, cause: BaseException)
cause: BaseException
should_retry: bool
101    @property
102    def should_retry(self) -> bool:
103        """Returns ``True`` if it's worth retrying when encountering this error."""
104        return False

Returns True if it's worth retrying when encountering this error.

def is_call_err( err: ClientError | None) -> TypeGuard[CallError]:
629def is_call_err(err: ClientError | None) -> TypeGuard[CallError]:
630    """``True`` if this is a ``CallError``."""
631    return isinstance(err, CallError)

True if this is a CallError.

def is_call_timeout( err: ClientError | None) -> TypeGuard[CallTimeoutError]:
634def is_call_timeout(err: ClientError | None) -> TypeGuard[CallTimeoutError]:
635    """``True`` if this is a ``CallTimeoutError``."""
636    return isinstance(err, CallTimeoutError)

True if this is a CallTimeoutError.

def is_exceeding_maxsize( err: ClientError | None) -> TypeGuard[QueryRejectError]:
680def is_exceeding_maxsize(err: ClientError | None) -> TypeGuard[QueryRejectError]:
681    """``True`` if this is a ``GiveupError`` with cause ``EXCEEDED_MAXSIZE``."""
682    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.EXCEEDED_MAXSIZE

True if this is a GiveupError with cause EXCEEDED_MAXSIZE.

def is_exceeding_timeout( err: ClientError | None) -> TypeGuard[QueryRejectError]:
685def is_exceeding_timeout(err: ClientError | None) -> TypeGuard[QueryRejectError]:
686    """``True`` if this is a ``GiveupError`` with cause ``EXCEEDED_TIMEOUT``."""
687    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.EXCEEDED_TIMEOUT

True if this is a GiveupError with cause EXCEEDED_TIMEOUT.

def is_gateway_rejection( err: ClientError | None) -> TypeGuard[QueryRejectError]:
654def is_gateway_rejection(err: ClientError | None) -> TypeGuard[QueryRejectError]:
655    """``True`` if this is a ``QueryRejectError`` with gateway rejection."""
656    return isinstance(err, QueryRejectError) and err.cause in {
657        QueryRejectCause.TOO_MANY_QUERIES,
658        QueryRejectCause.TOO_BUSY,
659    }

True if this is a QueryRejectError with gateway rejection.

def is_giveup_err( err: ClientError | None) -> TypeGuard[GiveupError]:
639def is_giveup_err(err: ClientError | None) -> TypeGuard[GiveupError]:
640    """``True`` if this is a ``GiveupError``."""
641    return isinstance(err, GiveupError)

True if this is a GiveupError.

def is_rejection( err: ClientError | None) -> TypeGuard[QueryRejectError]:
649def is_rejection(err: ClientError | None) -> TypeGuard[QueryRejectError]:
650    """``True`` if this is a ``QueryRejectError``."""
651    return isinstance(err, QueryRejectError)

True if this is a QueryRejectError.

def is_runtime_rejection( err: ClientError | None) -> TypeGuard[QueryRejectError]:
672def is_runtime_rejection(err: ClientError | None) -> TypeGuard[QueryRejectError]:
673    """``True`` if this is a ``QueryRejectError`` with runtime rejection."""
674    return isinstance(err, QueryRejectError) and err.cause in {
675        QueryRejectCause.EXCEEDED_MAXSIZE,
676        QueryRejectCause.EXCEEDED_TIMEOUT,
677    }

True if this is a QueryRejectError with runtime rejection.

def is_server_error( err: ClientError | None) -> TypeGuard[ResponseError]:
644def is_server_error(err: ClientError | None) -> TypeGuard[ResponseError]:
645    """``True`` if this is a ``ResponseError`` presumably cause by a server-side error."""
646    return isinstance(err, ResponseError) and err.is_server_error

True if this is a ResponseError presumably cause by a server-side error.

def is_too_busy( err: ClientError | None) -> TypeGuard[QueryRejectError]:
667def is_too_busy(err: ClientError | None) -> TypeGuard[QueryRejectError]:
668    """``True`` if this is a ``QueryRejectError`` with cause ``TOO_BUSY``."""
669    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.TOO_BUSY

True if this is a QueryRejectError with cause TOO_BUSY.

def is_too_many_queries( err: ClientError | None) -> TypeGuard[QueryRejectError]:
662def is_too_many_queries(err: ClientError | None) -> TypeGuard[QueryRejectError]:
663    """``True`` if this is a ``QueryRejectError`` with cause ``TOO_MANY_QUERIES``."""
664    return isinstance(err, QueryRejectError) and err.cause is QueryRejectCause.TOO_MANY_QUERIES

True if this is a QueryRejectError with cause TOO_MANY_QUERIES.