//! The error set, and the diagnostics that go with it. //! //! Everything the library can fail with is one `Error`, so a caller who only //! cares that *something* went wrong catches one set and is done. //! //! Below that, the split is by **the decision the caller has to make**, not by //! the HTTP status that caused it: //! //! - `error.InvalidCep` and `error.NotFound` are problems with the *request*. //! Retrying is pointless — the same CEP will fail again. //! - `error.NetworkError`, `error.ServerError` and `error.RateLimited` are //! problems with *transport*. Retrying may help, and the client already //! retried on its own before returning any of them. //! //! That split is why `error.NotFound` is not "the HTTP 404 error": the client //! also returns `error.InvalidCep` without making any request at all, when the //! CEP is malformed before it ever leaves the machine. //! //! A Zig error carries no payload, so anything a caller may need beyond the //! tag — the HTTP status, the `Retry-After` the server asked for, the API's own //! message — travels in an optional `Diagnostics` the caller passes in. const std = @import("std"); /// Everything that can go wrong when talking to the API, minus allocation /// failure. Use `Error` in signatures; use this when you want to match /// exhaustively without `error.OutOfMemory` in the way. pub const CepisError = error{ /// The CEP is not 8 digits. /// /// Usually returned **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, in which case `Diagnostics.message` carries the /// API's own wording. InvalidCep, /// The CEP is well-formed but does not exist in the DNE database. NotFound, /// The API (or the edge in front of it) answered 429 on every attempt. /// /// `Diagnostics.retry_after_ms` holds what the server asked for, when it /// said. The client already honoured it across the attempts it made; it is /// reported for callers who want to reschedule the work. RateLimited, /// The API answered 5xx on every attempt. `Diagnostics.status` holds the /// status seen on the last one. ServerError, /// The request never completed: DNS, refused connection, timeout, TLS. /// /// Returned only after every configured attempt has failed. NetworkError, /// 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. InvalidResponse, }; /// The error set of every fallible operation in this library. pub const Error = CepisError || std.mem.Allocator.Error; /// Whether trying the same call again later could plausibly succeed. /// /// True for `RateLimited`, `ServerError` and `NetworkError`; false for a bad or /// unknown CEP, which fails the same way forever. `OutOfMemory` is not /// retriable in any useful sense and reports false. /// /// The client has already retried internally by the time any of these are /// returned, so "later" means later — not immediately. pub fn isRetriable(err: Error) bool { return switch (err) { error.RateLimited, error.ServerError, error.NetworkError => true, else => false, }; } /// Optional out-parameter carrying the detail a Zig error tag cannot. /// /// Declare one on the stack, pass a pointer, and read it after the call fails: /// /// ```zig /// var diag: cepis.Diagnostics = .{}; /// const address = client.lookup("01001000", .{ .diagnostics = &diag }) catch |err| switch (err) { /// error.RateLimited => return retryIn(diag.retry_after_ms orelse 60_000), /// else => return err, /// }; /// ``` /// /// It owns no memory and needs no `deinit`: the message lives in a fixed /// buffer inside the struct, and is only valid as long as the `Diagnostics` /// itself. Copy it if you need it to outlive the value. /// /// The fields describe the last response the call saw, so `status` is also /// filled after a call that succeeded. They are only *meaningful* after a /// failure. Reusing one `Diagnostics` across calls without `reset` can show /// stale detail. pub const Diagnostics = struct { /// The HTTP status of the last attempt, when there was a response at all. status: ?u16 = null, /// What the server asked for via `Retry-After`, in milliseconds, or null /// if it did not say. Already capped at `Config.backoff_max_ms` by the /// waits the client performed, but reported here uncapped. retry_after_ms: ?u64 = null, /// How many attempts were made before giving up. attempts: u32 = 0, /// Backing storage for `message`. Internal; read it through `message`. message_buffer: [max_message_len]u8 = undefined, /// Length of the text in `message_buffer`. Internal. message_len: usize = 0, /// The longest explanation a `Diagnostics` will keep. Longer text is /// truncated rather than allocated. pub const max_message_len = 512; /// A human-readable explanation of the failure, borrowed from the /// `Diagnostics` and valid only as long as it is. Empty when none was set. pub fn message(d: *const Diagnostics) []const u8 { return d.message_buffer[0..d.message_len]; } /// Clear every field, so the value can be reused for another call. pub fn reset(d: *Diagnostics) void { d.status = null; d.retry_after_ms = null; d.attempts = 0; d.message_len = 0; } /// Set the message, truncating at `max_message_len`. Internal. pub fn setMessage(d: *Diagnostics, comptime fmt: []const u8, args: anytype) void { var writer: std.Io.Writer = .fixed(&d.message_buffer); writer.print(fmt, args) catch {}; d.message_len = writer.end; } }; /// Write to `maybe_diagnostics` when the caller supplied one. Internal. pub fn note(maybe_diagnostics: ?*Diagnostics, comptime fmt: []const u8, args: anytype) void { const d = maybe_diagnostics orelse return; d.setMessage(fmt, args); } test "isRetriable splits by decision" { try std.testing.expect(isRetriable(error.NetworkError)); try std.testing.expect(isRetriable(error.ServerError)); try std.testing.expect(isRetriable(error.RateLimited)); try std.testing.expect(!isRetriable(error.NotFound)); try std.testing.expect(!isRetriable(error.InvalidCep)); try std.testing.expect(!isRetriable(error.InvalidResponse)); } test "Diagnostics keeps a message without allocating" { var diag: Diagnostics = .{}; try std.testing.expectEqualStrings("", diag.message()); diag.setMessage("CEP {s} rejected", .{"00000000"}); try std.testing.expectEqualStrings("CEP 00000000 rejected", diag.message()); diag.reset(); try std.testing.expectEqualStrings("", diag.message()); } test "Diagnostics truncates instead of overflowing" { var diag: Diagnostics = .{}; diag.setMessage("{s}", .{"x" ** (Diagnostics.max_message_len * 2)}); try std.testing.expectEqual(Diagnostics.max_message_len, diag.message().len); }