summaryrefslogtreecommitdiffstats
path: root/lib/cepis/error.ex
blob: bd86b651e230036ad2169b5feaafc9b0f595368d (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
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