diff options
Diffstat (limited to 'src/errors.zig')
| -rw-r--r-- | src/errors.zig | 176 |
1 files changed, 176 insertions, 0 deletions
diff --git a/src/errors.zig b/src/errors.zig new file mode 100644 index 0000000..c46e266 --- /dev/null +++ b/src/errors.zig @@ -0,0 +1,176 @@ +//! 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); +} |