1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
|
/**
* 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<void> {
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<Address> {
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<Health> {
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<Reply> {
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;
}
|