Skip to content

Commit 40e0d85

Browse files
committed
fix: adjust build error and update deps
1 parent 2d65abe commit 40e0d85

9 files changed

Lines changed: 411 additions & 237 deletions

File tree

pnpm-lock.yaml

Lines changed: 141 additions & 183 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/cities.ts

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { writeFile } from "node:fs/promises";
44
import { dirname, resolve } from "node:path";
55
import { fileURLToPath } from "node:url";
66

7+
import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts";
8+
79
const scriptsDir = dirname(fileURLToPath(import.meta.url));
810

911
type City = {
@@ -29,36 +31,43 @@ type City = {
2931
};
3032
};
3133

32-
const response = await fetch("https://servicodados.ibge.gov.br/api/v1/localidades/municipios");
34+
const main = async () => {
35+
const response = await fetchWithRetry(
36+
"https://servicodados.ibge.gov.br/api/v1/localidades/municipios",
37+
);
38+
39+
if (!response.ok) {
40+
throw new Error(`IBGE municipalities request failed with status ${response.status}`);
41+
}
3342

34-
const json = (await response.json()) as City[];
43+
const json = (await response.json()) as City[];
3544

36-
const cities = Object.fromEntries(
37-
Object.entries(
38-
json.reduce(
39-
(acc, city) => {
40-
const stateInitials = city?.microrregiao?.mesorregiao?.UF?.sigla;
45+
const cities = Object.fromEntries(
46+
Object.entries(
47+
json.reduce(
48+
(acc, city) => {
49+
const stateInitials = city?.microrregiao?.mesorregiao?.UF?.sigla;
4150

42-
if (!stateInitials) return acc;
51+
if (!stateInitials) return acc;
4352

44-
if (!acc[stateInitials]) {
45-
acc[stateInitials] = [];
46-
}
53+
if (!acc[stateInitials]) {
54+
acc[stateInitials] = [];
55+
}
4756

48-
acc[stateInitials].push(city.nome);
57+
acc[stateInitials].push(city.nome);
4958

50-
return acc;
51-
},
52-
{} as Record<string, string[]>,
53-
),
54-
)
55-
.sort(([a], [b]) => a.localeCompare(b))
56-
.map(([state, cityNames]) => [state, cityNames.sort((a, b) => a.localeCompare(b))]),
57-
);
59+
return acc;
60+
},
61+
{} as Record<string, string[]>,
62+
),
63+
)
64+
.sort(([a], [b]) => a.localeCompare(b))
65+
.map(([state, cityNames]) => [state, cityNames.sort((a, b) => a.localeCompare(b))]),
66+
);
5867

59-
await writeFile(
60-
resolve(scriptsDir, "..", "./src/_internals/constants/cities.ts"),
61-
`/**
68+
await writeFile(
69+
resolve(scriptsDir, "..", "./src/_internals/constants/cities.ts"),
70+
`/**
6271
* A collection of Brazilian cities categorized by their respective states.
6372
*
6473
* @constant
@@ -82,4 +91,10 @@ await writeFile(
8291
* @property {string[]} MA - Cities in the state of Maranhão.
8392
*/
8493
export const DATA = ${JSON.stringify(cities)} as const`,
85-
);
94+
);
95+
};
96+
97+
await main().catch((error) => {
98+
console.error(error instanceof Error ? error.message : error);
99+
process.exit(1);
100+
});

scripts/states.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { writeFile } from "node:fs/promises";
44
import { dirname, resolve } from "node:path";
55
import { fileURLToPath } from "node:url";
66

7+
import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts";
8+
79
const scriptsDir = dirname(fileURLToPath(import.meta.url));
810

911
type State = {
@@ -17,22 +19,29 @@ type State = {
1719
};
1820
};
1921

20-
const response = await fetch("https://servicodados.ibge.gov.br/api/v1/localidades/estados");
21-
22-
const json = (await response.json()) as State[];
23-
24-
const states = json
25-
.sort((cityA, cityB) => (cityA.nome > cityB.nome ? 1 : -1))
26-
.map((state) => ({
27-
code: state.sigla,
28-
name: state.nome,
29-
regionCode: state.regiao.sigla,
30-
regionName: state.regiao.nome,
31-
}));
32-
33-
await writeFile(
34-
resolve(scriptsDir, "..", "./src/_internals/constants/states.ts"),
35-
`/**
22+
const main = async () => {
23+
const response = await fetchWithRetry(
24+
"https://servicodados.ibge.gov.br/api/v1/localidades/estados",
25+
);
26+
27+
if (!response.ok) {
28+
throw new Error(`IBGE states request failed with status ${response.status}`);
29+
}
30+
31+
const json = (await response.json()) as State[];
32+
33+
const states = json
34+
.sort((cityA, cityB) => (cityA.nome > cityB.nome ? 1 : -1))
35+
.map((state) => ({
36+
code: state.sigla,
37+
name: state.nome,
38+
regionCode: state.regiao.sigla,
39+
regionName: state.regiao.nome,
40+
}));
41+
42+
await writeFile(
43+
resolve(scriptsDir, "..", "./src/_internals/constants/states.ts"),
44+
`/**
3645
* @type {Array<{code: string, name: string, regionCode: string, regionName: string}>}
3746
*/
3847
export const DATA = ${JSON.stringify(states)} as const
@@ -42,4 +51,10 @@ export type State = (typeof DATA)[number];
4251
export type StateName = (typeof DATA)[number]["name"];
4352
4453
export type StateCode = (typeof DATA)[number]["code"];`,
45-
);
54+
);
55+
};
56+
57+
await main().catch((error) => {
58+
console.error(error instanceof Error ? error.message : error);
59+
process.exit(1);
60+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "../test/runtime";
2+
import { fetchWithRetry, isRetryableFetchError } from "./fetch-with-retry";
3+
4+
describe("fetchWithRetry", () => {
5+
const originalFetch = globalThis.fetch;
6+
7+
beforeEach(() => {
8+
vi.restoreAllMocks();
9+
});
10+
11+
afterEach(() => {
12+
globalThis.fetch = originalFetch;
13+
vi.restoreAllMocks();
14+
});
15+
16+
it("retries transient undici socket failures", async () => {
17+
const fetchMock = vi
18+
.fn()
19+
.mockRejectedValueOnce(
20+
Object.assign(new TypeError("fetch failed"), {
21+
cause: { code: "UND_ERR_SOCKET" },
22+
}),
23+
)
24+
.mockResolvedValueOnce({
25+
ok: true,
26+
status: 200,
27+
});
28+
29+
globalThis.fetch = fetchMock as unknown as typeof fetch;
30+
31+
const response = await fetchWithRetry("https://example.com", { retries: 1, retryDelayMs: 0 });
32+
33+
expect(response.ok).toBe(true);
34+
expect(fetchMock).toHaveBeenCalledTimes(2);
35+
});
36+
37+
it("does not retry non-transient failures", async () => {
38+
const error = new Error("Invalid URL");
39+
const fetchMock = vi.fn().mockRejectedValue(error);
40+
41+
globalThis.fetch = fetchMock as unknown as typeof fetch;
42+
43+
await expect(
44+
fetchWithRetry("https://example.com", { retries: 3, retryDelayMs: 0 }),
45+
).rejects.toThrow(error);
46+
expect(fetchMock).toHaveBeenCalledTimes(1);
47+
});
48+
49+
it("detects retryable undici errors by code", () => {
50+
expect(
51+
isRetryableFetchError(
52+
Object.assign(new TypeError("fetch failed"), {
53+
cause: { code: "UND_ERR_SOCKET" },
54+
}),
55+
),
56+
).toBe(true);
57+
});
58+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const RETRYABLE_ERROR_CODES = new Set([
2+
"UND_ERR_SOCKET",
3+
"UND_ERR_CONNECT_TIMEOUT",
4+
"UND_ERR_HEADERS_TIMEOUT",
5+
"UND_ERR_BODY_TIMEOUT",
6+
"ECONNRESET",
7+
"ECONNREFUSED",
8+
"EHOSTUNREACH",
9+
"ENETUNREACH",
10+
"ETIMEDOUT",
11+
]);
12+
13+
export type FetchWithRetryOptions = RequestInit & {
14+
retries?: number;
15+
retryDelayMs?: number;
16+
};
17+
18+
const wait = (ms: number): Promise<void> =>
19+
ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms));
20+
21+
const getErrorCode = (error: unknown): string | undefined => {
22+
if (!error || typeof error !== "object") return undefined;
23+
24+
const code = "code" in error ? error.code : undefined;
25+
26+
if (typeof code === "string") {
27+
return code;
28+
}
29+
30+
const cause = "cause" in error ? error.cause : undefined;
31+
32+
if (!cause || typeof cause !== "object") return undefined;
33+
34+
const causeCode = "code" in cause ? cause.code : undefined;
35+
36+
return typeof causeCode === "string" ? causeCode : undefined;
37+
};
38+
39+
export const isRetryableFetchError = (error: unknown): boolean => {
40+
const code = getErrorCode(error);
41+
42+
if (code && RETRYABLE_ERROR_CODES.has(code)) {
43+
return true;
44+
}
45+
46+
if (!(error instanceof Error)) {
47+
return false;
48+
}
49+
50+
return error.message.toLowerCase().includes("fetch failed");
51+
};
52+
53+
export const fetchWithRetry = async (
54+
input: string | URL | Request,
55+
{ retries = 2, retryDelayMs = 250, ...init }: FetchWithRetryOptions = {},
56+
): Promise<Response> => {
57+
let lastError: unknown;
58+
59+
for (let attempt = 0; attempt <= retries; attempt += 1) {
60+
try {
61+
return await fetch(input, init);
62+
} catch (error) {
63+
lastError = error;
64+
65+
if (attempt === retries || !isRetryableFetchError(error)) {
66+
throw error;
67+
}
68+
69+
await wait(retryDelayMs * (attempt + 1));
70+
}
71+
}
72+
73+
throw lastError;
74+
};

src/get-address-info-by-cep/get-address-info-by-cep.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fetchWithRetry } from "../_internals/fetch-with-retry/fetch-with-retry";
12
/**
23
* based on https://github.com/BrasilAPI/cep-promise
34
*/
@@ -77,7 +78,7 @@ type BrasilApiResponse = {
7778
};
7879

7980
const fetchViaCep = async (cep: string): Promise<AddressInfo> => {
80-
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
81+
const response = await fetchWithRetry(`https://viacep.com.br/ws/${cep}/json/`);
8182

8283
if (!response.ok) {
8384
throw new Error(`ViaCEP request failed with status ${response.status}`);
@@ -99,7 +100,9 @@ const fetchViaCep = async (cep: string): Promise<AddressInfo> => {
99100
};
100101

101102
const fetchWidenet = async (cep: string): Promise<AddressInfo> => {
102-
const response = await fetch(`https://apps.widenet.com.br/busca-cep/api/cep/${cep}.json`);
103+
const response = await fetchWithRetry(
104+
`https://apps.widenet.com.br/busca-cep/api/cep/${cep}.json`,
105+
);
103106

104107
if (!response.ok) {
105108
throw new Error(`Widenet request failed with status ${response.status}`);
@@ -121,7 +124,7 @@ const fetchWidenet = async (cep: string): Promise<AddressInfo> => {
121124
};
122125

123126
const fetchBrasilApi = async (cep: string): Promise<AddressInfo> => {
124-
const response = await fetch(`https://brasilapi.com.br/api/cep/v1/${cep}`);
127+
const response = await fetchWithRetry(`https://brasilapi.com.br/api/cep/v1/${cep}`);
125128

126129
if (!response.ok) {
127130
throw new Error(`BrasilAPI request failed with status ${response.status}`);

src/get-cep-info-by-address/get-cep-info-by-address.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "../_internals/test/runtime";
2-
import { GetCepInfoByAddressValidationError, getCepInfoByAddress } from "./get-cep-info-by-address";
2+
import {
3+
GetCepInfoByAddressError,
4+
GetCepInfoByAddressValidationError,
5+
getCepInfoByAddress,
6+
} from "./get-cep-info-by-address";
37

48
describe("getCepInfoByAddress", () => {
59
const fetchMock = vi.fn();
@@ -55,4 +59,31 @@ describe("getCepInfoByAddress", () => {
5559
},
5660
]);
5761
});
62+
63+
it("should wrap transport failures with GetCepInfoByAddressError", async () => {
64+
fetchMock.mockRejectedValueOnce(
65+
Object.assign(new TypeError("fetch failed"), {
66+
cause: { code: "UND_ERR_SOCKET" },
67+
}),
68+
);
69+
fetchMock.mockRejectedValueOnce(
70+
Object.assign(new TypeError("fetch failed"), {
71+
cause: { code: "UND_ERR_SOCKET" },
72+
}),
73+
);
74+
fetchMock.mockRejectedValueOnce(
75+
Object.assign(new TypeError("fetch failed"), {
76+
cause: { code: "UND_ERR_SOCKET" },
77+
}),
78+
);
79+
80+
await expect(
81+
getCepInfoByAddress({
82+
federalUnit: "SP",
83+
city: "São Paulo",
84+
street: "Avenida Paulista",
85+
}),
86+
).rejects.toThrow(GetCepInfoByAddressError);
87+
expect(fetchMock).toHaveBeenCalledTimes(3);
88+
});
5889
});

src/get-cep-info-by-address/get-cep-info-by-address.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DATA as STATES, type StateCode } from "../_internals/constants/states";
2+
import { fetchWithRetry } from "../_internals/fetch-with-retry/fetch-with-retry";
23

34
export class GetCepInfoByAddressError extends Error {
45
constructor(message: string) {
@@ -66,9 +67,16 @@ export const getCepInfoByAddress = async ({
6667
throw new GetCepInfoByAddressValidationError("City and street are required");
6768
}
6869

69-
const response = await fetch(
70-
`https://viacep.com.br/ws/${normalizedUf}/${encodeURIComponent(normalizeAddressPart(city))}/${encodeURIComponent(normalizeAddressPart(street))}/json/`,
71-
);
70+
let response: Response;
71+
try {
72+
response = await fetchWithRetry(
73+
`https://viacep.com.br/ws/${normalizedUf}/${encodeURIComponent(normalizeAddressPart(city))}/${encodeURIComponent(normalizeAddressPart(street))}/json/`,
74+
);
75+
} catch (error) {
76+
throw new GetCepInfoByAddressError(
77+
error instanceof Error ? error.message : "ViaCEP request failed",
78+
);
79+
}
7280

7381
if (!response.ok) {
7482
throw new GetCepInfoByAddressError(`ViaCEP request failed with status ${response.status}`);

0 commit comments

Comments
 (0)