summaryrefslogtreecommitdiffstats
path: root/src/errors.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/errors.zig')
-rw-r--r--src/errors.zig176
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);
+}