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.
"""
|