summaryrefslogtreecommitdiffstats
path: root/src/cepis/errors.py
blob: 1698972aba9ca534f3c29cf4a10ceb25b43bb1a1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
"""Exception hierarchy.

Everything derives from :class:`CepisError`, so callers who only care that
*something* went wrong can catch one type.

Below that, the split is by **the decision the caller has to make**, not by the
HTTP status that caused it:

- :class:`InvalidCEP` and :class:`CEPNotFound` are problems with the *request*.
  Retrying is pointless — the same CEP will fail again.
- :class:`NetworkError`, :class:`ServerError` and :class:`RateLimited` are
  problems with *transport*. Retrying may help, and the client already retried
  on its own before raising any of them.

That split is why `CEPNotFound` is not "the HTTP 404 exception": the client
also raises it without making any request at all, when the CEP is malformed
before it ever leaves the machine.
"""

from __future__ import annotations


class CepisError(Exception):
    """Base of every error. Catch this to handle any failure."""


class InvalidCEP(CepisError, ValueError):
    """The CEP is not 8 digits.

    Deliberately also a :class:`ValueError`: this is an argument error, and
    code that already handles `ValueError` while validating input keeps
    working without knowing this library exists.

    Usually raised **without** a request ever being made — the client checks
    the format before going to the network.
    """


class CEPNotFound(CepisError, KeyError):
    """The CEP is well-formed but does not exist in the DNE database.

    Also a :class:`KeyError`, by the same reasoning as `InvalidCEP`: looking a
    CEP up is conceptually a key lookup.

    `KeyError.__str__` wraps the message in quotes (inherited from dict
    behaviour), which is why `__str__` is overridden here — otherwise every
    message would come out with spurious quotes around it.
    """

    def __str__(self) -> str:
        return self.args[0] if self.args else ""


class NetworkError(CepisError):
    """The request never completed: DNS, refused connection, timeout, TLS.

    Raised only after every configured attempt has failed.
    """


class ServerError(CepisError):
    """The API answered 5xx on every attempt.

    `status` holds the HTTP status seen on the last attempt.
    """

    def __init__(self, message: str, status: int) -> None:
        super().__init__(message)
        self.status = status


class RateLimited(CepisError):
    """The API (or the edge in front of it) answered 429.

    `retry_after` is what the server asked for, in seconds, or `None` if it
    did not say. The client already honoured that value across the attempts it
    made; it is exposed here for callers who want to reschedule the work.
    """

    def __init__(self, message: str, retry_after: float | None = None) -> None:
        super().__init__(message)
        self.retry_after = retry_after


class InvalidResponse(CepisError):
    """A success status came back with a body that cannot be parsed.

    In practice this means something between you and the API is answering
    instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error
    page. Useful as a diagnosis: the problem is not in your code.
    """