summaryrefslogtreecommitdiffstats
path: root/Sources/Cepis/Errors.swift
blob: 089121ddc644ac77ce376edfcf347b74d920360a (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
import Foundation

/// Every failure this library produces.
///
/// One type, so `catch let error as CepisError` handles anything that can go
/// wrong. The cases split by **the decision the caller has to make**, not by
/// the HTTP status that caused them:
///
/// - ``invalidCEP(_:)`` and ``cepNotFound(message:faixa:)`` are problems with
///   the *request*. Retrying is pointless — the same CEP will fail again.
/// - ``networkError(message:)``, ``serverError(message:status:)`` and
///   ``rateLimited(message:retryAfter:)`` are problems with *transport*.
///   Retrying may help, and the client already retried on its own before
///   throwing any of them.
///
/// That split is why ``cepNotFound(message:faixa:)`` is not "the HTTP 404
/// case": nothing about it is tied to a status line.
public enum CepisError: Error, Sendable {
    /// The CEP is not 8 digits.
    ///
    /// Usually thrown **without** a request ever being made — the client
    /// checks the format before going to the network. Also thrown when the
    /// API itself answers 400, in which case the payload carries the API's own
    /// message.
    case invalidCEP(String)

    /// The CEP is well-formed but does not exist in the DNE database.
    ///
    /// `faixa` names the municipality the CEP *would* have belonged to, when
    /// the API can say — see ``Faixa``. It is `nil` whenever the API omits it,
    /// which happens for a CEP in a band no municipality owns, and whenever
    /// what came back could not be understood. It never changes the meaning of
    /// the case: the CEP does not exist either way.
    case cepNotFound(message: String, faixa: Faixa?)

    /// The API (or the edge in front of it) answered 429 on every attempt.
    ///
    /// `retryAfter` is what the server asked for, in seconds, 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.
    case rateLimited(message: String, retryAfter: TimeInterval?)

    /// The API answered 5xx on every attempt. `status` is the last one seen.
    case serverError(message: String, status: Int)

    /// The request never completed: DNS, refused connection, timeout, TLS.
    ///
    /// Thrown only after every configured attempt has failed.
    case networkError(message: String)

    /// 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.
    case invalidResponse(String)

    /// The human-readable message carried by the case.
    public var message: String {
        switch self {
        case .invalidCEP(let message): return message
        case .cepNotFound(let message, _): return message
        case .rateLimited(let message, _): return message
        case .serverError(let message, _): return message
        case .networkError(let message): return message
        case .invalidResponse(let message): return message
        }
    }

    /// The municipality the missing CEP would have belonged to, when known.
    ///
    /// `nil` for every case other than ``cepNotFound(message:faixa:)``, so a
    /// caller can reach for it without pattern-matching the enum first.
    public var faixa: Faixa? {
        guard case .cepNotFound(_, let faixa) = self else { return nil }
        return faixa
    }

    /// Whether making the same call again later stands a chance of succeeding.
    ///
    /// `true` for rate limiting, server errors and network failures; `false`
    /// for a malformed or absent CEP, which will fail identically forever.
    public var isRetriable: Bool {
        switch self {
        case .rateLimited, .serverError, .networkError: return true
        case .invalidCEP, .cepNotFound, .invalidResponse: return false
        }
    }
}

extension CepisError: CustomStringConvertible {
    /// ``message``, plus the municipality when a ``Faixa`` came with the
    /// not-found.
    ///
    /// The addition is the whole point of the faixa: *"CEP 25845000 does not
    /// exist in the DNE database (that range is Areal/RJ)"* tells the person at
    /// the keyboard they typed the right city and the wrong number, which
    /// *"does not exist"* on its own does not.
    ///
    /// ``message`` stays exactly what it was, for callers that log or match on
    /// it.
    public var description: String {
        guard let faixa else { return message }
        return "\(message) (that range is \(faixa.localidadeUF))"
    }
}

extension CepisError: LocalizedError {
    public var errorDescription: String? { description }
}