lookup('01001-000')->localidade; // São Paulo * ``` * * For a script, {@see Client::default()} is the shared, default-configured * instance: * * ```php * echo Cepis\Client::default()->lookup('01001-000')->uf; // SP * ``` * * Retries cover 429, the transient 5xx in {@see Client::RETRIABLE_STATUS} and * network failures, with exponential backoff and full jitter. 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. */ final class Client { public const VERSION = '0.2.0'; /** * Where the API lives, unless `baseUrl` says otherwise. */ public const DEFAULT_URL = 'https://cep.is'; /** * Most CEPs the API accepts in a single batch request. */ public const MAX_BATCH = 500; /** * Statuses worth trying again. */ public const RETRIABLE_STATUS = [429, 500, 502, 503, 504]; /** * Where the API lives. A trailing slash is stripped, so it cannot produce * doubled separators that some proxies normalise and others 404. */ public readonly string $baseUrl; /** * Identifies the library and its version to the server. */ public readonly string $userAgent; /** * How the GET is actually performed. {@see CurlTransport} unless the * caller supplied one. */ public readonly Transport $transport; private static ?self $default = null; /** * @param string $baseUrl 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. * @param float $timeout Seconds to allow for **a single attempt** — * connection, response and body read together. It is not a cap on the * whole call; that cap is `$totalTimeout`. * @param int $maxAttempts 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. * @param float $backoffBase Base of the exponential wait, in seconds. 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. * @param float $backoffMax Cap on each individual wait, in seconds. It * applies to a server-sent `Retry-After` too, or a * `Retry-After: 3600` from a misconfigured proxy would park the * process for an hour. * @param float|null $totalTimeout Cap on the whole call, attempts and * waits included. `null` leaves `$maxAttempts` as the only bound. Set * it in code serving a web request: without it the worst case is * `$maxAttempts * $timeout` plus the sum of the backoffs. * @param string|null $userAgent Overrides the `User-Agent`. The default * identifies the library and its version, which is what lets the * server diagnose an old client misbehaving. * @param Transport|null $transport Overrides how the GET is performed. * Supply one to route requests through the host application's HTTP * layer — a WordPress plugin passes an implementation over * `wp_remote_get`. `null` uses curl directly. * * @throws \InvalidArgumentException a setting is out of range. */ public function __construct( string $baseUrl = self::DEFAULT_URL, public readonly float $timeout = 10.0, public readonly int $maxAttempts = 3, public readonly float $backoffBase = 0.5, public readonly float $backoffMax = 8.0, public readonly ?float $totalTimeout = null, ?string $userAgent = null, ?Transport $transport = null, ) { if ($maxAttempts < 1) { throw new \InvalidArgumentException('maxAttempts must be >= 1'); } if ($timeout <= 0) { throw new \InvalidArgumentException('timeout must be > 0'); } if ($backoffBase < 0 || $backoffMax < 0) { throw new \InvalidArgumentException('backoffBase and backoffMax must be >= 0'); } $this->baseUrl = rtrim($baseUrl, '/'); $this->userAgent = $userAgent ?? sprintf('cepis-php/%s (+https://cep.is)', self::VERSION); $this->transport = $transport ?? new CurlTransport(); } /** * The shared, default-configured client. * * Built once because {@see Client} keeps no state between calls. It * applies process-wide, third-party libraries that also use this package * included. Anyone needing different settings should build their own with * `new Client(...)`. */ public static function default(): self { return self::$default ??= new self(); } /** * Return the CEP's 8 digits, accepting the mask. * * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become * `"01001000"`. * * 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. * * @throws InvalidCepException unless exactly 8 digits remain. */ public static function normalizeCep(string $value): string { $cleaned = str_replace(['-', '.', ' '], '', trim($value)); if (preg_match('/^\d{8}$/', $cleaned) !== 1) { throw new InvalidCepException(sprintf('CEP must be 8 digits, got "%s"', $value)); } return $cleaned; } /** * Look up a single CEP, masked or unmasked. * * ```php * $client->lookup('01001-000')->localidade; // "São Paulo" * ``` * * A 400 from the API is surfaced as {@see InvalidCepException} carrying * the API's own message: the format already passed local validation, so * the server knows something this client does not. * * A 404 is surfaced as {@see CepNotFoundException}, which may carry a * {@see Faixa} naming the municipality the CEP would have belonged to. It * is still a 404 either way: the `faixa` is context for the error, never an * address, and a CEP that has one is just as absent from the DNE as one * that does not. * * @throws InvalidCepException the CEP is not 8 digits (no request is made). * @throws CepNotFoundException well-formed, but not in the database. * @throws RateLimitedException 429 after every attempt. * @throws ServerErrorException 5xx after every attempt. * @throws NetworkException the request never completed. * @throws InvalidResponseException a success status with an unreadable body. */ public function lookup(string $cep): Address { $number = self::normalizeCep($cep); [$status, $body] = $this->request('/' . $number); if ($status === 404) { $faixa = self::errorFaixa($body); $message = sprintf('CEP %s does not exist in the DNE database', $number); if ($faixa !== null) { $message .= sprintf(' (the range covering it is %s)', $faixa->label()); } throw new CepNotFoundException($message, $faixa); } if ($status === 400) { throw new InvalidCepException( self::errorMessage($body, sprintf('CEP %s rejected', $number)), ); } return Address::fromArray($this->decodeJsonObject($body)); } /** * Look up many CEPs in one batch. * * Returns an array of **the same length and in the same order** as the * input, re-indexed from zero. 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 {@see 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, rather than * throwing {@see InvalidCepException}. To know *which* one was malformed, * validate first with {@see Client::normalizeCep()}. * * Input is chunked into blocks of {@see Client::MAX_BATCH} (the API's * limit) automatically. Do not loop over {@see Client::lookup()}: a loop of * 500 calls pays 500 TLS handshakes, the batch pays one. * * **Batches carry no coordinate.** The endpoint behind them is CSV, and * the CSV has no `geo` column. Every {@see Address} returned here has a * `null` `geo`, including for CEPs that do have one. Need the coordinate? * Use {@see Client::lookup()}, one at a time. * * @param list $ceps * * @return list */ public function lookupMany(array $ceps): array { if ($ceps === []) { return []; } $result = []; foreach (array_chunk(array_values($ceps), self::MAX_BATCH) as $chunk) { foreach ($this->lookupChunk($chunk) as $address) { $result[] = $address; } } 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 * {@see Health::$status}, and checking health is exactly when you want the * diagnosis rather than an exception. Network failures still throw. */ public function health(): Health { [, $body] = $this->request('/health', [200, 503]); return Health::fromArray($this->decodeJsonObject($body)); } /** * One chunk of at most {@see Client::MAX_BATCH} CEPs, in a single request. * * 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 result is padded to the * input length rather than returned short, because a shifted array * silently pairs the wrong address with the wrong CEP. * * @param list $ceps * * @return list */ private function lookupChunk(array $ceps): array { $query = http_build_query(['ceps' => implode(',', $ceps)]); [, $body] = $this->request('/csv?' . $query); $rows = []; foreach ($this->parseCsv($body) as $row) { $rows[] = trim($row['localidade'] ?? '') === '' ? null : Address::fromArray($row); } $wanted = count($ceps); if (count($rows) !== $wanted) { $rows = array_slice(array_pad($rows, $wanted, null), 0, $wanted); } return $rows; } /** * Parse the CSV batch body into one associative row per record. * * `fgetcsv` over an in-memory stream is PHP's own RFC 4180 parser: it * handles the quoted fields the API emits for values containing a comma, * the `""` escape inside them, and a quoted field spanning a newline — * which splitting on `\n` and calling `str_getcsv` per line would not. * * The empty `$escape` argument is required for RFC 4180: PHP's historical * default treats a backslash as an escape character, which would swallow * the closing quote of a field ending in one. * * @return list> */ private function parseCsv(string $body): array { $text = $body; if (str_starts_with($text, "\xEF\xBB\xBF")) { $text = substr($text, 3); } $stream = fopen('php://temp', 'r+'); if ($stream === false) { throw new InvalidResponseException('could not open a stream to parse the CSV'); } try { fwrite($stream, $text); rewind($stream); $header = fgetcsv($stream, null, ',', '"', ''); if (!is_array($header) || $header === [null]) { throw new InvalidResponseException( 'the batch response has no CSV header row: ' . var_export(substr($body, 0, 120), true), ); } $header = array_map(static fn ($name): string => trim((string) $name), $header); $width = count($header); $rows = []; while (($record = fgetcsv($stream, null, ',', '"', '')) !== false) { if (!is_array($record) || $record === [null]) { continue; } $record = array_slice(array_pad($record, $width, ''), 0, $width); $rows[] = array_combine( $header, array_map(static fn ($value): string => (string) ($value ?? ''), $record), ); } return $rows; } finally { fclose($stream); } } /** * Decode a JSON object body, or throw {@see InvalidResponseException}. * * @return array */ private function decodeJsonObject(string $body): array { try { $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $exception) { throw new InvalidResponseException( 'response is not valid JSON — something between you and the API is ' . 'answering instead of it: ' . var_export(substr($body, 0, 120), true), 0, $exception, ); } if (!is_array($data) || (array_is_list($data) && $data !== [])) { throw new InvalidResponseException( 'expected a JSON object, got ' . get_debug_type($data), ); } return $data; } /** * Pull `mensagem` out of an error body, falling back to a default. */ private static function errorMessage(string $body, string $fallback): string { $data = json_decode($body, true); if (is_array($data) && isset($data['mensagem']) && is_scalar($data['mensagem'])) { $message = (string) $data['mensagem']; if ($message !== '') { return $message; } } return $fallback; } /** * Pull the optional `faixa` block out of a 404 body, or `null`. * * Nothing in here throws, by design. The body of a not-found is extra * information on top of an answer the caller already has, so a truncated * response, an HTML error page from a proxy or a `faixa` in a shape this * release does not know must all end the same way: no `faixa`, and the same * {@see CepNotFoundException} that would have been thrown anyway. Note the * plain `json_decode` — {@see Client::decodeJsonObject()} is deliberately * not used here, because its whole job is to throw. */ private static function errorFaixa(string $body): ?Faixa { $data = json_decode($body, true); return is_array($data) ? Faixa::fromArray($data['faixa'] ?? null) : null; } /** * Parse the `Retry-After` header in both RFC 9110 forms. * * 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. */ private static function retryAfterSeconds(?string $value): ?float { if ($value === null) { return null; } $value = trim($value); if ($value === '') { return null; } if (is_numeric($value)) { return max(0.0, (float) $value); } $target = strtotime($value); if ($target === false) { return null; } return max(0.0, (float) $target - microtime(true)); } /** * 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 `backoffMax` cap. Otherwise the wait is drawn from `[0, ceiling]` * rather than being the ceiling — see `$backoffBase` on why that matters. */ private function backoff(int $attempt, ?float $retryAfter): float { if ($retryAfter !== null) { return min($retryAfter, $this->backoffMax); } $ceiling = min($this->backoffBase * (2 ** ($attempt - 1)), $this->backoffMax); return (mt_rand() / mt_getrandmax()) * $ceiling; } /** * Perform the GET, with retries. Returns `[status, body]`. * * *Definitive* error statuses (400, 404) are returned normally, for the * caller to turn into the right exception — only transient failures become * exceptions here, and only once the attempts are exhausted. * * @param list $acceptedStatus * * @return array{0: int, 1: string} */ private function request(string $path, array $acceptedStatus = [200]): array { $url = $this->baseUrl . $path; $headers = [ 'User-Agent' => $this->userAgent, 'Accept' => 'application/json, text/csv;q=0.9, */*;q=0.1', 'Accept-Encoding' => 'identity', ]; $deadline = $this->totalTimeout === null ? null : self::monotonic() + $this->totalTimeout; $lastStatus = null; $lastRetryAfter = null; $lastNetworkError = null; for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) { if ($deadline !== null && self::monotonic() >= $deadline) { break; } try { $response = $this->transport->get($url, $headers, $this->timeout); if ( in_array($response->status, $acceptedStatus, true) || !in_array($response->status, self::RETRIABLE_STATUS, true) ) { return [$response->status, $response->body]; } $lastStatus = $response->status; $lastRetryAfter = self::retryAfterSeconds($response->header('Retry-After')); $lastNetworkError = null; } catch (NetworkException $exception) { $lastNetworkError = $exception; $lastStatus = null; $lastRetryAfter = null; } if ($attempt === $this->maxAttempts) { break; } $wait = $this->backoff($attempt, $lastRetryAfter); if ($deadline !== null) { $remaining = $deadline - self::monotonic(); if ($remaining <= 0) { break; } $wait = min($wait, $remaining); } if ($wait > 0) { usleep((int) round($wait * 1_000_000)); } } $this->fail($url, $lastStatus, $lastRetryAfter, $lastNetworkError); } /** * Turn the last failure into the exception that says what to do about it. */ private function fail( string $url, ?int $status, ?float $retryAfter, ?NetworkException $networkError, ): never { $attempts = sprintf('(after %d attempt(s))', $this->maxAttempts); if ($status === 429) { throw new RateLimitedException( sprintf('rate limit reached at %s %s', $url, $attempts), $retryAfter, ); } if ($status !== null) { throw new ServerErrorException( sprintf('the API answered %d at %s %s', $status, $url, $attempts), $status, ); } throw new NetworkException( sprintf( 'could not reach %s %s: %s', $url, $attempts, $networkError?->getMessage() ?? 'the call ran out of time before any attempt', ), 0, $networkError, ); } private static function monotonic(): float { return hrtime(true) / 1_000_000_000; } }