summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:48:43 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:48:43 -0300
commit87166a5299aa551517a86379a0c05eb2110b52b0 (patch)
tree3e77e7dd89cca736a22a5f2219451aa57dd99068 /src
downloadzig-87166a5299aa551517a86379a0c05eb2110b52b0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src')
-rw-r--r--src/client.zig804
-rw-r--r--src/csv.zig207
-rw-r--r--src/errors.zig176
-rw-r--r--src/main.zig118
-rw-r--r--src/model.zig543
-rw-r--r--src/retry.zig152
-rw-r--r--src/root.zig90
7 files changed, 2090 insertions, 0 deletions
diff --git a/src/client.zig b/src/client.zig
new file mode 100644
index 0000000..3a726e5
--- /dev/null
+++ b/src/client.zig
@@ -0,0 +1,804 @@
+//! The HTTP client, built on `std.http.Client` from the standard library.
+//!
+//! Retry policy: 429 and the transient 5xx in `retriable_status`, plus network
+//! failures, are retried with exponentially backed-off, jittered waits. 400 and
+//! 404 are deliberately absent from that set — both are definitive answers
+//! about the request, so retrying returns the same thing more slowly while
+//! burning someone else's quota.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+const csv = @import("csv.zig");
+const errors = @import("errors.zig");
+const model = @import("model.zig");
+const retry = @import("retry.zig");
+
+const Address = model.Address;
+const Diagnostics = errors.Diagnostics;
+const Error = errors.Error;
+const Health = model.Health;
+const note = errors.note;
+
+/// The version of this library, as sent in the `User-Agent`.
+pub const version = "0.1.0";
+
+/// Where the public API lives.
+pub const default_url = "https://cep.is";
+
+/// How many CEPs the batch endpoint accepts in one request.
+pub const max_batch = 500;
+
+/// The statuses worth trying again: rate limiting and the transient 5xx.
+pub const retriable_status = [_]u16{ 429, 500, 502, 503, 504 };
+
+/// The `User-Agent` sent unless `Config.user_agent` overrides it. Identifying
+/// the library and its version is what lets the server diagnose an old client
+/// misbehaving.
+pub const default_user_agent = "cepis-zig/" ++ version ++ " (+https://cep.is)";
+
+const accept_header = "application/json, text/csv;q=0.9, */*;q=0.1";
+
+/// Refuse to buffer more than this from one response. The largest legitimate
+/// answer is a 500-row CSV, comfortably under a megabyte.
+const max_body_bytes = 16 * 1024 * 1024;
+
+/// Everything that can go wrong building a `Client`. Distinct from `Error`
+/// because a bad configuration is a programming mistake, not a runtime
+/// condition to be retried.
+pub const InitError = Allocator.Error || error{
+ /// `max_attempts` is 0, or `timeout_ms` is 0 — either produces a client
+ /// that can never complete a call.
+ InvalidConfiguration,
+};
+
+/// How a `Client` behaves. Every field has a default; set only what differs.
+pub const Config = struct {
+ /// Where the API lives.
+ ///
+ /// Point it at a local instance (the API repo brings one up on
+ /// `http://localhost:8080` with `just up`) or at a test server. It is the
+ /// only setting that usually changes between environments. A trailing slash
+ /// is stripped, so it cannot produce doubled separators that some proxies
+ /// normalise and others 404. Copied on `init`; the caller keeps ownership.
+ base_url: []const u8 = default_url,
+
+ /// How long to wait for the TCP and TLS handshake, in milliseconds.
+ ///
+ /// This is **not** a cap on the whole call, and Zig 0.16's
+ /// `std.http.Client` exposes no per-read timeout, so a server that accepts
+ /// the connection and then stalls is bounded only by `total_timeout_ms`.
+ /// Set that one in code serving a web request.
+ timeout_ms: u64 = 10_000,
+
+ /// How many attempts in total, counting the first.
+ ///
+ /// 1 disables retrying. The default of 3 covers the common case (one
+ /// transient failure) without turning a real outage into a long wait.
+ max_attempts: u32 = 3,
+
+ /// Base of the exponential wait, in milliseconds.
+ ///
+ /// The actual wait is drawn from `[0, base * 2^(n-1)]` — *full jitter*. The
+ /// randomness is not decoration: without it, N clients that fail together
+ /// come back together and reproduce the spike that took the service down.
+ backoff_base_ms: u64 = 500,
+
+ /// Cap on each individual wait, in milliseconds.
+ ///
+ /// It applies to a server-sent `Retry-After` too, or a `Retry-After: 3600`
+ /// from a misconfigured proxy would park the caller for an hour.
+ backoff_max_ms: u64 = 8_000,
+
+ /// Cap on the whole call, attempts and waits included, in milliseconds.
+ ///
+ /// Null leaves `max_attempts` as the only bound.
+ total_timeout_ms: ?u64 = null,
+
+ /// Overrides the `User-Agent`. Copied on `init`; the caller keeps
+ /// ownership.
+ user_agent: []const u8 = default_user_agent,
+
+ /// Seed for the backoff jitter. Null draws one from the system, which is
+ /// what you want; set it only to make a test deterministic.
+ jitter_seed: ?u64 = null,
+};
+
+/// Per-call knobs shared by every method.
+pub const CallOptions = struct {
+ /// Where to record the HTTP status, the `Retry-After` and the API's own
+ /// message when the call fails. Null discards them.
+ diagnostics: ?*Diagnostics = null,
+};
+
+/// Return the CEP's 8 digits, accepting the mask.
+///
+/// `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+///
+/// Fails with `error.InvalidCep` unless exactly 8 digits remain. This runs
+/// **before** any request: a 7-digit CEP is the caller's bug, and spending a
+/// round trip for the server to say so wastes both sides' time.
+///
+/// ## Ownership
+///
+/// The result borrows `buffer`; it is valid for as long as `buffer` is. No
+/// allocation happens.
+pub fn normalizeCep(value: []const u8, buffer: *[8]u8) error{InvalidCep}![]const u8 {
+ var n: usize = 0;
+ for (std.mem.trim(u8, value, " \t\r\n")) |c| {
+ switch (c) {
+ '-', '.', ' ' => continue,
+ '0'...'9' => {
+ if (n == buffer.len) return error.InvalidCep;
+ buffer[n] = c;
+ n += 1;
+ },
+ else => return error.InvalidCep,
+ }
+ }
+ if (n != buffer.len) return error.InvalidCep;
+ return buffer[0..];
+}
+
+/// Client for the cep.is API.
+///
+/// Build one and reuse it: the configuration is immutable after `init`, no
+/// method keeps state between calls, and the underlying connection pool is
+/// shared — so a second lookup on the same host skips the TLS handshake.
+/// Sharing one across threads is safe.
+///
+/// ```zig
+/// var threaded: std.Io.Threaded = .init(gpa, .{});
+/// defer threaded.deinit();
+///
+/// var client: cepis.Client = try .init(gpa, threaded.io(), .{});
+/// defer client.deinit();
+///
+/// var address = try client.lookup("01001-000", .{});
+/// defer address.deinit(gpa);
+/// ```
+pub const Client = struct {
+ /// The allocator every returned value was built with. Pass it to
+ /// `Address.deinit`, `Health.deinit` and `freeAddresses`.
+ gpa: Allocator,
+ io: Io,
+ http: std.http.Client,
+
+ /// The API URL in use, without a trailing slash. Owned by the client.
+ base_url: []u8,
+ /// The `User-Agent` sent with every request. Owned by the client.
+ user_agent: []u8,
+
+ timeout_ms: u64,
+ max_attempts: u32,
+ backoff_base_ms: u64,
+ backoff_max_ms: u64,
+ total_timeout_ms: ?u64,
+
+ prng: std.Random.DefaultPrng,
+ /// Guards `prng` and the lazy trust-store load, so one `Client` can be
+ /// shared across threads.
+ mutex: Io.Mutex,
+
+ /// Build a client. `io` and `gpa` must outlive it.
+ ///
+ /// Fails with `error.InvalidConfiguration` when `max_attempts` is 0 or
+ /// `timeout_ms` is 0 — both describe a client that can never complete a
+ /// call. Call `deinit` when done.
+ pub fn init(gpa: Allocator, io: Io, config: Config) InitError!Client {
+ if (config.max_attempts < 1) return error.InvalidConfiguration;
+ if (config.timeout_ms == 0) return error.InvalidConfiguration;
+
+ const trimmed = std.mem.trimEnd(u8, config.base_url, "/");
+ const base_url = try gpa.dupe(u8, trimmed);
+ errdefer gpa.free(base_url);
+
+ const user_agent = try gpa.dupe(u8, config.user_agent);
+ errdefer gpa.free(user_agent);
+
+ const seed = config.jitter_seed orelse seed: {
+ const now = Io.Timestamp.now(io, .real);
+ break :seed @as(u64, @truncate(@as(u128, @bitCast(@as(i128, now.nanoseconds)))));
+ };
+
+ return .{
+ .gpa = gpa,
+ .io = io,
+ .http = .{ .allocator = gpa, .io = io },
+ .base_url = base_url,
+ .user_agent = user_agent,
+ .timeout_ms = config.timeout_ms,
+ .max_attempts = config.max_attempts,
+ .backoff_base_ms = config.backoff_base_ms,
+ .backoff_max_ms = config.backoff_max_ms,
+ .total_timeout_ms = config.total_timeout_ms,
+ .prng = .init(seed),
+ .mutex = .init,
+ };
+ }
+
+ /// Close every pooled connection and release the client's own memory.
+ ///
+ /// Values already returned by `lookup`, `lookupMany` and `health` are not
+ /// touched: they own their memory and outlive the client.
+ pub fn deinit(c: *Client) void {
+ c.http.deinit();
+ c.gpa.free(c.base_url);
+ c.gpa.free(c.user_agent);
+ c.* = undefined;
+ }
+
+ /// Look up a single CEP.
+ ///
+ /// The CEP may be masked or not. This is the only way to obtain a
+ /// coordinate — see `lookupMany` for why.
+ ///
+ /// A 400 from the API becomes `error.InvalidCep` with the API's own message
+ /// in `Diagnostics`: the format already passed local validation, so the
+ /// server knows something this client does not.
+ ///
+ /// Errors: `error.InvalidCep` (not 8 digits — no request is made),
+ /// `error.NotFound`, `error.RateLimited`, `error.ServerError`,
+ /// `error.NetworkError`, `error.InvalidResponse`, `error.OutOfMemory`.
+ ///
+ /// ## Ownership
+ ///
+ /// The returned `Address` owns its memory. Call `address.deinit(client.gpa)`.
+ pub fn lookup(c: *Client, cep: []const u8, options: CallOptions) Error!Address {
+ var digits: [8]u8 = undefined;
+ const number = normalizeCep(cep, &digits) catch {
+ note(options.diagnostics, "CEP must be 8 digits, got \"{s}\"", .{cep});
+ return error.InvalidCep;
+ };
+
+ var path_buffer: [16]u8 = undefined;
+ const path = std.fmt.bufPrint(&path_buffer, "/{s}", .{number}) catch unreachable;
+
+ const response = try c.request(path, null, &.{200}, options.diagnostics);
+ defer c.gpa.free(response.body);
+
+ if (response.status == 404) {
+ note(options.diagnostics, "CEP {s} does not exist in the DNE database", .{number});
+ return error.NotFound;
+ }
+ if (response.status == 400) {
+ noteApiMessage(options.diagnostics, response.body, "CEP {s} rejected", .{number});
+ return error.InvalidCep;
+ }
+
+ const parsed = try c.parseJsonObject(response.body, options.diagnostics);
+ defer parsed.deinit();
+
+ return Address.fromJson(c.gpa, parsed.value, response.body);
+ }
+
+ /// Look up many CEPs in one batch.
+ ///
+ /// Returns a slice of **the same length and in the same order** as the
+ /// input. A CEP that does not exist becomes null at its position — it does
+ /// not vanish from the list, and it is not an error. That is the right
+ /// difference from `lookup`: in a batch of a thousand, one bad CEP must not
+ /// cost the other nine hundred and ninety-nine.
+ ///
+ /// A malformed CEP also becomes null, for the same reason. If you need to
+ /// know *which* one was malformed, validate first with `normalizeCep`.
+ ///
+ /// Input is chunked into blocks of `max_batch` automatically. Do not loop
+ /// over `lookup` instead: 500 calls pay 500 round trips, the batch pays one.
+ ///
+ /// **Batches carry no coordinate.** The endpoint behind them is CSV, and the
+ /// CSV has no `geo` column. Every `Address` returned here has `geo == null`,
+ /// including for CEPs that do have one. Need `geo`? Use `lookup`, one at a
+ /// time.
+ ///
+ /// Errors are transport failures only — `error.RateLimited`,
+ /// `error.ServerError`, `error.NetworkError`, `error.InvalidResponse`,
+ /// `error.OutOfMemory`. A bad CEP never fails a batch.
+ ///
+ /// ## Ownership
+ ///
+ /// The returned slice and every `Address` in it are owned by the caller.
+ /// Release the lot with `cepis.freeAddresses(client.gpa, result)`.
+ pub fn lookupMany(c: *Client, ceps: []const []const u8, options: CallOptions) Error![]?Address {
+ const result = try c.gpa.alloc(?Address, ceps.len);
+ errdefer freeAddresses(c.gpa, result);
+ @memset(result, null);
+ if (ceps.len == 0) return result;
+
+ var start: usize = 0;
+ while (start < ceps.len) : (start += max_batch) {
+ const end = @min(start + max_batch, ceps.len);
+ try c.lookupChunk(ceps[start..end], result[start..end], options);
+ }
+ return result;
+ }
+
+ /// Query `GET /health`: does the API answer, and with which database.
+ ///
+ /// An API reporting itself degraded is not an error — that arrives in
+ /// `Health.status`, and checking health is exactly when you want the
+ /// diagnosis rather than a failure. Network failures still fail.
+ ///
+ /// ## Ownership
+ ///
+ /// The returned `Health` owns its memory. Call `health.deinit(client.gpa)`.
+ pub fn health(c: *Client, options: CallOptions) Error!Health {
+ const response = try c.request("/health", null, &.{ 200, 503 }, options.diagnostics);
+ defer c.gpa.free(response.body);
+
+ const parsed = try c.parseJsonObject(response.body, options.diagnostics);
+ defer parsed.deinit();
+
+ return Health.fromJson(c.gpa, parsed.value, response.body);
+ }
+
+ /// One chunk of at most `max_batch` CEPs, in a single request, written into
+ /// `out` which is already null-filled and exactly as long as `ceps`.
+ ///
+ /// A blank CSV row means "not found": the API fills only the `cep` column
+ /// and leaves the rest empty, precisely so it does not misalign whoever is
+ /// consuming the batch in a spreadsheet.
+ ///
+ /// The API guarantees one row per requested CEP. Should that ever fail to
+ /// hold — a proxy truncating, a cut response — the extra positions stay null
+ /// rather than the result being returned short, because a shifted list
+ /// silently pairs the wrong address with the wrong CEP.
+ fn lookupChunk(
+ c: *Client,
+ ceps: []const []const u8,
+ out: []?Address,
+ options: CallOptions,
+ ) Error!void {
+ const query = try buildBatchQuery(c.gpa, ceps);
+ defer c.gpa.free(query);
+
+ const response = try c.request("/csv", query, &.{200}, options.diagnostics);
+ defer c.gpa.free(response.body);
+
+ const records = try csv.parse(c.gpa, response.body);
+ defer csv.free(c.gpa, records);
+
+ if (records.len < 2) return;
+ const header = records[0];
+ const localidade = columnIndex(header, "localidade");
+
+ for (records[1..], 0..) |record, i| {
+ if (i >= out.len) break;
+ const found = if (localidade) |column|
+ column < record.len and std.mem.trim(u8, record[column], " \t").len != 0
+ else
+ false;
+ if (!found) continue;
+ out[i] = try Address.fromCsvRecord(c.gpa, header, record);
+ }
+ }
+
+ /// One HTTP response, buffered.
+ const Response = struct {
+ status: u16,
+ /// Owned by `Client.gpa`; free it.
+ body: []u8,
+ retry_after_ms: ?u64,
+ };
+
+ /// Perform the GET, with retries. `query` is appended to `path` as-is and
+ /// must already be percent-encoded, or be null.
+ ///
+ /// *Definitive* error statuses (400, 404) come back normally, for the
+ /// caller to turn into the right error — only transient failures become
+ /// errors here, and only once the attempts are exhausted. `accepted` lists
+ /// the statuses to hand back even though they would otherwise be retried,
+ /// which is how `/health` gets its 503.
+ fn request(
+ c: *Client,
+ path: []const u8,
+ query: ?[]const u8,
+ accepted: []const u16,
+ diagnostics: ?*Diagnostics,
+ ) Error!Response {
+ const url = try std.fmt.allocPrint(c.gpa, "{s}{s}{s}{s}", .{
+ c.base_url,
+ path,
+ if (query != null) "?" else "",
+ query orelse "",
+ });
+ defer c.gpa.free(url);
+
+ const started = Io.Timestamp.now(c.io, .awake);
+
+ var last_status: ?u16 = null;
+ var last_retry_after: ?u64 = null;
+ var attempts: u32 = 0;
+
+ var attempt: u32 = 1;
+ while (attempt <= c.max_attempts) : (attempt += 1) {
+ if (c.remainingMs(started)) |left| {
+ if (left == 0) break;
+ }
+ attempts = attempt;
+
+ if (c.attemptOnce(url, diagnostics)) |response| {
+ const definitive = !isRetriable(response.status) or contains(accepted, response.status);
+ if (definitive) {
+ if (diagnostics) |d| {
+ d.status = response.status;
+ d.attempts = attempts;
+ }
+ return response;
+ }
+ c.gpa.free(response.body);
+ last_status = response.status;
+ last_retry_after = response.retry_after_ms;
+ } else |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.InvalidResponse => {
+ if (diagnostics) |d| d.attempts = attempts;
+ return error.InvalidResponse;
+ },
+ error.NetworkError => {
+ last_status = null;
+ last_retry_after = null;
+ },
+ }
+
+ if (attempt == c.max_attempts) break;
+
+ var wait = c.backoffMs(attempt, last_retry_after);
+ if (c.remainingMs(started)) |left| {
+ if (left == 0) break;
+ wait = @min(wait, left);
+ }
+ c.sleepMs(wait);
+ }
+
+ if (diagnostics) |d| {
+ d.status = last_status;
+ d.retry_after_ms = last_retry_after;
+ d.attempts = attempts;
+ }
+
+ if (last_status) |status| {
+ if (status == 429) {
+ note(diagnostics, "rate limit reached at {s} (after {d} attempt(s))", .{ url, attempts });
+ return error.RateLimited;
+ }
+ note(diagnostics, "the API answered {d} at {s} (after {d} attempt(s))", .{ status, url, attempts });
+ return error.ServerError;
+ }
+
+ if (attempts == 0) {
+ note(diagnostics, "no attempt was made at {s} within the total timeout", .{url});
+ }
+ return error.NetworkError;
+ }
+
+ /// A single request/response round trip. `error.NetworkError` means the
+ /// exchange never completed and is worth retrying; the message explaining
+ /// which transport error it was is already in `diagnostics`.
+ fn attemptOnce(
+ c: *Client,
+ url: []const u8,
+ diagnostics: ?*Diagnostics,
+ ) (error{ NetworkError, InvalidResponse } || Allocator.Error)!Response {
+ const uri = std.Uri.parse(url) catch {
+ note(diagnostics, "{s} is not a usable URL", .{url});
+ return error.NetworkError;
+ };
+
+ const connection = c.connectWithTimeout(uri) catch |err| {
+ note(diagnostics, "could not reach {s}: {t}", .{ url, err });
+ return if (err == error.OutOfMemory) error.OutOfMemory else error.NetworkError;
+ };
+ var req = c.http.request(.GET, uri, .{
+ .connection = connection,
+ .keep_alive = true,
+ .headers = .{
+ .user_agent = .{ .override = c.user_agent },
+ .accept_encoding = .{ .override = "identity" },
+ },
+ .extra_headers = &.{.{ .name = "accept", .value = accept_header }},
+ }) catch |err| {
+ c.http.connection_pool.release(connection, c.io);
+ note(diagnostics, "could not reach {s}: {t}", .{ url, err });
+ return if (err == error.OutOfMemory) error.OutOfMemory else error.NetworkError;
+ };
+ defer req.deinit();
+
+ req.sendBodiless() catch |err| {
+ note(diagnostics, "could not send the request to {s}: {t}", .{ url, err });
+ return error.NetworkError;
+ };
+
+ var redirect_buffer: [8192]u8 = undefined;
+ var response = req.receiveHead(&redirect_buffer) catch |err| {
+ note(diagnostics, "no usable response from {s}: {t}", .{ url, err });
+ return if (err == error.OutOfMemory) error.OutOfMemory else error.NetworkError;
+ };
+
+ const status: u16 = @intFromEnum(response.head.status);
+ const retry_after_ms = c.readRetryAfter(response.head);
+
+ const decompress_buffer: []u8 = switch (response.head.content_encoding) {
+ .identity => &.{},
+ .zstd => try c.gpa.alloc(u8, std.compress.zstd.default_window_len),
+ .deflate, .gzip => try c.gpa.alloc(u8, std.compress.flate.max_window_len),
+ .compress => {
+ note(diagnostics, "{s} answered with an unsupported content encoding", .{url});
+ return error.InvalidResponse;
+ },
+ };
+ defer c.gpa.free(decompress_buffer);
+
+ var transfer_buffer: [4096]u8 = undefined;
+ var decompress: std.http.Decompress = undefined;
+ const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer);
+
+ const body = reader.allocRemaining(c.gpa, .limited(max_body_bytes)) catch |err| {
+ note(diagnostics, "could not read the response from {s}: {t}", .{ url, err });
+ return switch (err) {
+ error.OutOfMemory => error.OutOfMemory,
+ error.StreamTooLong => error.InvalidResponse,
+ else => error.NetworkError,
+ };
+ };
+
+ return .{ .status = status, .body = body, .retry_after_ms = retry_after_ms };
+ }
+
+ /// Open (or reuse) a connection, giving the handshake at most
+ /// `timeout_ms`.
+ ///
+ /// `std.http.Client.request` connects with no timeout at all, so a host
+ /// that blackholes packets would hang the caller until the kernel gives
+ /// up. Connecting first and handing the connection over is how `timeout_ms`
+ /// is made to mean something. Proxy support is skipped along the way — this
+ /// library never configures one.
+ fn connectWithTimeout(c: *Client, uri: std.Uri) !*std.http.Client.Connection {
+ const protocol = std.http.Client.Protocol.fromUri(uri) orelse
+ return error.UnsupportedUriScheme;
+ if (protocol == .tls) try c.ensureTrustStore();
+ var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined;
+ const host = try uri.getHost(&host_buffer);
+ const port = uri.port orelse switch (protocol) {
+ .plain => @as(u16, 80),
+ .tls => @as(u16, 443),
+ };
+ return c.http.connectTcpOptions(.{
+ .host = host,
+ .port = port,
+ .protocol = protocol,
+ .timeout = .{ .duration = .{
+ .raw = .fromNanoseconds(@as(i96, c.timeout_ms) * std.time.ns_per_ms),
+ .clock = .awake,
+ } },
+ });
+ }
+
+ /// Load the system trust store into the underlying `std.http.Client`, once.
+ ///
+ /// `std.http.Client.request` does this lazily on the first HTTPS request;
+ /// `connectTcpOptions`, which is the only entry point that takes a timeout,
+ /// assumes it has already happened and panics otherwise. Doing it here is
+ /// what makes `timeout_ms` and `https://` coexist.
+ fn ensureTrustStore(c: *Client) !void {
+ if (!std.http.Client.disable_tls) {
+ c.mutex.lockUncancelable(c.io);
+ defer c.mutex.unlock(c.io);
+ if (c.http.now != null) return;
+
+ const now = Io.Clock.real.now(c.io);
+ var bundle: std.crypto.Certificate.Bundle = .empty;
+ errdefer bundle.deinit(c.gpa);
+ bundle.rescan(c.gpa, c.io, now) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ else => return error.CertificateBundleLoadFailure,
+ };
+ c.http.ca_bundle = bundle;
+ c.http.now = now;
+ }
+ }
+
+ fn readRetryAfter(c: *Client, head: std.http.Client.Response.Head) ?u64 {
+ var it = head.iterateHeaders();
+ while (it.next()) |header| {
+ if (!std.ascii.eqlIgnoreCase(header.name, "retry-after")) continue;
+ const now = Io.Timestamp.now(c.io, .real);
+ const now_unix: i64 = @intCast(@divFloor(now.nanoseconds, std.time.ns_per_s));
+ return retry.parseRetryAfter(header.value, now_unix);
+ }
+ return null;
+ }
+
+ /// How long to wait before the next attempt.
+ ///
+ /// The server's `Retry-After` wins over the local calculation when present:
+ /// it knows when it will be back, we are guessing. It still goes through the
+ /// `backoff_max_ms` cap. Otherwise the wait is drawn from `[0, ceiling]`
+ /// rather than being the ceiling.
+ fn backoffMs(c: *Client, attempt: u32, retry_after_ms: ?u64) u64 {
+ if (retry_after_ms) |asked| return @min(asked, c.backoff_max_ms);
+
+ const ceiling = retry.ceilingMs(attempt, c.backoff_base_ms, c.backoff_max_ms);
+ c.mutex.lockUncancelable(c.io);
+ defer c.mutex.unlock(c.io);
+ return retry.jitter(c.prng.random(), ceiling);
+ }
+
+ /// Milliseconds left of `total_timeout_ms`, or null when there is no cap.
+ fn remainingMs(c: *Client, started: Io.Timestamp) ?u64 {
+ const cap = c.total_timeout_ms orelse return null;
+ const now = Io.Timestamp.now(c.io, .awake);
+ const elapsed_ns = started.durationTo(now).nanoseconds;
+ if (elapsed_ns <= 0) return cap;
+ const elapsed_ms: u128 = @intCast(@divFloor(elapsed_ns, std.time.ns_per_ms));
+ if (elapsed_ms >= cap) return 0;
+ return cap - @as(u64, @intCast(elapsed_ms));
+ }
+
+ fn sleepMs(c: *Client, ms: u64) void {
+ if (ms == 0) return;
+ c.io.sleep(.fromNanoseconds(@as(i96, ms) * std.time.ns_per_ms), .awake) catch {};
+ }
+
+ fn parseJsonObject(
+ c: *Client,
+ body: []const u8,
+ diagnostics: ?*Diagnostics,
+ ) Error!std.json.Parsed(std.json.Value) {
+ const parsed = std.json.parseFromSlice(std.json.Value, c.gpa, body, .{}) catch |err| {
+ if (err == error.OutOfMemory) return error.OutOfMemory;
+ note(
+ diagnostics,
+ "response is not valid JSON — something between you and the API is " ++
+ "answering instead of it: \"{s}\"",
+ .{preview(body)},
+ );
+ return error.InvalidResponse;
+ };
+ errdefer parsed.deinit();
+
+ switch (parsed.value) {
+ .object => return parsed,
+ else => {
+ note(diagnostics, "expected a JSON object, got \"{s}\"", .{preview(body)});
+ return error.InvalidResponse;
+ },
+ }
+ }
+};
+
+/// Release a slice returned by `Client.lookupMany`, and every `Address` in it.
+pub fn freeAddresses(gpa: Allocator, list: []?Address) void {
+ for (list) |*slot| {
+ if (slot.*) |*address| address.deinit(gpa);
+ }
+ gpa.free(list);
+}
+
+/// Build `ceps=a,b,c`, percent-encoding anything that is not safe in a query.
+///
+/// The caller's CEPs are not validated here — a malformed one is answered with
+/// an empty row and becomes null, which is the documented behaviour of
+/// `lookupMany`. Encoding is still required so a stray `&` or space cannot
+/// rewrite the query string.
+///
+/// The result is owned by the caller.
+fn buildBatchQuery(gpa: Allocator, ceps: []const []const u8) Allocator.Error![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+
+ try out.appendSlice(gpa, "ceps=");
+ for (ceps, 0..) |cep, i| {
+ if (i != 0) try out.appendSlice(gpa, ",");
+ for (cep) |char| switch (char) {
+ 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => try out.append(gpa, char),
+ else => try out.print(gpa, "%{X:0>2}", .{char}),
+ };
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+fn columnIndex(header: []const []const u8, name: []const u8) ?usize {
+ for (header, 0..) |column, i| {
+ if (std.mem.eql(u8, std.mem.trim(u8, column, " \t"), name)) return i;
+ }
+ return null;
+}
+
+fn contains(haystack: []const u16, needle: u16) bool {
+ for (haystack) |value| {
+ if (value == needle) return true;
+ }
+ return false;
+}
+
+fn isRetriable(status: u16) bool {
+ return contains(&retriable_status, status);
+}
+
+/// Pull `mensagem` out of an error body into `diagnostics`, falling back to a
+/// formatted default when the body says nothing useful.
+fn noteApiMessage(
+ diagnostics: ?*Diagnostics,
+ body: []const u8,
+ comptime fallback: []const u8,
+ args: anytype,
+) void {
+ const d = diagnostics orelse return;
+
+ var buffer: [4096]u8 = undefined;
+ var fba: std.heap.FixedBufferAllocator = .init(&buffer);
+ if (std.json.parseFromSlice(std.json.Value, fba.allocator(), body, .{})) |parsed| {
+ defer parsed.deinit();
+ switch (parsed.value) {
+ .object => |object| if (object.get("mensagem")) |value| switch (value) {
+ .string => |text| if (text.len != 0) {
+ d.setMessage("{s}", .{text});
+ return;
+ },
+ else => {},
+ },
+ else => {},
+ }
+ } else |_| {}
+
+ d.setMessage(fallback, args);
+}
+
+fn preview(body: []const u8) []const u8 {
+ return body[0..@min(body.len, 120)];
+}
+
+const testing = std.testing;
+
+test "normalizeCep accepts the mask" {
+ var buffer: [8]u8 = undefined;
+ for ([_][]const u8{ "01001000", "01001-000", " 01001-000 ", "01001.000" }) |value| {
+ try testing.expectEqualStrings("01001000", try normalizeCep(value, &buffer));
+ }
+}
+
+test "normalizeCep rejects anything else" {
+ var buffer: [8]u8 = undefined;
+ for ([_][]const u8{ "", "123", "0100100a", "010010000", "abcdefgh", "01001-0000" }) |value| {
+ try testing.expectError(error.InvalidCep, normalizeCep(value, &buffer));
+ }
+}
+
+test "buildBatchQuery joins and encodes" {
+ const query = try buildBatchQuery(testing.allocator, &.{ "01001000", "a b&c" });
+ defer testing.allocator.free(query);
+ try testing.expectEqualStrings("ceps=01001000,a%20b%26c", query);
+}
+
+test "buildBatchQuery on an empty list is just the key" {
+ const query = try buildBatchQuery(testing.allocator, &.{});
+ defer testing.allocator.free(query);
+ try testing.expectEqualStrings("ceps=", query);
+}
+
+test "columnIndex finds the column or nothing" {
+ const header = [_][]const u8{ "cep", "logradouro", "localidade" };
+ try testing.expectEqual(@as(?usize, 2), columnIndex(&header, "localidade"));
+ try testing.expectEqual(@as(?usize, null), columnIndex(&header, "geo"));
+}
+
+test "only transient statuses are retriable" {
+ for ([_]u16{ 429, 500, 502, 503, 504 }) |status| {
+ try testing.expect(isRetriable(status));
+ }
+ for ([_]u16{ 200, 400, 404, 501 }) |status| {
+ try testing.expect(!isRetriable(status));
+ }
+}
+
+test "noteApiMessage prefers the API wording" {
+ var diag: Diagnostics = .{};
+ noteApiMessage(&diag, "{\"erro\":\"cep_invalido\",\"mensagem\":\"rejected\"}", "fallback {s}", .{"x"});
+ try testing.expectEqualStrings("rejected", diag.message());
+
+ diag.reset();
+ noteApiMessage(&diag, "<html>", "fallback {s}", .{"x"});
+ try testing.expectEqualStrings("fallback x", diag.message());
+}
diff --git a/src/csv.zig b/src/csv.zig
new file mode 100644
index 0000000..953e5eb
--- /dev/null
+++ b/src/csv.zig
@@ -0,0 +1,207 @@
+//! A small RFC 4180 reader for the `/csv` batch endpoint.
+//!
+//! Values may contain commas and are then double-quoted with `""` escaping,
+//! which is why the batch cannot be split on `,`. The reader is deliberately
+//! lenient: blank lines are skipped and a stray quote inside an unquoted field
+//! is kept as data, because a malformed byte in one row must not cost the whole
+//! batch.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+/// One parsed row: a slice of owned field values.
+pub const Record = [][]const u8;
+
+/// Split CSV text into records, honouring quoting, `""` escapes, embedded
+/// commas and newlines, CRLF and LF line endings, and a leading BOM.
+///
+/// ## Ownership
+///
+/// Every field, every record and the outer slice are allocated from `gpa` and
+/// owned by the caller. Release the lot with `free`; `input` is only borrowed
+/// for the duration of the call.
+pub fn parse(gpa: Allocator, input: []const u8) Allocator.Error![]Record {
+ const text = if (std.mem.startsWith(u8, input, "\xEF\xBB\xBF")) input[3..] else input;
+
+ var records: std.ArrayList(Record) = .empty;
+ errdefer free(gpa, records.items);
+ errdefer records.deinit(gpa);
+
+ var record: std.ArrayList([]const u8) = .empty;
+ errdefer freeRecord(gpa, record.items);
+ errdefer record.deinit(gpa);
+
+ var field: std.ArrayList(u8) = .empty;
+ defer field.deinit(gpa);
+
+ var quoted = false;
+ var i: usize = 0;
+ while (i < text.len) : (i += 1) {
+ const c = text[i];
+
+ if (quoted) {
+ if (c == '"') {
+ if (i + 1 < text.len and text[i + 1] == '"') {
+ try field.append(gpa, '"');
+ i += 1;
+ } else {
+ quoted = false;
+ }
+ } else {
+ try field.append(gpa, c);
+ }
+ continue;
+ }
+
+ switch (c) {
+ '"' => if (field.items.len == 0) {
+ quoted = true;
+ } else {
+ try field.append(gpa, c);
+ },
+ ',' => try endField(gpa, &record, &field),
+ '\r', '\n' => {
+ if (c == '\r' and i + 1 < text.len and text[i + 1] == '\n') i += 1;
+ try endField(gpa, &record, &field);
+ try endRecord(gpa, &records, &record);
+ },
+ else => try field.append(gpa, c),
+ }
+ }
+
+ if (field.items.len != 0 or record.items.len != 0) {
+ try endField(gpa, &record, &field);
+ try endRecord(gpa, &records, &record);
+ }
+
+ return records.toOwnedSlice(gpa);
+}
+
+/// Release everything `parse` returned.
+pub fn free(gpa: Allocator, records: []Record) void {
+ for (records) |record| freeRecord(gpa, record);
+ gpa.free(records);
+}
+
+fn freeRecord(gpa: Allocator, record: []const []const u8) void {
+ for (record) |value| gpa.free(value);
+ gpa.free(record);
+}
+
+fn endField(
+ gpa: Allocator,
+ record: *std.ArrayList([]const u8),
+ field: *std.ArrayList(u8),
+) Allocator.Error!void {
+ const owned = try gpa.dupe(u8, field.items);
+ errdefer gpa.free(owned);
+ try record.append(gpa, owned);
+ field.clearRetainingCapacity();
+}
+
+/// Append the record unless every one of its fields is empty. A blank line
+/// carries no data and would otherwise be mistaken for a row of empties, which
+/// is exactly what the batch endpoint uses to mean "not found".
+fn endRecord(
+ gpa: Allocator,
+ records: *std.ArrayList(Record),
+ record: *std.ArrayList([]const u8),
+) Allocator.Error!void {
+ var any = false;
+ for (record.items) |value| {
+ if (value.len != 0) any = true;
+ }
+ if (!any) {
+ freeRecord(gpa, try record.toOwnedSlice(gpa));
+ return;
+ }
+ const owned = try record.toOwnedSlice(gpa);
+ errdefer freeRecord(gpa, owned);
+ try records.append(gpa, owned);
+}
+
+const testing = std.testing;
+
+fn expectRecords(input: []const u8, expected: []const []const []const u8) !void {
+ const records = try parse(testing.allocator, input);
+ defer free(testing.allocator, records);
+
+ try testing.expectEqual(expected.len, records.len);
+ for (expected, records) |want_record, got_record| {
+ try testing.expectEqual(want_record.len, got_record.len);
+ for (want_record, got_record) |want, got| {
+ try testing.expectEqualStrings(want, got);
+ }
+ }
+}
+
+test "splits plain rows" {
+ try expectRecords("a,b,c\n1,2,3\n", &.{
+ &.{ "a", "b", "c" },
+ &.{ "1", "2", "3" },
+ });
+}
+
+test "keeps empty fields" {
+ const records = try parse(testing.allocator, "99999999,,,,,,,,,,,,\n");
+ defer free(testing.allocator, records);
+
+ try testing.expectEqual(@as(usize, 1), records.len);
+ try testing.expectEqual(@as(usize, 13), records[0].len);
+ try testing.expectEqualStrings("99999999", records[0][0]);
+ try testing.expectEqualStrings("", records[0][12]);
+}
+
+test "reads a quoted field containing a comma" {
+ try expectRecords("cep,logradouro\n01001000,\"Rua Um, Dois\"\n", &.{
+ &.{ "cep", "logradouro" },
+ &.{ "01001000", "Rua Um, Dois" },
+ });
+}
+
+test "unescapes doubled quotes" {
+ try expectRecords("a\n\"say \"\"hi\"\"\"\n", &.{
+ &.{"a"},
+ &.{"say \"hi\""},
+ });
+}
+
+test "accepts an embedded newline and CRLF endings" {
+ try expectRecords("a,b\r\n\"line\nbreak\",2\r\n", &.{
+ &.{ "a", "b" },
+ &.{ "line\nbreak", "2" },
+ });
+}
+
+test "ignores a BOM and blank lines" {
+ try expectRecords("\xEF\xBB\xBFa,b\n\n1,2", &.{
+ &.{ "a", "b" },
+ &.{ "1", "2" },
+ });
+}
+
+test "a final row without a trailing newline is kept" {
+ try expectRecords("a,b\n1,2", &.{
+ &.{ "a", "b" },
+ &.{ "1", "2" },
+ });
+}
+
+test "a stray quote inside an unquoted field is data" {
+ try expectRecords("a\n12\"34\n", &.{
+ &.{"a"},
+ &.{"12\"34"},
+ });
+}
+
+test "empty input yields no records" {
+ try expectRecords("", &.{});
+ try expectRecords("\n\n", &.{});
+}
+
+test "UTF-8 passes through untouched" {
+ try expectRecords("logradouro\nPraça da Sé\n", &.{
+ &.{"logradouro"},
+ &.{"Praça da Sé"},
+ });
+}
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);
+}
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 0000000..6bb9777
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,118 @@
+//! A one-shot CLI over the library, mostly so `zig build run` can confirm that
+//! an instance answers.
+//!
+//! cepis 01001000
+//! cepis --base-url http://localhost:8080 01001000 20040020
+//! cepis --health
+
+const std = @import("std");
+const cepis = @import("cepis");
+
+const usage =
+ \\usage: cepis [--base-url URL] [--health] [CEP ...]
+ \\
+ \\ --base-url URL where the API lives (default https://cep.is)
+ \\ --health query GET /health instead of looking anything up
+ \\ --help show this message
+ \\
+;
+
+pub fn main(init: std.process.Init) !u8 {
+ const gpa = init.gpa;
+ const io = init.io;
+ const arena = init.arena.allocator();
+
+ var stdout_buffer: [4096]u8 = undefined;
+ var stdout: std.Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
+ const out = &stdout.interface;
+ defer out.flush() catch {};
+
+ const args = try init.minimal.args.toSlice(arena);
+
+ var base_url: []const u8 = cepis.default_url;
+ var want_health = false;
+ var ceps: std.ArrayList([]const u8) = .empty;
+ defer ceps.deinit(gpa);
+
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ const arg = args[i];
+ if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
+ try out.writeAll(usage);
+ return 0;
+ } else if (std.mem.eql(u8, arg, "--health")) {
+ want_health = true;
+ } else if (std.mem.eql(u8, arg, "--base-url")) {
+ i += 1;
+ if (i >= args.len) {
+ try out.writeAll("--base-url needs a value\n");
+ return 2;
+ }
+ base_url = args[i];
+ } else {
+ try ceps.append(gpa, arg);
+ }
+ }
+
+ if (!want_health and ceps.items.len == 0) {
+ try out.writeAll(usage);
+ return 2;
+ }
+
+ var client: cepis.Client = try .init(gpa, io, .{ .base_url = base_url });
+ defer client.deinit();
+
+ var diag: cepis.Diagnostics = .{};
+
+ if (want_health) {
+ var health = client.health(.{ .diagnostics = &diag }) catch |err| {
+ try out.print("health failed: {t}: {s}\n", .{ err, diag.message() });
+ return 1;
+ };
+ defer health.deinit(gpa);
+ try out.print("{s} dne={s} ceps={d} geo={d} gerada_em={s}\n", .{
+ health.status, health.dne, health.ceps, health.geo, health.base_gerada_em,
+ });
+ return 0;
+ }
+
+ if (ceps.items.len == 1) {
+ var address = client.lookup(ceps.items[0], .{ .diagnostics = &diag }) catch |err| {
+ try out.print("{s}: {t}: {s}\n", .{ ceps.items[0], err, diag.message() });
+ return 1;
+ };
+ defer address.deinit(gpa);
+ try printAddress(out, address);
+ return 0;
+ }
+
+ const found = client.lookupMany(ceps.items, .{ .diagnostics = &diag }) catch |err| {
+ try out.print("batch failed: {t}: {s}\n", .{ err, diag.message() });
+ return 1;
+ };
+ defer cepis.freeAddresses(gpa, found);
+
+ for (ceps.items, found) |requested, maybe_address| {
+ if (maybe_address) |address| {
+ try printAddress(out, address);
+ } else {
+ try out.print("{s}: not found\n", .{requested});
+ }
+ }
+ return 0;
+}
+
+fn printAddress(out: *std.Io.Writer, address: cepis.Address) !void {
+ var buffer: [16]u8 = undefined;
+ try out.print("{s} {s}, {s} — {s}/{s}", .{
+ address.formattedCep(&buffer),
+ address.logradouro,
+ address.bairro,
+ address.localidade,
+ address.uf,
+ });
+ if (address.geo) |geo| {
+ try out.print(" [{d:.6},{d:.6} ±{d}m {s}]", .{ geo.lat, geo.lon, geo.raio_m, geo.precisao });
+ }
+ try out.writeAll("\n");
+}
diff --git a/src/model.zig b/src/model.zig
new file mode 100644
index 0000000..10ecbd3
--- /dev/null
+++ b/src/model.zig
@@ -0,0 +1,543 @@
+//! The data the API returns, as types.
+//!
+//! **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+//! `localidade`. Everything else in this library is English; the fields are the
+//! one deliberate exception.
+//!
+//! The reason is fidelity: what you read in the JSON at <https://cep.is/01001000>
+//! is exactly what you access on the value. Translating to `street`/`city`
+//! would mean maintaining a field-by-field mapping and forcing anyone reading
+//! the API docs alongside this library to translate back in their head. The
+//! values are Portuguese either way — they come from the Correios database.
+//!
+//! ## Ownership
+//!
+//! `Address` and `Health` own their strings. Every one of their `[]const u8`
+//! fields points into memory the value allocated, and `deinit` releases it.
+//! Pass the same allocator the `Client` was built with, and treat every slice
+//! as invalid afterwards.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+/// The CEP's coordinate, when the database has one.
+///
+/// The point is the **median** of the coordinates of that CEP's addresses, not
+/// the mean: one badly geocoded address 40 km away shifts a mean and does not
+/// move a median.
+///
+/// Always check `precisao` before using `lat`/`lon` for distance:
+///
+/// - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
+/// 100–150 m. Good for maps and for distance.
+/// - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+/// CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
+/// Computing shipping from this is wrong by tens of kilometres.
+///
+/// `raio_m` is the radius containing 90% of that CEP's addresses. It exists
+/// precisely so that error cannot pass unnoticed.
+pub const Geo = struct {
+ /// Latitude in decimal degrees, WGS 84.
+ lat: f64,
+ /// Longitude in decimal degrees, WGS 84.
+ lon: f64,
+ /// Radius, in metres, containing 90% of the CEP's addresses.
+ raio_m: i64,
+ /// `"logradouro"` (a street) or `"localidade"` (a municipality centroid).
+ /// Borrowed from the owning `Address`; invalid once that is deinitialised.
+ precisao: []const u8,
+
+ /// True when the coordinate points at a street, not a municipality.
+ ///
+ /// Shorthand for `precisao == "logradouro"`, which is the check to make
+ /// before using the point to measure distance.
+ pub fn isPrecise(g: Geo) bool {
+ return std.mem.eql(u8, g.precisao, "logradouro");
+ }
+};
+
+/// The string fields of an `Address`, in the API's own order.
+///
+/// Used to keep the field list, the JSON keys and the CSV columns in step: all
+/// three are driven from this one declaration.
+pub const address_fields = [_][]const u8{
+ "cep", "logradouro", "complemento", "unidade",
+ "bairro", "localidade", "uf", "estado",
+ "regiao", "ibge", "gia", "ddd",
+ "siafi",
+};
+
+/// A CEP and everything the database knows about it.
+///
+/// Fields the database does not have for that CEP come back as empty strings,
+/// never absent — that is what the API returns, and changing it here would
+/// create a difference between reading the JSON and reading this library.
+///
+/// `geo` is the exception: it is null when there is no coordinate. There the
+/// difference is real, because the API **omits** the whole field in that case
+/// (about 43% of CEPs) rather than sending null.
+///
+/// ## Ownership
+///
+/// The value owns every byte its slices point at. Call `deinit` with the
+/// allocator the `Client` was built with exactly once; all slices, including
+/// `geo.precisao` and `raw`, are invalid from then on.
+pub const Address = struct {
+ /// The CEP, always unmasked: `"01001000"`.
+ cep: []const u8 = "",
+ /// Street, avenue, square — the public way.
+ logradouro: []const u8 = "",
+ /// Range or side of the street the CEP covers, when it covers only part.
+ complemento: []const u8 = "",
+ /// Name of the large user or building the CEP belongs to, when it does.
+ unidade: []const u8 = "",
+ /// Neighbourhood.
+ bairro: []const u8 = "",
+ /// Municipality.
+ localidade: []const u8 = "",
+ /// Federative unit, two letters: `"SP"`.
+ uf: []const u8 = "",
+ /// Federative unit spelled out: `"São Paulo"`.
+ estado: []const u8 = "",
+ /// Region: `"Sudeste"`, `"Nordeste"`, and so on.
+ regiao: []const u8 = "",
+ /// IBGE municipality code — the key to join with public datasets.
+ ibge: []const u8 = "",
+ /// GIA code, São Paulo state tax administration only.
+ gia: []const u8 = "",
+ /// Area code for telephony: `"11"`.
+ ddd: []const u8 = "",
+ /// SIAFI municipality code, federal administration.
+ siafi: []const u8 = "",
+
+ /// The coordinate, or null when the database has none for this CEP.
+ ///
+ /// Always null for an address that came from `Client.lookupMany` — batches
+ /// carry no coordinate at all.
+ geo: ?Geo = null,
+
+ /// The response body exactly as it arrived, uninterpreted, so a field the
+ /// API grows before this library is updated is not lost. Parse it yourself
+ /// with `std.json` when you need one.
+ ///
+ /// Empty for an address that came from `Client.lookupMany`: the batch
+ /// endpoint answers in CSV, and a single row is not a document.
+ raw: []const u8 = "",
+
+ /// Backing storage for every string field above. Internal — do not read it
+ /// and do not free it yourself; `deinit` does.
+ bytes: []u8 = &.{},
+
+ /// Release everything the address owns. Pass the allocator the `Client`
+ /// was built with. Every slice on the value is invalid afterwards.
+ pub fn deinit(a: *Address, gpa: Allocator) void {
+ gpa.free(a.bytes);
+ gpa.free(a.raw);
+ a.* = undefined;
+ }
+
+ /// Write the CEP with its mask — `01001-000` — into `buffer`.
+ ///
+ /// The native contract always returns it unmasked; formatting is
+ /// presentation, and it lives here so it does not become a repeated
+ /// `bufPrint` in every project that uses this. A CEP that is not 8 digits
+ /// long is copied through unchanged. The result borrows `buffer`, which
+ /// must hold at least 9 bytes.
+ pub fn formattedCep(a: Address, buffer: []u8) []const u8 {
+ if (a.cep.len != 8 or buffer.len < 9) {
+ const n = @min(a.cep.len, buffer.len);
+ @memcpy(buffer[0..n], a.cep[0..n]);
+ return buffer[0..n];
+ }
+ @memcpy(buffer[0..5], a.cep[0..5]);
+ buffer[5] = '-';
+ @memcpy(buffer[6..9], a.cep[5..8]);
+ return buffer[0..9];
+ }
+
+ /// Build from the JSON object the API returned.
+ ///
+ /// Unknown fields are ignored during construction but preserved in `raw`.
+ /// A new field in the API therefore cannot break this library.
+ ///
+ /// A malformed `geo` block yields `geo = null` rather than failing the
+ /// whole value: it is not worth losing the address over, since the rest of
+ /// the response is still useful.
+ ///
+ /// `body` is duplicated into `raw`; the caller keeps ownership of what it
+ /// passed in. The returned `Address` owns its memory — call `deinit`.
+ pub fn fromJson(gpa: Allocator, value: std.json.Value, body: []const u8) Allocator.Error!Address {
+ var scratch: [address_fields.len][32]u8 = undefined;
+ var values: [address_fields.len][]const u8 = undefined;
+ inline for (address_fields, 0..) |name, i| {
+ values[i] = jsonText(value, name, &scratch[i]);
+ }
+
+ const geo_source = geoFromJson(value);
+ return build(gpa, &values, geo_source, body);
+ }
+
+ /// Build from one CSV record of the batch endpoint, given the header row.
+ ///
+ /// Columns the header does not mention are ignored; columns the header
+ /// mentions but the record lacks come out empty. There is no `geo` column
+ /// in the batch, so `geo` is always null and `raw` always empty.
+ ///
+ /// The returned `Address` owns its memory — call `deinit`.
+ pub fn fromCsvRecord(
+ gpa: Allocator,
+ header: []const []const u8,
+ record: []const []const u8,
+ ) Allocator.Error!Address {
+ var values: [address_fields.len][]const u8 = @splat("");
+ for (header, 0..) |column, i| {
+ if (i >= record.len) break;
+ inline for (address_fields, 0..) |name, slot| {
+ if (std.mem.eql(u8, column, name)) values[slot] = record[i];
+ }
+ }
+ return build(gpa, &values, null, "");
+ }
+
+ /// A `Geo` whose `precisao` still points at borrowed memory, as read out of
+ /// a JSON block but before the strings are packed. Internal.
+ const GeoSource = struct { lat: f64, lon: f64, raio_m: i64, precisao: []const u8 };
+
+ fn build(
+ gpa: Allocator,
+ values: *const [address_fields.len][]const u8,
+ geo_source: ?GeoSource,
+ body: []const u8,
+ ) Allocator.Error!Address {
+ var total: usize = 0;
+ for (values) |v| total += v.len;
+ if (geo_source) |g| total += g.precisao.len;
+
+ const bytes = try gpa.alloc(u8, total);
+ errdefer gpa.free(bytes);
+
+ const raw = try gpa.dupe(u8, body);
+ errdefer gpa.free(raw);
+
+ var address: Address = .{ .bytes = bytes, .raw = raw };
+ var cursor: usize = 0;
+ inline for (address_fields, 0..) |name, i| {
+ const v = values[i];
+ @memcpy(bytes[cursor..][0..v.len], v);
+ @field(address, name) = bytes[cursor..][0..v.len];
+ cursor += v.len;
+ }
+
+ if (geo_source) |g| {
+ @memcpy(bytes[cursor..][0..g.precisao.len], g.precisao);
+ address.geo = .{
+ .lat = g.lat,
+ .lon = g.lon,
+ .raio_m = g.raio_m,
+ .precisao = bytes[cursor..][0..g.precisao.len],
+ };
+ cursor += g.precisao.len;
+ }
+
+ return address;
+ }
+
+ fn geoFromJson(value: std.json.Value) ?GeoSource {
+ const object = switch (value) {
+ .object => |o| o,
+ else => return null,
+ };
+ const block = switch (object.get("geo") orelse return null) {
+ .object => |o| o,
+ else => return null,
+ };
+ const precisao = switch (block.get("precisao") orelse return null) {
+ .string, .number_string => |s| s,
+ else => return null,
+ };
+ return .{
+ .lat = jsonFloat(block.get("lat")) orelse return null,
+ .lon = jsonFloat(block.get("lon")) orelse return null,
+ .raio_m = jsonInt(block.get("raio_m")) orelse return null,
+ .precisao = precisao,
+ };
+ }
+};
+
+/// The response of `GET /health`.
+///
+/// Good for two things: checking that the API answers, and knowing **which
+/// version of the database** is live. The DNE database changes every two weeks;
+/// `dne` identifies it.
+///
+/// ## Ownership
+///
+/// Owns its strings. Call `deinit` with the allocator the `Client` was built
+/// with exactly once.
+pub const Health = struct {
+ /// `"ok"` when the API considers itself healthy.
+ status: []const u8 = "",
+ /// Identifier of the fortnightly DNE database currently loaded.
+ dne: []const u8 = "",
+ /// How many CEPs the database holds.
+ ceps: u64 = 0,
+ /// How many of those carry a coordinate.
+ geo: u64 = 0,
+ /// When the database was generated, RFC 3339 in UTC.
+ base_gerada_em: []const u8 = "",
+ /// The response body exactly as it arrived.
+ raw: []const u8 = "",
+ /// Backing storage for the string fields. Internal.
+ bytes: []u8 = &.{},
+
+ /// Release everything the value owns. Pass the allocator the `Client` was
+ /// built with.
+ pub fn deinit(h: *Health, gpa: Allocator) void {
+ gpa.free(h.bytes);
+ gpa.free(h.raw);
+ h.* = undefined;
+ }
+
+ /// True when the API reports itself healthy.
+ pub fn ok(h: Health) bool {
+ return std.mem.eql(u8, h.status, "ok");
+ }
+
+ /// Build from the JSON object the API returned. `body` is duplicated into
+ /// `raw`; the returned value owns its memory — call `deinit`.
+ pub fn fromJson(gpa: Allocator, value: std.json.Value, body: []const u8) Allocator.Error!Health {
+ var scratch: [3][32]u8 = undefined;
+ const status = jsonText(value, "status", &scratch[0]);
+ const dne = jsonText(value, "dne", &scratch[1]);
+ const base_gerada_em = jsonText(value, "base_gerada_em", &scratch[2]);
+
+ const bytes = try gpa.alloc(u8, status.len + dne.len + base_gerada_em.len);
+ errdefer gpa.free(bytes);
+ const raw = try gpa.dupe(u8, body);
+ errdefer gpa.free(raw);
+
+ @memcpy(bytes[0..status.len], status);
+ @memcpy(bytes[status.len..][0..dne.len], dne);
+ @memcpy(bytes[status.len + dne.len ..][0..base_gerada_em.len], base_gerada_em);
+
+ const maybe_object: ?std.json.ObjectMap = switch (value) {
+ .object => |o| o,
+ else => null,
+ };
+
+ return .{
+ .status = bytes[0..status.len],
+ .dne = bytes[status.len..][0..dne.len],
+ .base_gerada_em = bytes[status.len + dne.len ..][0..base_gerada_em.len],
+ .ceps = if (maybe_object) |o| jsonCount(o.get("ceps")) else 0,
+ .geo = if (maybe_object) |o| jsonCount(o.get("geo")) else 0,
+ .raw = raw,
+ .bytes = bytes,
+ };
+ }
+};
+
+/// Read `key` off a JSON object as text.
+///
+/// A string comes through as-is; a number is rendered into `scratch`; anything
+/// else — including a missing key and an explicit null — is the empty string,
+/// which is what the API sends for a field the database lacks.
+fn jsonText(value: std.json.Value, key: []const u8, scratch: []u8) []const u8 {
+ const object = switch (value) {
+ .object => |o| o,
+ else => return "",
+ };
+ return switch (object.get(key) orelse return "") {
+ .string, .number_string => |s| s,
+ .integer => |n| std.fmt.bufPrint(scratch, "{d}", .{n}) catch "",
+ .float => |n| std.fmt.bufPrint(scratch, "{d}", .{n}) catch "",
+ .bool => |b| if (b) "true" else "false",
+ else => "",
+ };
+}
+
+fn jsonFloat(maybe: ?std.json.Value) ?f64 {
+ return switch (maybe orelse return null) {
+ .float => |n| n,
+ .integer => |n| @floatFromInt(n),
+ .number_string, .string => |s| std.fmt.parseFloat(f64, s) catch null,
+ else => null,
+ };
+}
+
+fn jsonInt(maybe: ?std.json.Value) ?i64 {
+ return switch (maybe orelse return null) {
+ .integer => |n| n,
+ .float => |n| @intFromFloat(n),
+ .number_string, .string => |s| std.fmt.parseInt(i64, s, 10) catch null,
+ else => null,
+ };
+}
+
+fn jsonCount(maybe: ?std.json.Value) u64 {
+ return switch (maybe orelse return 0) {
+ .integer => |n| if (n > 0) @intCast(n) else 0,
+ .float => |n| if (n > 0) @intFromFloat(n) else 0,
+ .number_string, .string => |s| std.fmt.parseInt(u64, s, 10) catch 0,
+ else => 0,
+ };
+}
+
+const testing = std.testing;
+
+fn parseValue(body: []const u8) !std.json.Parsed(std.json.Value) {
+ return std.json.parseFromSlice(std.json.Value, testing.allocator, body, .{});
+}
+
+test "Address.fromJson reads every field and owns its strings" {
+ const body =
+ \\{"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",
+ \\ "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP",
+ \\ "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",
+ \\ "ddd":"11","siafi":"7107"}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var address = try Address.fromJson(testing.allocator, parsed.value, body);
+ defer address.deinit(testing.allocator);
+
+ try testing.expectEqualStrings("01001000", address.cep);
+ try testing.expectEqualStrings("Praça da Sé", address.logradouro);
+ try testing.expectEqualStrings("São Paulo", address.localidade);
+ try testing.expectEqualStrings("SP", address.uf);
+ try testing.expectEqualStrings("7107", address.siafi);
+ try testing.expectEqualStrings("", address.unidade);
+ try testing.expect(address.geo == null);
+ try testing.expectEqualStrings(body, address.raw);
+
+ var buffer: [16]u8 = undefined;
+ try testing.expectEqualStrings("01001-000", address.formattedCep(&buffer));
+}
+
+test "Address.fromJson reads a geo block" {
+ const body =
+ \\{"cep":"01310100","geo":{"lat":-23.567202,"lon":-46.649659,
+ \\ "raio_m":1084,"precisao":"logradouro"}}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var address = try Address.fromJson(testing.allocator, parsed.value, body);
+ defer address.deinit(testing.allocator);
+
+ const geo = address.geo.?;
+ try testing.expectApproxEqAbs(@as(f64, -23.567202), geo.lat, 1e-9);
+ try testing.expectApproxEqAbs(@as(f64, -46.649659), geo.lon, 1e-9);
+ try testing.expectEqual(@as(i64, 1084), geo.raio_m);
+ try testing.expect(geo.isPrecise());
+}
+
+test "a municipality coordinate is not precise" {
+ const body =
+ \\{"cep":"69900000","geo":{"lat":-9.9,"lon":-67.8,"raio_m":22000,
+ \\ "precisao":"localidade"}}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var address = try Address.fromJson(testing.allocator, parsed.value, body);
+ defer address.deinit(testing.allocator);
+
+ try testing.expect(!address.geo.?.isPrecise());
+}
+
+test "a malformed geo block does not lose the address" {
+ const body =
+ \\{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var address = try Address.fromJson(testing.allocator, parsed.value, body);
+ defer address.deinit(testing.allocator);
+
+ try testing.expect(address.geo == null);
+ try testing.expectEqualStrings("São Paulo", address.localidade);
+}
+
+test "an unknown field survives in raw and breaks nothing" {
+ const body =
+ \\{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var address = try Address.fromJson(testing.allocator, parsed.value, body);
+ defer address.deinit(testing.allocator);
+
+ try testing.expectEqualStrings("São Paulo", address.localidade);
+ try testing.expect(std.mem.indexOf(u8, address.raw, "campo_do_futuro") != null);
+}
+
+test "a numeric field is rendered as text" {
+ const body =
+ \\{"cep":"01001000","ibge":3550308,"gia":null}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var address = try Address.fromJson(testing.allocator, parsed.value, body);
+ defer address.deinit(testing.allocator);
+
+ try testing.expectEqualStrings("3550308", address.ibge);
+ try testing.expectEqualStrings("", address.gia);
+}
+
+test "Address.fromCsvRecord maps by header and carries no geo" {
+ const header = [_][]const u8{ "cep", "logradouro", "localidade", "uf", "coluna_nova" };
+ const record = [_][]const u8{ "01001000", "Praça da Sé", "São Paulo", "SP", "ignored" };
+
+ var address = try Address.fromCsvRecord(testing.allocator, &header, &record);
+ defer address.deinit(testing.allocator);
+
+ try testing.expectEqualStrings("01001000", address.cep);
+ try testing.expectEqualStrings("Praça da Sé", address.logradouro);
+ try testing.expectEqualStrings("SP", address.uf);
+ try testing.expectEqualStrings("", address.siafi);
+ try testing.expect(address.geo == null);
+ try testing.expectEqualStrings("", address.raw);
+}
+
+test "formattedCep passes a malformed CEP through" {
+ var address: Address = .{ .cep = "123" };
+ var buffer: [16]u8 = undefined;
+ try testing.expectEqualStrings("123", address.formattedCep(&buffer));
+}
+
+test "Health.fromJson reads counts and status" {
+ const body =
+ \\{"status":"ok","dne":"26071","ceps":1596168,"geo":894489,
+ \\ "base_gerada_em":"2026-07-24T03:48:28Z"}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var health = try Health.fromJson(testing.allocator, parsed.value, body);
+ defer health.deinit(testing.allocator);
+
+ try testing.expect(health.ok());
+ try testing.expectEqualStrings("26071", health.dne);
+ try testing.expectEqual(@as(u64, 1_596_168), health.ceps);
+ try testing.expectEqual(@as(u64, 894_489), health.geo);
+ try testing.expectEqualStrings("2026-07-24T03:48:28Z", health.base_gerada_em);
+}
+
+test "a degraded Health is still a Health" {
+ const body =
+ \\{"status":"degradado"}
+ ;
+ const parsed = try parseValue(body);
+ defer parsed.deinit();
+
+ var health = try Health.fromJson(testing.allocator, parsed.value, body);
+ defer health.deinit(testing.allocator);
+
+ try testing.expect(!health.ok());
+ try testing.expectEqual(@as(u64, 0), health.ceps);
+}
diff --git a/src/retry.zig b/src/retry.zig
new file mode 100644
index 0000000..5caab09
--- /dev/null
+++ b/src/retry.zig
@@ -0,0 +1,152 @@
+//! Backoff arithmetic and `Retry-After` parsing.
+//!
+//! Everything here is pure: the clock and the randomness arrive as arguments,
+//! which is what makes the policy testable without a network or a sleep.
+
+const std = @import("std");
+
+/// A random wait in `[0, ceiling_ms]` — *full jitter*.
+///
+/// The randomness is not decoration: without it, N clients that fail together
+/// come back together and reproduce the spike that took the service down.
+pub fn jitter(random: std.Random, ceiling_ms: u64) u64 {
+ if (ceiling_ms == 0) return 0;
+ return random.uintLessThan(u64, ceiling_ms + 1);
+}
+
+/// The ceiling of the exponential wait for `attempt`, capped at `max_ms`.
+///
+/// `attempt` counts from 1, so the first wait is `base_ms`, the second twice
+/// that, and so on until the cap.
+pub fn ceilingMs(attempt: u32, base_ms: u64, max_ms: u64) u64 {
+ if (base_ms == 0) return 0;
+ const shift: u6 = @intCast(@min(attempt -| 1, 63));
+ const scaled = std.math.shlExact(u64, base_ms, shift) catch return max_ms;
+ return @min(scaled, max_ms);
+}
+
+/// Parse the `Retry-After` header in both RFC 9110 forms, in milliseconds.
+///
+/// It may arrive as a number of seconds (`"120"`) or as an HTTP date
+/// (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common
+/// bug, and it makes a client ignore exactly the wait request that matters
+/// most.
+///
+/// `now_unix` is the current time in seconds since the Unix epoch, so a date
+/// can be turned into a delay. A date already in the past yields 0; anything
+/// unparseable yields null.
+pub fn parseRetryAfter(value: []const u8, now_unix: i64) ?u64 {
+ const text = std.mem.trim(u8, value, " \t");
+ if (text.len == 0) return null;
+
+ if (std.fmt.parseFloat(f64, text)) |seconds| {
+ if (!std.math.isFinite(seconds)) return null;
+ if (seconds <= 0) return 0;
+ return @intFromFloat(@min(seconds * 1000.0, @as(f64, @floatFromInt(std.math.maxInt(u64)))));
+ } else |_| {}
+
+ const target = httpDateToUnix(text) orelse return null;
+ if (target <= now_unix) return 0;
+ const delta: u64 = @intCast(target - now_unix);
+ return delta *| 1000;
+}
+
+/// Seconds since the Unix epoch for an IMF-fixdate, the form HTTP servers must
+/// send: `Wed, 21 Oct 2026 07:28:00 GMT`. The two obsolete formats RFC 9110
+/// still mentions are not accepted.
+pub fn httpDateToUnix(value: []const u8) ?i64 {
+ const rest = if (std.mem.indexOf(u8, value, ", ")) |i| value[i + 2 ..] else value;
+ var parts = std.mem.tokenizeAny(u8, rest, " \t");
+
+ const day = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null;
+ const month = monthNumber(parts.next() orelse return null) orelse return null;
+ const year = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null;
+
+ var clock = std.mem.splitScalar(u8, parts.next() orelse return null, ':');
+ const hour = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null;
+ const minute = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null;
+ const second = std.fmt.parseInt(i64, clock.next() orelse return null, 10) catch return null;
+
+ if (day < 1 or day > 31) return null;
+ if (hour < 0 or hour > 23) return null;
+ if (minute < 0 or minute > 59) return null;
+ if (second < 0 or second > 60) return null;
+
+ return daysFromCivil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second;
+}
+
+fn monthNumber(name: []const u8) ?i64 {
+ const months = [_][]const u8{
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ };
+ for (months, 0..) |month, i| {
+ if (std.mem.eql(u8, month, name)) return @intCast(i + 1);
+ }
+ return null;
+}
+
+/// Days between 1970-01-01 and the given proleptic Gregorian date.
+fn daysFromCivil(year_in: i64, month: i64, day: i64) i64 {
+ const year = if (month <= 2) year_in - 1 else year_in;
+ const era = @divFloor(if (year >= 0) year else year - 399, 400);
+ const year_of_era = year - era * 400;
+ const month_shifted = @mod(month + 9, 12);
+ const day_of_year = @divTrunc(153 * month_shifted + 2, 5) + day - 1;
+ const day_of_era = year_of_era * 365 + @divTrunc(year_of_era, 4) - @divTrunc(year_of_era, 100) + day_of_year;
+ return era * 146_097 + day_of_era - 719_468;
+}
+
+const testing = std.testing;
+
+test "reads plain seconds" {
+ try testing.expectEqual(@as(?u64, 7000), parseRetryAfter(" 7 ", 0));
+ try testing.expectEqual(@as(?u64, 0), parseRetryAfter("0", 0));
+}
+
+test "negative seconds become zero" {
+ try testing.expectEqual(@as(?u64, 0), parseRetryAfter("-5", 0));
+}
+
+test "reads an HTTP date" {
+ try testing.expectEqual(@as(?i64, 1_792_567_680), httpDateToUnix("Wed, 21 Oct 2026 07:28:00 GMT"));
+}
+
+test "a date in the past is zero" {
+ try testing.expectEqual(@as(?u64, 0), parseRetryAfter("Thu, 01 Jan 1970 00:00:00 GMT", 1_700_000_000));
+}
+
+test "a date in the future is a delay" {
+ const epoch = httpDateToUnix("Wed, 21 Oct 2026 07:28:00 GMT").?;
+ try testing.expectEqual(@as(?u64, 60_000), parseRetryAfter("Wed, 21 Oct 2026 07:28:00 GMT", epoch - 60));
+}
+
+test "rejects nonsense" {
+ try testing.expectEqual(@as(?u64, null), parseRetryAfter("soon", 0));
+ try testing.expectEqual(@as(?u64, null), parseRetryAfter("", 0));
+ try testing.expectEqual(@as(?u64, null), parseRetryAfter(" ", 0));
+ try testing.expectEqual(@as(?u64, null), parseRetryAfter("Mon, 99 Xyz 2026 07:28:00 GMT", 0));
+}
+
+test "civil days match known dates" {
+ try testing.expectEqual(@as(i64, 0), daysFromCivil(1970, 1, 1));
+ try testing.expectEqual(@as(i64, 11_017), daysFromCivil(2000, 3, 1));
+ try testing.expectEqual(@as(i64, -719_468), daysFromCivil(0, 3, 1));
+}
+
+test "the ceiling doubles and then caps" {
+ try testing.expectEqual(@as(u64, 500), ceilingMs(1, 500, 8000));
+ try testing.expectEqual(@as(u64, 1000), ceilingMs(2, 500, 8000));
+ try testing.expectEqual(@as(u64, 2000), ceilingMs(3, 500, 8000));
+ try testing.expectEqual(@as(u64, 8000), ceilingMs(9, 500, 8000));
+ try testing.expectEqual(@as(u64, 8000), ceilingMs(999, 500, 8000));
+}
+
+test "jitter stays within the ceiling" {
+ var prng: std.Random.DefaultPrng = .init(1234);
+ const random = prng.random();
+ for (0..1000) |_| {
+ try testing.expect(jitter(random, 50) <= 50);
+ }
+ try testing.expectEqual(@as(u64, 0), jitter(random, 0));
+}
diff --git a/src/root.zig b/src/root.zig
new file mode 100644
index 0000000..0a83c5c
--- /dev/null
+++ b/src/root.zig
@@ -0,0 +1,90 @@
+//! Zig client for the public CEP API at [cep.is](https://cep.is). Official
+//! Correios data (DNE), refreshed every two weeks.
+//!
+//! Zig has no ambient allocator and, since 0.16, no ambient I/O either, so
+//! there is no module-level `lookup`: everything goes through a `Client` you
+//! build with both. That is one extra line and it is the honest one.
+//!
+//! ```zig
+//! const std = @import("std");
+//! const cepis = @import("cepis");
+//!
+//! pub fn main() !void {
+//! var debug: std.heap.DebugAllocator(.{}) = .init;
+//! defer _ = debug.deinit();
+//! const gpa = debug.allocator();
+//!
+//! var threaded: std.Io.Threaded = .init(gpa, .{});
+//! defer threaded.deinit();
+//!
+//! var client: cepis.Client = try .init(gpa, threaded.io(), .{});
+//! defer client.deinit();
+//!
+//! var address = try client.lookup("01001-000", .{});
+//! defer address.deinit(gpa);
+//!
+//! std.debug.print("{s} {s}\n", .{ address.localidade, address.uf });
+//! }
+//! ```
+//!
+//! Pointing at a local instance (the API repo brings one up with `just up`)
+//! is a matter of `base_url`:
+//!
+//! ```zig
+//! var client: cepis.Client = try .init(gpa, io, .{ .base_url = "http://localhost:8080" });
+//! ```
+//!
+//! Errors are one set, split by decision — whether retrying helps or not:
+//!
+//! ```zig
+//! var diag: cepis.Diagnostics = .{};
+//! const address = client.lookup(cep_from_form, .{ .diagnostics = &diag }) catch |err| switch (err) {
+//! error.NotFound => return askForAnotherCep(),
+//! else => if (cepis.isRetriable(err)) return reschedule() else return err,
+//! };
+//! ```
+//!
+//! Note on field names: the API surface is English, but the **data fields keep
+//! the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`).
+//! What you read in the JSON is what you access on the value.
+//!
+//! Every value this library returns owns its memory and has a `deinit` taking
+//! the allocator the `Client` was built with. Each type documents exactly what
+//! it owns.
+
+const client = @import("client.zig");
+const errors = @import("errors.zig");
+const model = @import("model.zig");
+
+pub const Client = client.Client;
+pub const Config = client.Config;
+pub const CallOptions = client.CallOptions;
+pub const InitError = client.InitError;
+pub const normalizeCep = client.normalizeCep;
+pub const freeAddresses = client.freeAddresses;
+
+pub const default_url = client.default_url;
+pub const default_user_agent = client.default_user_agent;
+pub const max_batch = client.max_batch;
+pub const retriable_status = client.retriable_status;
+
+/// The version of this library, as sent in the `User-Agent`.
+pub const version = client.version;
+
+pub const Error = errors.Error;
+pub const CepisError = errors.CepisError;
+pub const Diagnostics = errors.Diagnostics;
+pub const isRetriable = errors.isRetriable;
+
+pub const Address = model.Address;
+pub const Geo = model.Geo;
+pub const Health = model.Health;
+
+test {
+ @import("std").testing.refAllDecls(@This());
+ _ = @import("csv.zig");
+ _ = @import("retry.zig");
+ _ = client;
+ _ = errors;
+ _ = model;
+}