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 } }