diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:39:06 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:39:06 -0300 |
| commit | 497b1c1b01f6c9a584e5b40e02aff4763447529c (patch) | |
| tree | b50feb4252ba93cf045be3aaac14a96eeb04dff5 /src/errors.zig | |
| parent | 87166a5299aa551517a86379a0c05eb2110b52b0 (diff) | |
| download | zig-dc951ba530c9deed36233ffbfc2dd4a9478cabef.tar.gz | |
A CEP that fails to resolve is rarely a random number. It is almost always
someone who knows the city and guessed the digits, and the classic guess is
the -000 that 957 municipalities never received. The API now says so: a 404
carries an optional faixa block naming the municipality the number would have
belonged to, and answering "no such CEP in Areal/RJ, which has 118 of them" is
a great deal more useful than "not found".
It arrives through Diagnostics, which is where every other detail a Zig error
tag cannot carry already travels, and it is read with diag.faixa(). Like
message(), it lives in a fixed buffer inside the value and allocates nothing,
so no caller has to learn a new deinit for it.
The lookup still fails. faixa is context for the error, never an address, and
cep_ini/cep_fim are the smallest and largest CEP that actually exist there
rather than the bounds the Correios publish — for Areal the DNE's own start is
25845000, which is exactly the CEP that failed. The range is also sparse, 118
CEPs over ~4,900 numbers, so ceps is the count and the pair must never be
presented as a list. The doc comments and the README say all three, because
this field is easy to misread as a success.
Parsing is deliberately inert: a faixa that is missing, null, not an object,
nameless or oversized yields null, and an unreadable body is skipped. Nothing
about this field may promote a clean not-found into a parse failure.
Diffstat (limited to 'src/errors.zig')
| -rw-r--r-- | src/errors.zig | 128 |
1 files changed, 124 insertions, 4 deletions
diff --git a/src/errors.zig b/src/errors.zig index c46e266..5ce474a 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -18,10 +18,13 @@ //! //! 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. +//! message, the municipality a missing CEP would have belonged to — travels in +//! an optional `Diagnostics` the caller passes in. const std = @import("std"); +const Faixa = @import("model.zig").Faixa; + /// 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. @@ -35,6 +38,10 @@ pub const CepisError = error{ InvalidCep, /// The CEP is well-formed but does not exist in the DNE database. + /// + /// `Diagnostics.faixa` may name the municipality the CEP would have + /// belonged to. That is context for the failure, not an address — the + /// lookup found nothing either way. NotFound, /// The API (or the edge in front of it) answered 429 on every attempt. @@ -91,9 +98,9 @@ pub fn isRetriable(err: Error) bool { /// }; /// ``` /// -/// 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. +/// It owns no memory and needs no `deinit`: the message and the range live in +/// fixed buffers inside the struct, and are only valid as long as the +/// `Diagnostics` itself. Copy them if you need them 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 @@ -117,6 +124,17 @@ pub const Diagnostics = struct { /// Length of the text in `message_buffer`. Internal. message_len: usize = 0, + /// Backing storage for `faixa`. Internal; read it through `faixa`. + faixa_buffer: [Faixa.max_bytes]u8 = undefined, + + /// Length of each of `faixa`'s strings inside `faixa_buffer`, in + /// `Faixa.text_fields` order, or null when no range was reported. + /// Internal. + faixa_lengths: ?[Faixa.text_fields.len]u16 = null, + + /// The `ceps` count of the reported range. Internal. Read through `faixa`. + faixa_ceps: u64 = 0, + /// The longest explanation a `Diagnostics` will keep. Longer text is /// truncated rather than allocated. pub const max_message_len = 512; @@ -127,12 +145,47 @@ pub const Diagnostics = struct { return d.message_buffer[0..d.message_len]; } + /// After `error.NotFound`, the municipality the CEP would have belonged to. + /// + /// **The lookup still failed.** A non-null `Faixa` explains *where* the + /// number would have fallen; it is not the address, and there is no address + /// to be had. Use it to answer better — "no such CEP in Areal/RJ, which has + /// 118 of them" — not to carry on as though the CEP existed. + /// + /// Null whenever the API said nothing: the CEP fell in a band no + /// municipality owns (the API **omits** the field there, it never sends + /// null), the response predates the field, or the body could not be read. + /// Null is therefore ordinary, not a sign of trouble. + /// + /// Like `message`, the value borrows this `Diagnostics` and is valid only + /// as long as it is. + /// + /// ```zig + /// client.lookup(cep, .{ .diagnostics = &diag }) catch |err| switch (err) { + /// error.NotFound => if (diag.faixa()) |f| { + /// std.log.info("no such CEP in {s}/{s} ({d} CEPs)", .{ f.localidade, f.uf, f.ceps }); + /// }, + /// else => return err, + /// }; + /// ``` + pub fn faixa(d: *const Diagnostics) ?Faixa { + const lengths = d.faixa_lengths orelse return null; + var found: Faixa = .{ .ceps = d.faixa_ceps }; + var cursor: usize = 0; + inline for (Faixa.text_fields, 0..) |name, i| { + @field(found, name) = d.faixa_buffer[cursor..][0..lengths[i]]; + cursor += lengths[i]; + } + return found; + } + /// 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; + d.clearFaixa(); } /// Set the message, truncating at `max_message_len`. Internal. @@ -141,6 +194,26 @@ pub const Diagnostics = struct { writer.print(fmt, args) catch {}; d.message_len = writer.end; } + + /// Record the `faixa` block of a 404 body, or clear it when the body has + /// none this can use. Never fails. Internal. + pub fn setFaixa(d: *Diagnostics, value: std.json.Value) void { + d.clearFaixa(); + const found = Faixa.fromJson(value, &d.faixa_buffer) orelse return; + + var lengths: [Faixa.text_fields.len]u16 = undefined; + inline for (Faixa.text_fields, 0..) |name, i| { + lengths[i] = @intCast(@field(found, name).len); + } + d.faixa_lengths = lengths; + d.faixa_ceps = found.ceps; + } + + /// Forget any range previously recorded. Internal. + pub fn clearFaixa(d: *Diagnostics) void { + d.faixa_lengths = null; + d.faixa_ceps = 0; + } }; /// Write to `maybe_diagnostics` when the caller supplied one. Internal. @@ -174,3 +247,50 @@ test "Diagnostics truncates instead of overflowing" { diag.setMessage("{s}", .{"x" ** (Diagnostics.max_message_len * 2)}); try std.testing.expectEqual(Diagnostics.max_message_len, diag.message().len); } + +test "Diagnostics keeps a faixa without allocating" { + const body = + \\{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ", + \\ "estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3300225", + \\ "cep_ini":"25845001","cep_fim":"25849899","ceps":118}} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, body, .{}); + defer parsed.deinit(); + + var diag: Diagnostics = .{}; + try std.testing.expect(diag.faixa() == null); + + diag.setFaixa(parsed.value); + const found = diag.faixa().?; + try std.testing.expectEqualStrings("Areal", found.localidade); + try std.testing.expectEqualStrings("RJ", found.uf); + try std.testing.expectEqualStrings("Rio de Janeiro", found.estado); + try std.testing.expectEqualStrings("25845001", found.cep_ini); + try std.testing.expectEqualStrings("25849899", found.cep_fim); + try std.testing.expectEqual(@as(u64, 118), found.ceps); + + diag.reset(); + try std.testing.expect(diag.faixa() == null); +} + +test "a 404 without a faixa clears the one before it" { + const with = + \\{"faixa":{"localidade":"Areal","uf":"RJ","ceps":118}} + ; + const without = + \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} + ; + + var diag: Diagnostics = .{}; + inline for (.{ with, without }) |body| { + const parsed = try std.json.parseFromSlice( + std.json.Value, + std.testing.allocator, + body, + .{}, + ); + defer parsed.deinit(); + diag.setFaixa(parsed.value); + } + try std.testing.expect(diag.faixa() == null); +} |