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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
|
defmodule Cepis.InvalidCEPError do
@moduledoc """
The CEP is not 8 digits.
Usually produced **without** a request ever being made — the client checks
the format before going to the network. It is also what a `400` from the API
becomes, carrying the API's own message: the format already passed local
validation, so the server knows something this client does not.
Retrying does not help.
"""
defexception [:message]
@type t :: %__MODULE__{message: String.t()}
end
defmodule Cepis.NotFoundError do
@moduledoc """
The CEP is well-formed but does not exist in the DNE database.
`cep` holds the normalised 8 digits that were looked up. Retrying does not
help: the answer is definitive.
"""
defexception [:message, :cep]
@type t :: %__MODULE__{message: String.t(), cep: String.t()}
end
defmodule Cepis.NetworkError do
@moduledoc """
The request never completed: DNS, refused connection, timeout, TLS.
`reason` is the underlying `:httpc` reason. Produced only after every
configured attempt has failed, so retrying immediately is unlikely to help —
reschedule instead.
"""
defexception [:message, :reason]
@type t :: %__MODULE__{message: String.t(), reason: term()}
end
defmodule Cepis.ServerError do
@moduledoc """
The API answered 5xx on every attempt.
`status` holds the HTTP status seen on the last attempt. Retrying later may
help; the client already retried on its own before returning this.
"""
defexception [:message, :status]
@type t :: %__MODULE__{message: String.t(), status: pos_integer()}
end
defmodule Cepis.RateLimitError do
@moduledoc """
The API (or the edge in front of it) answered 429 on every attempt.
`retry_after` is what the server asked for, **in milliseconds**, or `nil` 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, e.g.
with `Process.send_after/3`.
"""
defexception [:message, :retry_after]
@type t :: %__MODULE__{message: String.t(), retry_after: non_neg_integer() | nil}
end
defmodule Cepis.InvalidResponseError do
@moduledoc """
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.
"""
defexception [:message]
@type t :: %__MODULE__{message: String.t()}
end
defmodule Cepis.Error do
@moduledoc """
The error surface, and how to tell the two kinds apart.
Every function in `Cepis` returns `{:ok, result}` or `{:error, error}`, where
`error` is one of the exception structs listed in `t:t/0`. Matching on
`{:error, error}` is therefore the one handler that catches everything; the
bang variants raise the same structs instead.
Below that, the split is by **the decision the caller has to make**, not by
the HTTP status that caused it:
- `Cepis.InvalidCEPError` and `Cepis.NotFoundError` are problems with the
*request*. Retrying is pointless — the same CEP will fail again.
- `Cepis.NetworkError`, `Cepis.ServerError` and `Cepis.RateLimitError` are
problems with *transport*. Retrying may help, and the client already
retried on its own before returning any of them.
That split is why `Cepis.NotFoundError` is not "the HTTP 404 error": a
malformed CEP is rejected before it ever leaves the machine, with no request
made at all.
"""
@exceptions [
Cepis.InvalidCEPError,
Cepis.NotFoundError,
Cepis.NetworkError,
Cepis.ServerError,
Cepis.RateLimitError,
Cepis.InvalidResponseError
]
@type t ::
Cepis.InvalidCEPError.t()
| Cepis.NotFoundError.t()
| Cepis.NetworkError.t()
| Cepis.ServerError.t()
| Cepis.RateLimitError.t()
| Cepis.InvalidResponseError.t()
@doc """
Every exception module this library produces.
Useful for building a `rescue` clause around the bang variants:
rescue
error in [Cepis.NotFoundError, Cepis.InvalidCEPError] -> ...
end
"""
@spec exceptions() :: [module()]
def exceptions, do: @exceptions
@doc """
Whether `term` is one of this library's errors.
"""
@spec error?(term()) :: boolean()
def error?(%module{}), do: module in @exceptions
def error?(_), do: false
@doc """
Whether retrying the same call later could plausibly succeed.
`true` for transport failures (network, 5xx, 429), `false` for definitive
answers about the request itself.
"""
@spec retriable?(t()) :: boolean()
def retriable?(%Cepis.NetworkError{}), do: true
def retriable?(%Cepis.ServerError{}), do: true
def retriable?(%Cepis.RateLimitError{}), do: true
def retriable?(_), do: false
end
|