summaryrefslogtreecommitdiffstats
path: root/cepis/errors.lua
blob: 0076b951c39f305aae45a411540a1085465885fb (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
--- Error values returned by the `nil, err` convention.
--
-- Every failure this library expects to happen is returned, never thrown:
-- `local address, err = client:lookup(cep)`. Only programmer mistakes — a bad
-- option table, a wrong argument type where no sensible value exists — raise
-- through `error()`.
--
-- An error is a table with a `kind` field, so a caller branches on one string
-- instead of matching messages. The split is by **the decision the caller has
-- to make**, not by the HTTP status that caused it:
--
-- - `invalid_cep` and `not_found` are problems with the *request*. Retrying is
--   pointless — the same CEP will fail again.
-- - `network_error`, `server_error` and `rate_limited` are problems with
--   *transport*. Retrying may help, and the client already retried on its own
--   before returning any of them.
--
-- That split is why `not_found` is not "the HTTP 404 error": the client also
-- returns `invalid_cep` without making any request at all, when the CEP is
-- malformed before it ever leaves the machine.
--
-- @module cepis.errors

local M = {}

--- The CEP is not 8 digits. Usually returned without any request being made.
M.INVALID_CEP = "invalid_cep"

--- The CEP is well-formed but does not exist in the DNE database.
M.NOT_FOUND = "not_found"

--- The API (or the edge in front of it) answered 429 on every attempt.
M.RATE_LIMITED = "rate_limited"

--- The API answered 5xx on every attempt.
M.SERVER_ERROR = "server_error"

--- The request never completed: DNS, refused connection, timeout, TLS.
M.NETWORK_ERROR = "network_error"

--- 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.
M.INVALID_RESPONSE = "invalid_response"

--- Every kind, keyed by itself, for validation and for iterating.
M.kinds = {
  [M.INVALID_CEP] = true,
  [M.NOT_FOUND] = true,
  [M.RATE_LIMITED] = true,
  [M.SERVER_ERROR] = true,
  [M.NETWORK_ERROR] = true,
  [M.INVALID_RESPONSE] = true,
}

local RETRIABLE = {
  [M.RATE_LIMITED] = true,
  [M.SERVER_ERROR] = true,
  [M.NETWORK_ERROR] = true,
}

--- Metatable of every error this library returns.
-- @type Error
local Error = {}
Error.__index = Error
Error.__name = "cepis.Error"

function Error.__tostring(self)
  return self.message
end

--- Whether trying the same call again later could plausibly succeed.
--
-- True for `rate_limited`, `server_error` and `network_error`; false for
-- `invalid_cep`, `not_found` and `invalid_response`. The client has already
-- exhausted its own attempts by the time it returns any of them, so this
-- answers "should I reschedule this work?", not "should I loop right now?".
-- @treturn boolean
function Error:is_retriable()
  return RETRIABLE[self.kind] == true
end

M.Error = Error

--- Build an error value.
--
-- @tparam string kind one of the six constants in this module
-- @tparam string message human-readable, already complete
-- @tparam[opt] table fields extra context: `cep`, `status`, `retry_after`
-- @treturn table the error, with `tostring()` yielding `message`
function M.new(kind, message, fields)
  local err = setmetatable({}, Error)
  if fields then
    for key, value in pairs(fields) do
      err[key] = value
    end
  end
  err.kind = kind
  err.message = message
  return err
end

--- Whether a value is one of this library's errors.
--
-- Use it to tell a `cepis` error apart from any other value a `nil, err`
-- boundary might hand you.
-- @param value anything
-- @treturn boolean
function M.is(value)
  return getmetatable(value) == Error
end

return M