/** * The HTTP client, built on the platform's `fetch`. * * 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. * * @module */ import { parseCsv } from "./csv.js"; import { CEPNotFound, InvalidCEP, InvalidResponse, NetworkError, RateLimited, ServerError, } from "./errors.js"; import { Address, Faixa, Health, type RawObject } from "./models.js"; import { VERSION } from "./version.js"; /** Where the public API lives. */ export const DEFAULT_URL = "https://cep.is"; /** How many CEPs the `/csv` endpoint accepts in one request. */ export const MAX_BATCH = 500; const RETRIABLE_STATUS = new Set([429, 500, 502, 503, 504]); const DIGITS_ONLY = /^\d{8}$/; const DELAY_SECONDS = /^\d+$/; const ACCEPT = "application/json, text/csv;q=0.9, */*;q=0.1"; /** * Return the CEP's 8 digits, accepting the mask. * * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. * * Throws {@link 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. */ export function normalizeCep(value: string): string { if (typeof value !== "string") { throw new InvalidCEP(`CEP must be a string, got ${typeof value}`); } const cleaned = value.trim().replaceAll("-", "").replaceAll(".", "").replaceAll(" ", ""); if (!DIGITS_ONLY.test(cleaned)) { throw new InvalidCEP(`CEP must be 8 digits, got ${JSON.stringify(value)}`); } return cleaned; } function retryAfterMs(value: string | null): number | undefined { if (value === null) { return undefined; } const header = value.trim(); if (header === "") { return undefined; } if (DELAY_SECONDS.test(header)) { return Number(header) * 1000; } const target = Date.parse(header); if (Number.isNaN(target)) { return undefined; } return Math.max(0, target - Date.now()); } function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function describe(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } /** * Everything that can be configured on a {@link Client}. Every field is * optional; the defaults are the ones documented on each property. */ export interface ClientOptions { /** * Where the API lives. Default `https://cep.is`. * * 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 option that usually changes between environments. A trailing slash is * stripped, so it cannot produce doubled separators that some proxies * normalise and others 404. */ baseUrl?: string; /** * Milliseconds allowed for a **single attempt**, request and body read * included. Default `10_000`. * * `fetch` has no per-socket timeout, so this is one deadline over the whole * exchange rather than one per connect and read. The cap on the whole call, * retries and waits included, is {@link ClientOptions.totalTimeoutMs}. */ timeoutMs?: number; /** * How many attempts in total, counting the first. Default `3`; `1` disables * retrying. * * Three covers the common case (one transient failure) without turning a * real outage into a long wait. */ maxAttempts?: number; /** * Base of the exponential wait, in milliseconds. Default `500`. * * 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. */ backoffBaseMs?: number; /** * Cap on each individual wait, in milliseconds. Default `8_000`. * * 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. */ backoffMaxMs?: number; /** * Cap on the whole call, attempts and waits included, in milliseconds. * Undefined by default, which leaves `maxAttempts` as the only bound. * * Set it in code serving a web request: without it the worst case is * `maxAttempts * timeoutMs` plus the sum of the backoffs. */ totalTimeoutMs?: number; /** * Overrides the `User-Agent`. The default identifies the library and its * version, which is what lets the server diagnose an old client * misbehaving. */ userAgent?: string; } interface Reply { status: number; body: string; headers: Headers; } /** * Client for the cep.is API. * * Reuse one instance: the configuration is read-only and no method keeps state * between calls, so it is safe to share anywhere in a process. * * ```ts * const client = new Client({ timeoutMs: 5_000, totalTimeoutMs: 15_000 }); * const address = await client.lookup("01001-000"); * ``` */ export class Client { /** The API root, without a trailing slash. */ readonly baseUrl: string; /** Milliseconds allowed for a single attempt. */ readonly timeoutMs: number; /** How many attempts in total, counting the first. */ readonly maxAttempts: number; /** Base of the exponential wait, in milliseconds. */ readonly backoffBaseMs: number; /** Cap on each individual wait, in milliseconds. */ readonly backoffMaxMs: number; /** Cap on the whole call, in milliseconds, or `undefined` for none. */ readonly totalTimeoutMs?: number; /** The `User-Agent` sent with every request. */ readonly userAgent: string; /** * @throws {RangeError} if `maxAttempts` is below 1, or a timeout is not * positive. */ constructor(options: ClientOptions = {}) { const { baseUrl = DEFAULT_URL, timeoutMs = 10_000, maxAttempts = 3, backoffBaseMs = 500, backoffMaxMs = 8_000, totalTimeoutMs, userAgent, } = options; if (!Number.isFinite(maxAttempts) || maxAttempts < 1) { throw new RangeError("maxAttempts must be >= 1"); } if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new RangeError("timeoutMs must be > 0"); } if (totalTimeoutMs !== undefined && (!Number.isFinite(totalTimeoutMs) || totalTimeoutMs <= 0)) { throw new RangeError("totalTimeoutMs must be > 0"); } this.baseUrl = baseUrl.replace(/\/+$/, ""); this.timeoutMs = timeoutMs; this.maxAttempts = Math.trunc(maxAttempts); this.backoffBaseMs = backoffBaseMs; this.backoffMaxMs = backoffMaxMs; this.totalTimeoutMs = totalTimeoutMs; this.userAgent = userAgent ?? `cepis-typescript/${VERSION} (+https://cep.is)`; } /** * Look up a single CEP. * * ```ts * const address = await client.lookup("01001-000"); * address.localidade; // "São Paulo" * ``` * * A 400 from the API surfaces as {@link InvalidCEP} carrying the API's own * message: the format already passed local validation, so the server knows * something this client does not. * * A CEP that does not exist throws {@link CEPNotFound}, which may carry a * {@link Faixa} naming the municipality the number would belong to: * * ```ts * try { * await client.lookup("25845000"); * } catch (error) { * if (error instanceof CEPNotFound && error.faixa) { * error.faixa.localidade; // "Areal" — still a 404, still no address * } * } * ``` * * @throws {InvalidCEP} the CEP is not 8 digits — no request is made. * @throws {CEPNotFound} well-formed, but not in the database. * @throws {RateLimited} 429 after every attempt. * @throws {ServerError} 5xx after every attempt. * @throws {NetworkError} the request never completed. * @throws {InvalidResponse} a success status with an unreadable body. */ async lookup(cep: string): Promise
{ const number = normalizeCep(cep); const reply = await this.request(`/${number}`); if (reply.status === 404) { const faixa = Faixa.fromJson(errorBody(reply.body)?.faixa); const belongs = faixa === undefined ? "" : ` — that band belongs to ${faixa.label}`; throw new CEPNotFound( `CEP ${number} does not exist in the DNE database${belongs}`, number, faixa, ); } if (reply.status === 400) { throw new InvalidCEP(errorMessage(reply.body, `CEP ${number} rejected`)); } return Address.fromJson(this.json(reply.body)); } /** * Look up many CEPs in one batch. * * Resolves to an array 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 array, and it does not throw. That is the right * difference from {@link Client.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. To know *which* * one was malformed, validate first with {@link normalizeCep}. * * Input is chunked into blocks of {@link MAX_BATCH} automatically. * * **Batches carry no coordinate.** The endpoint behind this 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 * {@link Client.lookup}, one at a time. */ async lookupMany(ceps: readonly string[]): Promise<(Address | null)[]> { if (ceps.length === 0) { return []; } const result: (Address | null)[] = []; for (let start = 0; start < ceps.length; start += MAX_BATCH) { result.push(...(await this.lookupChunk(ceps.slice(start, start + MAX_BATCH)))); } return result; } /** * Query `GET /health`: does the API answer, and with which database. * * Does not throw for an API reporting itself degraded — that arrives in * `Health.status`, and checking health is exactly when you want the * diagnosis rather than an exception. Network failures still throw. */ async health(): Promise { const reply = await this.request("/health", [200, 503]); return Health.fromJson(this.json(reply.body)); } private async lookupChunk(ceps: readonly string[]): Promise<(Address | null)[]> { const query = new URLSearchParams({ ceps: ceps.join(",") }).toString(); const reply = await this.request(`/csv?${query}`); const rows: (Address | null)[] = []; for (const record of parseCsv(reply.body)) { if ((record.localidade ?? "").trim() === "") { rows.push(null); continue; } rows.push(Address.fromJson(record)); } while (rows.length < ceps.length) { rows.push(null); } return rows.slice(0, ceps.length); } private json(body: string): RawObject { let data: unknown; try { data = JSON.parse(body); } catch (error) { throw new InvalidResponse( "response is not valid JSON — something between you and the API is " + `answering instead of it: ${JSON.stringify(body.slice(0, 120))}`, { cause: error }, ); } if (typeof data !== "object" || data === null || Array.isArray(data)) { throw new InvalidResponse( `expected a JSON object, got ${Array.isArray(data) ? "array" : typeof data}`, ); } return data as RawObject; } private backoff(attempt: number, retryAfter: number | undefined): number { if (retryAfter !== undefined) { return Math.min(retryAfter, this.backoffMaxMs); } const ceiling = Math.min(this.backoffBaseMs * 2 ** (attempt - 1), this.backoffMaxMs); return Math.random() * ceiling; } private async request(path: string, acceptedStatus: readonly number[] = [200]): Promise { const url = `${this.baseUrl}${path}`; const deadline = this.totalTimeoutMs === undefined ? undefined : performance.now() + this.totalTimeoutMs; let lastStatus: number | undefined; let lastRetryAfter: number | undefined; let lastFailure: unknown; for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) { let budget = this.timeoutMs; if (deadline !== undefined) { const remaining = deadline - performance.now(); if (remaining <= 0) { break; } budget = Math.min(budget, remaining); } try { const response = await fetch(url, { method: "GET", redirect: "follow", headers: { "User-Agent": this.userAgent, Accept: ACCEPT, }, signal: AbortSignal.timeout(Math.max(1, Math.ceil(budget))), }); const body = await response.text(); if (acceptedStatus.includes(response.status) || !RETRIABLE_STATUS.has(response.status)) { return { status: response.status, body, headers: response.headers }; } lastStatus = response.status; lastRetryAfter = retryAfterMs(response.headers.get("Retry-After")); lastFailure = undefined; } catch (error) { lastFailure = error; lastStatus = undefined; lastRetryAfter = undefined; } if (attempt === this.maxAttempts) { break; } let wait = this.backoff(attempt, lastRetryAfter); if (deadline !== undefined) { const remaining = deadline - performance.now(); if (remaining <= 0) { break; } wait = Math.min(wait, remaining); } await sleep(wait); } const suffix = `(after ${this.maxAttempts} attempt(s))`; if (lastStatus === 429) { throw new RateLimited(`rate limit reached at ${url} ${suffix}`, lastRetryAfter); } if (lastStatus !== undefined) { throw new ServerError(`the API answered ${lastStatus} at ${url} ${suffix}`, lastStatus); } throw new NetworkError(`could not reach ${url} ${suffix}: ${describe(lastFailure)}`, { cause: lastFailure, }); } } /** * The body of an error response as an object, or `undefined` when it is not * one. * * Never throws. An error response is already the bad news; a proxy answering * with HTML in its place must not upgrade that into a second, different * failure. */ function errorBody(body: string): RawObject | undefined { let data: unknown; try { data = JSON.parse(body); } catch { return undefined; } if (typeof data !== "object" || data === null || Array.isArray(data)) { return undefined; } return data as RawObject; } function errorMessage(body: string, fallback: string): string { const message = errorBody(body)?.mensagem; if (typeof message === "string" && message !== "") { return message; } return fallback; }