summaryrefslogtreecommitdiffstats
path: root/src/client.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/client.zig')
-rw-r--r--src/client.zig804
1 files changed, 804 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());
+}