From d10c3cbb3b0662f78471faf34ceba5d5a8d24df2 Mon Sep 17 00:00:00 2001 From: gonzalo-zenrows Date: Tue, 3 Nov 2026 15:53:29 +0100 Subject: [PATCH] feat(client): opt-in retries that follow the shared retry policy, off by default With retries above 0, fetch(), get() and extract() retry a 429, 502, 503, 504, a 422 other than no_data_extracted, and a connect failure before the request was sent. post(), a network error after sending, a response timeout, an abort and target-site statuses are never retried. Waits follow Retry-After or full jitter (new retryMaxWait), within a 90 s budget (new retryBudget). Every retries value 2.6.0 accepted still builds a client with the same count; NaN and Infinity now turn retries off with a warning. --- README.md | 30 +++- src/index.ts | 60 +++---- src/retry.ts | 123 +++++++++++++ tests/retries.test.ts | 338 ++++++++++++++++++++++++++++-------- tests/retry-count.test.ts | 59 +++++-- tests/retry-network.test.ts | 77 ++++++++ 6 files changed, 562 insertions(+), 125 deletions(-) create mode 100644 src/retry.ts create mode 100644 tests/retry-network.test.ts diff --git a/README.md b/README.md index 00bedfa..5df4a40 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ SDK to access [Zenrows](https://www.zenrows.com/) API directly from Node.js. Zen - [Installation](#installation) - [Usage](#usage) + - [Retries](#retries) - [Adaptive Stealth Mode](#adaptive-stealth-mode) - [POST Requests](#post-requests) - [Extract](#extract) @@ -49,7 +50,7 @@ Start using the API by [creating your API Key](https://app.zenrows.com/register? The SDK uses the official [fetch api](https://nodejs.org/dist/latest-v18.x/docs/api/globals.html) for HTTP requests. The client's response will be a [`Response`](https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#response). -It also uses [fetch-retry](https://github.com/jonbern/fetch-retry) to automatically retry failed requests (network errors and status codes 422, 503 and 504, except Extract's `422 no_data_extracted`). Retries are not active by default; you need to specify the number of retries, as shown below. It already includes an exponential back-off retry delay between failed requests. +Retries are off by default: the client sends each request once. Set `retries` to turn them on, as shown below. See [Retries](#retries). > `client.fetch()` is the primary method for the main page-scraping product. `client.get()` still works and is kept as a deprecated alias — new code should call `fetch()`. @@ -131,6 +132,31 @@ You can also pass optional parameters and headers; the list above is a reference Sending headers to the target URL will overwrite our defaults. Be careful when doing it and contact us if there is any problem. +### Retries + +Retries are off by default: every request is sent once. Set `retries` to retry `fetch()`, `get()` and `extract()` up to that many times: + +```javascript +const client = new ZenRows(apiKey, { retries: 3 }); +``` + +The client retries: + +1. A `429`, `502`, `503` or `504`. +2. A `422`, except one with `code: "no_data_extracted"` (see [Extract](#extract)). +3. A connection that failed before the request was sent: refused, a name that does not resolve, an unreachable network or host, or a connect timeout. + +The client never retries: + +1. `post()`, whatever its headers. If you need it, write that retry yourself. +2. Any other status, `500` included. +3. A network error after the request was sent, a response timeout or an abort: the API may still run and charge that request. +4. A status from the target site: any status when you set `original_status`, and a status you list in `allowed_status_codes`. + +Between tries the client waits the `Retry-After` the API sends, in whole seconds, from 0.25 to 30 seconds. Without one, it waits a random time from 0.25 seconds up to 1, 2, 4, ... seconds for the first, second, third retry, and at most `retryMaxWait` (default 30). No retry starts once the time since the first try plus the next wait would pass `retryBudget` (default 90 seconds). An invalid `retryMaxWait` or `retryBudget` throws a `RangeError`. `client.batch`, `client.crawl`, `prepareDomain()` and `preparedDomainStatus()` keep their own rules. + +If you already set `retries`: `429` and `502` are now retried, and a `422` and a connect timeout still are. `post()`, a network error after sending, a response timeout, an abort and target-site statuses are no longer retried. Waits follow `Retry-After` or the random wait above instead of a fixed 1, 2, 4 seconds, within the 90-second budget. `NaN`, `Infinity` or another value that is not a number now turns retries off with a warning; before, it retried without limit. + ### Adaptive Stealth Mode Set `mode: "auto"` to let Zenrows pick the request configuration for you — it starts with the cheapest viable setup and escalates to `js_render`/`premium_proxy` only when the target needs it, billing only for the configuration that succeeds. @@ -152,7 +178,7 @@ const apiKey = "YOUR-API-KEY"; const url = "https://httpbin.org/anything"; (async () => { - const client = new ZenRows(apiKey, { retries: 1 }); + const client = new ZenRows(apiKey); const response = await client.post( url, diff --git a/src/index.ts b/src/index.ts index 64c8498..32f21d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { checkDomain, preparedDomainsRequest, } from "./extract/prepared-domains.js"; +import { type RetryOptions, retryOptions, retryPolicy } from "./retry.js"; export * from "./batch/client.js"; export * from "./batch/errors.js"; @@ -24,6 +25,7 @@ export { type PreparedDomainStatus, ZenRowsPreparedDomainError, } from "./extract/prepared-domains.js"; +export { isNoDataExtracted } from "./retry.js"; const API_URL = "https://api.zenrows.com/v1/"; @@ -61,23 +63,21 @@ function warnFallbackDeprecated(): void { ); } -/** True when a response is Extract's 422 `no_data_extracted`: Extract knows this kind - * of page but found no data on it. The request is not charged and the client never - * retries it, since another try returns the same answer. Reads via `.clone()`, so the - * original response body is left unconsumed for the caller. */ -export async function isNoDataExtracted(response: Response): Promise { - if (response.status !== 422) return false; - try { - const body = (await response.clone().json()) as { code?: unknown }; - return typeof body?.code === "string" && body.code.toLowerCase() === "no_data_extracted"; - } catch { - return false; - } -} - interface ClientConfig { concurrency?: number; + /** + * Retries for `fetch()`, `get()` and `extract()`. Default 0: every request is sent once. + * Retried: a 429, 502, 503 or 504, a 422 other than `no_data_extracted`, and a failure + * before the request reached the API. `post()` is never retried. A status from the target + * site (`original_status`, `allowed_status_codes`) is never retried. A network error after + * the request was sent, a response timeout or an abort is never retried (a connect timeout is). + */ retries?: number; + /** The longest random wait between tries, in seconds. Default 30. It never caps a `Retry-After`. */ + retryMaxWait?: number; + /** No retry starts once the time since the first try plus the next wait would pass this many + * seconds. Default 90. */ + retryBudget?: number; /** Config for `client.crawl`: its own `baseURL`, `retries` and `timeout`. */ crawl?: CrawlClientConfig; } @@ -117,37 +117,19 @@ export class ZenRows { readonly batch: ZenRowsBatchClient; /** Client for the Crawl API (new; one start URL in, the URLs behind it out). See `./crawl/client.ts`. */ readonly crawl: ZenRowsCrawlClient; + private readonly retry: RetryOptions; constructor(apiKey: string, clientConfig: ClientConfig = {}) { this.apiKey = apiKey; this.clientConfig = clientConfig; this.batch = new ZenRowsBatchClient(apiKey); this.crawl = new ZenRowsCrawlClient(apiKey, clientConfig.crawl); - const retries = this.clientConfig.retries ?? 0; + this.retry = retryOptions(clientConfig); this.queue = fastq.promise(this, this.worker, this.clientConfig.concurrency ?? 5); - this.fetchWithRetry = fetchRetry(fetch, { - retryDelay: (attempt) => 2 ** attempt * 1000, - // retryOn: [422, 503, 504], - retryOn: async (attempt, error, response) => { - if (attempt >= retries) { - return false; - } - - if (error !== null || response?.status === 503 || response?.status === 504) { - return true; - } - - // A 422 is usually a failure to get the target's content and worth another - // try, except Extract's `no_data_extracted`, which is a final answer. - if (response?.status === 422) { - return !(await isNoDataExtracted(response)); - } - - return false; - }, - }); + // Never retries by itself: worker() passes the retry policy to each request it may retry. + this.fetchWithRetry = fetchRetry(fetch, { retryOn: () => false }); } /** @@ -357,7 +339,11 @@ export class ZenRows { } } - const response = await this.fetchWithRetry(`${API_URL}?${params.toString()}`, fetchOptions); + const retry = method === "GET" && this.retry.retries > 0 ? retryPolicy(this.retry, config) : {}; + const response = await this.fetchWithRetry(`${API_URL}?${params.toString()}`, { + ...fetchOptions, + ...retry, + }); return response; } diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..945c9ea --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,123 @@ +// Retries for fetch(), get() and extract(), by the shared retry policy. The client turns them on +// only for a GET and only when `retries` is above 0; post() is never retried. + +const RETRY_STATUSES = new Set([429, 502, 503, 504]); + +/** `error.cause.code` values that mean the request never reached the server. */ +const CONNECT_ERRORS = new Set([ + "ECONNREFUSED", + "ENOTFOUND", + "EAI_AGAIN", + "ENETUNREACH", + "EHOSTUNREACH", + "UND_ERR_CONNECT_TIMEOUT", +]); + +export interface RetryOptions { + retries: number; + /** Seconds. */ + retryMaxWait: number; + /** Seconds. */ + retryBudget: number; +} + +/** True when a response is Extract's 422 `no_data_extracted`: Extract knows this kind + * of page but found no data on it. The request is not charged and the client never + * retries it, since another try returns the same answer. Reads via `.clone()`, so the + * original response body is left unconsumed for the caller. */ +export async function isNoDataExtracted(response: Response): Promise { + if (response.status !== 422) return false; + try { + const body = (await response.clone().json()) as { code?: unknown }; + return typeof body?.code === "string" && body.code.toLowerCase() === "no_data_extracted"; + } catch { + return false; + } +} + +/** Reads the client options. `retries` counts as earlier versions compared it: a numeric string + * is its number, a fraction rounds up, 0 or less is off. A value that is not a number, or + * Infinity, was retried without limit there; it now turns retries off, with a warning. */ +export function retryOptions(config: { + retries?: unknown; + retryMaxWait?: unknown; + retryBudget?: unknown; +}): RetryOptions { + let retries = Number(config.retries ?? 0); + if (Number.isNaN(retries) || retries === Number.POSITIVE_INFINITY) { + console.warn( + `zenrows: retries ${String(config.retries)} is not a valid retry count, retries are off`, + ); + retries = 0; + } + return { + retries: Math.max(0, Math.ceil(retries)), + retryMaxWait: seconds("retryMaxWait", config.retryMaxWait, 30), + retryBudget: seconds("retryBudget", config.retryBudget, 90), + }; +} + +function seconds(name: string, value: unknown, fallback: number): number { + if (value === undefined) return fallback; + if (Number.isFinite(value) && (value as number) > 0) return value as number; + throw new RangeError(`${name} must be a finite number of seconds above 0, got ${String(value)}`); +} + +function isConnectFailure(error: unknown): boolean { + const { code, syscall } = (error ?? {}) as { code?: unknown; syscall?: unknown }; + return CONNECT_ERRORS.has(code as string) || (code === "ETIMEDOUT" && syscall === "connect"); +} + +/** True when fetch failed before the request was sent. When Node tries several addresses, the + * cause is an AggregateError and every inner error must be a connect failure. */ +function isPreSendFailure(error: Error): boolean { + const cause = (error as { cause?: { errors?: unknown } }).cause; + const inner = cause?.errors; + if (Array.isArray(inner)) return inner.length > 0 && inner.every(isConnectFailure); + return isConnectFailure(cause); +} + +/** True for 429, 502, 503, 504, or a 422 other than `no_data_extracted`, unless the status is + * the target site's own answer (`original_status`, `allowed_status_codes`). */ +async function isRetryableAnswer(response: Response, params: Record) { + const original = String(params.original_status ?? "") + .trim() + .toLowerCase(); + const allowed = String(params.allowed_status_codes ?? "").split(","); + if (original === "true" || original === "1") return false; + if (allowed.some((code) => code.trim() === String(response.status))) return false; + if (RETRY_STATUSES.has(response.status)) return true; + return response.status === 422 && !(await isNoDataExtracted(response)); +} + +/** `Retry-After` in whole seconds, clamped to 0.25-30 s; otherwise full jitter. */ +export function retryWaitMs(attempt: number, response: Response | null, maxWaitMs: number): number { + const retryAfter = response?.headers.get("Retry-After")?.trim(); + if (retryAfter && /^\d+$/.test(retryAfter)) { + return Math.min(Math.max(Number(retryAfter) * 1000, 250), 30_000); + } + const ceiling = Math.max(250, Math.min(maxWaitMs, 1000 * 2 ** attempt)); + return 250 + Math.random() * (ceiling - 250); +} + +/** fetch-retry's `retryOn` and `retryDelay` for one request. `retryOn` picks the wait, so it + * can refuse a retry that would pass the budget. */ +export function retryPolicy(options: RetryOptions, params: Record = {}) { + const startedAt = Date.now(); + let waitMs = 0; + return { + async retryOn(attempt: number, error: Error | null, response: Response | null) { + if (attempt >= options.retries) return false; + const retryable = error + ? isPreSendFailure(error) + : await isRetryableAnswer(response as Response, params); + if (!retryable) return false; + waitMs = retryWaitMs(attempt, response, options.retryMaxWait * 1000); + if (Date.now() - startedAt + waitMs > options.retryBudget * 1000) return false; + // The dropped answer would hold its connection until garbage collection. + response?.body?.cancel().catch(() => {}); + return true; + }, + retryDelay: () => waitMs, + }; +} diff --git a/tests/retries.test.ts b/tests/retries.test.ts index 0f3782d..ff39c3c 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -1,82 +1,278 @@ import { http, HttpResponse } from "msw"; -import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; //TODO(Nestor): Try to use globals instead of importing -import { ZenRows } from "../src"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ZenRows, ZenRowsPreparedDomainError } from "../src"; +import { retryPolicy, retryWaitMs } from "../src/retry"; import { server } from "./_setup"; -describe("ZenRows Client with Retries", () => { - const apiKey = "API_KEY"; - const url = "https://example.com"; - const apiUrl = "https://api.zenrows.com/v1/"; - let client: ZenRows; - - beforeEach(() => { - client = new ZenRows(apiKey, { retries: 2 }); - }); - - test( - "should call fetch 3 times (initial plus 2 retries)", - { timeout: Number.POSITIVE_INFINITY }, - async () => { - const clientSpy = vi.spyOn(client, "fetchWithRetry"); - server.use( - http.get("https://api.zenrows.com/v1/", () => { - return HttpResponse.error(); - }), - ); - - try { - await client.get(url); - } catch (error) { - return; - } - - expect(clientSpy).toHaveBeenCalledTimes(3); - }, +const apiUrl = "https://api.zenrows.com/v1/"; +const url = "https://example.com"; + +const status = (code: number, headers: Record = {}) => + new Response(null, { status: code, headers }); +/** The error Node's fetch throws: `TypeError("fetch failed")`, caused by a system error with + * the `code` and `syscall` a socket sets. */ +const failed = (code: string, syscall?: string) => + new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code, syscall }) }); +const refused = failed("ECONNREFUSED", "connect"); +/** Node's happy-eyeballs error: one inner error per address, and the first one's `code` on top. */ +const everyAddress = (...errors: TypeError[]) => + new TypeError("fetch failed", { + cause: Object.assign(new AggregateError(errors.map((each) => each.cause)), { + code: "ECONNREFUSED", + }), + }); + +/** The first retry decision for one answer or error. */ +const retried = ( + error: Error | null, + response: Response | null, + params: Record = {}, +) => + retryPolicy({ retries: 3, retryMaxWait: 30, retryBudget: 90 }, params).retryOn( + 0, + error, + response, ); - test( - "should throw error with status code 500", - { timeout: Number.POSITIVE_INFINITY }, - async () => { - const clientSpy = vi.spyOn(client, "get"); - server.use( - http.get("https://api.zenrows.com/v1/", () => { - return new HttpResponse(null, { status: 503 }); - }), - ); - - try { - const response = await client.get(url); - expect(clientSpy).toHaveBeenCalledTimes(3); - expect(response.status).toBe(503); - } catch (error) { - return; - } +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "Date"] }); + vi.spyOn(Math, "random").mockReturnValue(0); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("conformance vectors", () => { + test.each([ + { retryAfter: "5", maxWaitMs: 30_000, waitMs: 5000 }, + { retryAfter: " 7 ", maxWaitMs: 30_000, waitMs: 7000 }, + { retryAfter: "0", maxWaitMs: 30_000, waitMs: 250 }, + { retryAfter: "99999", maxWaitMs: 30_000, waitMs: 30_000 }, + { retryAfter: "20", maxWaitMs: 5000, waitMs: 20_000 }, + // Anything but whole seconds is ignored, and the wait is jitter (250 ms with random() = 0). + ...["1.5", "-1", "1e3", "", "abc", "Wed, 21 Oct 2026 07:28:00 GMT"].map((retryAfter) => ({ + retryAfter, + maxWaitMs: 30_000, + waitMs: 250, + })), + ])("Retry-After $retryAfter with retryMaxWait $maxWaitMs ms waits $waitMs ms", (vector) => { + const response = status(503, { "Retry-After": vector.retryAfter }); + expect(retryWaitMs(0, response, vector.maxWaitMs)).toBe(vector.waitMs); + }); + + test.each([ + { attempt: 0, maxWaitMs: 30_000, highMs: 1000 }, + { attempt: 1, maxWaitMs: 30_000, highMs: 2000 }, + { attempt: 2, maxWaitMs: 30_000, highMs: 4000 }, + { attempt: 5, maxWaitMs: 30_000, highMs: 30_000 }, + { attempt: 4, maxWaitMs: 5000, highMs: 5000 }, + { attempt: 3, maxWaitMs: 100, highMs: 250 }, + ])("jitter for attempt $attempt, retryMaxWait $maxWaitMs ms: 250 to $highMs ms", (vector) => { + expect(retryWaitMs(vector.attempt, null, vector.maxWaitMs)).toBe(250); + vi.spyOn(Math, "random").mockReturnValue(1 - Number.EPSILON); + expect(retryWaitMs(vector.attempt, null, vector.maxWaitMs)).toBeCloseTo(vector.highMs, 6); + }); + + test.each<{ name: string; code: number; body?: object; retry: boolean }>([ + ...[429, 502, 503, 504].map((code) => ({ name: String(code), code, retry: true })), + { name: "422 RESP001", code: 422, body: { code: "RESP001" }, retry: true }, + { name: "422 with no body", code: 422, retry: true }, + { name: "422 no_data_extracted", code: 422, body: { code: "no_data_extracted" }, retry: false }, + ...[200, 400, 401, 402, 403, 404, 409, 500].map((code) => ({ + name: String(code), + code, + retry: false, + })), + ])("a $name answer: retried $retry", async ({ code, body, retry }) => { + const response = body ? Response.json(body, { status: code }) : status(code); + expect(await retried(null, response)).toBe(retry); + }); + + test.each([ + ...["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH"].map( + (code) => [code, failed(code, "connect"), true] as const, + ), + ["a connect timeout", failed("UND_ERR_CONNECT_TIMEOUT"), true], + ["ETIMEDOUT from connect", failed("ETIMEDOUT", "connect"), true], + [ + "every address failed to connect", + everyAddress(refused, failed("ETIMEDOUT", "connect")), + true, + ], + ["ETIMEDOUT from read", failed("ETIMEDOUT", "read"), false], + ["ETIMEDOUT with no syscall", failed("ETIMEDOUT"), false], + ["one address with a read timeout", everyAddress(refused, failed("ETIMEDOUT", "read")), false], + ["an AggregateError with no inner error", everyAddress(), false], + ["a socket closed after sending", failed("UND_ERR_SOCKET"), false], + ["a reset", failed("ECONNRESET", "read"), false], + ["a headers timeout", failed("UND_ERR_HEADERS_TIMEOUT"), false], + ["a body timeout", failed("UND_ERR_BODY_TIMEOUT"), false], + ["an abort", new DOMException("aborted", "AbortError"), false], + ["a timeout signal", new DOMException("timed out", "TimeoutError"), false], + ["an invalid header value", new TypeError("invalid header value"), false], + ] as const)("%s: retried %s", async (_name, error, retry) => { + expect(await retried(error, null)).toBe(retry); + }); +}); + +describe("a status from the target site is never retried", () => { + test.each([true, "true", " TRUE ", 1, "1"])("original_status %j", async (on) => { + for (const code of [429, 502, 503, 504, 422]) { + expect(await retried(null, status(code), { original_status: on })).toBe(false); + } + }); + + test.each([false, "false", 0, "0", ""])( + "original_status %j keeps the usual retries", + async (off) => { + expect(await retried(null, status(503), { original_status: off })).toBe(true); }, ); - test( - "should fail the first attempt and succeed the second", - { timeout: Number.POSITIVE_INFINITY }, - async () => { - server.use( - http.get( - "https://api.zenrows.com/v1/", - () => { - return new HttpResponse(null, { status: 503 }); - }, - { - once: true, - }, - ), - ); - - try { - const response = await client.get(url); - expect(response).toBe(200); - } catch (error) { - return; - } + test.each(["503", "404, 503", ["404", "503"], [404, 503], 503])( + "allowed_status_codes %j: a 503 is final, a 502 is retried", + async (listed) => { + expect(await retried(null, status(503), { allowed_status_codes: listed })).toBe(false); + expect(await retried(null, status(502), { allowed_status_codes: listed })).toBe(true); }, ); + + test("a refused connection is still retried", async () => { + expect(await retried(refused, null, { original_status: true })).toBe(true); + expect(await retried(refused, null, { allowed_status_codes: "503" })).toBe(true); + }); +}); + +describe("retry policy", () => { + test("starts no retry once the time since the first try plus the next wait passes the budget", async () => { + const policy = retryPolicy({ retries: 5, retryMaxWait: 30, retryBudget: 10 }); + vi.advanceTimersByTime(3000); + expect(await policy.retryOn(0, null, status(503, { "Retry-After": "7" }))).toBe(true); + expect(policy.retryDelay()).toBe(7000); + vi.advanceTimersByTime(1); + expect(await policy.retryOn(1, null, status(503, { "Retry-After": "7" }))).toBe(false); + }); + + test("cancels the body of the answer it drops, and leaves the final one unread", async () => { + const cancelled: string[] = []; + const answer = (name: string) => + new Response(new ReadableStream({ cancel: () => void cancelled.push(name) }), { + status: 503, + }); + const policy = retryPolicy({ retries: 1, retryMaxWait: 30, retryBudget: 90 }); + + await policy.retryOn(0, null, answer("retried")); + await policy.retryOn(1, null, answer("final")); + await Promise.resolve(); + + expect(cancelled).toEqual(["retried"]); + }); +}); + +describe("the client", () => { + /** Answers the API calls with `answers` in order, repeating the last one, and counts them. */ + function answerWith(...answers: (() => Response)[]) { + const calls = { count: 0 }; + const handle = () => (answers[Math.min(calls.count++, answers.length - 1)] as () => Response)(); + server.use(http.get(apiUrl, handle), http.post(apiUrl, handle)); + return calls; + } + + async function settle(pending: Promise): Promise { + await vi.advanceTimersByTimeAsync(120_000); + return pending; + } + + test("sends a request once by default, and returns the 503", async () => { + const calls = answerWith(() => status(503)); + expect((await settle(new ZenRows("API_KEY").fetch(url))).status).toBe(503); + expect(calls.count).toBe(1); + }); + + test("retries a 503 after the jitter wait, then returns the success", async () => { + const calls = answerWith( + () => status(503), + () => status(200), + ); + + const pending = new ZenRows("API_KEY", { retries: 2 }).fetch(url); + await vi.advanceTimersByTimeAsync(249); + expect(calls.count).toBe(1); + + expect((await settle(pending)).status).toBe(200); + expect(calls.count).toBe(2); + }); + + test("waits the Retry-After the API sends", async () => { + const calls = answerWith( + () => status(429, { "Retry-After": "5" }), + () => status(200), + ); + + const pending = new ZenRows("API_KEY", { retries: 2 }).fetch(url); + await vi.advanceTimersByTimeAsync(4999); + expect(calls.count).toBe(1); + + expect((await settle(pending)).status).toBe(200); + expect(calls.count).toBe(2); + }); + + test("returns the last answer once the next wait would pass retryBudget", async () => { + const calls = answerWith(() => status(503, { "Retry-After": "20" })); + // 20 s, then 40 s: a third wait would end at 60 s, past 45 s. + const client = new ZenRows("API_KEY", { retries: 5, retryBudget: 45 }); + expect((await settle(client.fetch(url))).status).toBe(503); + expect(calls.count).toBe(3); + }); + + test.each([ + { name: "post()", call: (c: ZenRows) => c.post(url, {}, { data: { a: 1 } }) }, + { + name: "post() with an Idempotency-Key", + call: (c: ZenRows) => + c.post(url, {}, { headers: { "Idempotency-Key": "k" }, data: { a: 1 } }), + }, + { + name: "fetch() with original_status", + call: (c: ZenRows) => c.fetch(url, { original_status: true }), + }, + { + name: "extract() with original_status", + call: (c: ZenRows) => c.extract(url, { original_status: true }), + }, + { + name: "fetch() with allowed_status_codes 503", + call: (c: ZenRows) => c.fetch(url, { allowed_status_codes: "503" }), + }, + ])("sends $name once on a 503, with retries: 3", async ({ call }) => { + const calls = answerWith(() => status(503)); + expect((await settle(call(new ZenRows("API_KEY", { retries: 3 })))).status).toBe(503); + expect(calls.count).toBe(1); + }); + + test.each([ + ["prepareDomain", (c: ZenRows) => c.prepareDomain("www.example.com", [url])], + ["preparedDomainStatus", (c: ZenRows) => c.preparedDomainStatus("www.example.com")], + ] as const)("%s() rejects on the first 503, with retries: 5", async (_name, call) => { + let calls = 0; + const answer = () => { + calls += 1; + return HttpResponse.json({ code: "overloaded" }, { status: 503 }); + }; + const preparedDomains = `${apiUrl}extract/prepared-domains`; + server.use(http.post(preparedDomains, answer), http.get(`${preparedDomains}/:domain`, answer)); + + const error = await settle(call(new ZenRows("API_KEY", { retries: 5 })).catch((e) => e)); + expect(error).toBeInstanceOf(ZenRowsPreparedDomainError); + expect(calls).toBe(1); + }); + + test.each(["retryMaxWait", "retryBudget"] as const)("rejects an invalid %s", (name) => { + for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, "5", null]) { + expect(() => new ZenRows("API_KEY", { [name]: value as number })).toThrow(RangeError); + } + expect(() => new ZenRows("API_KEY", { [name]: 0.5 })).not.toThrow(); + }); }); diff --git a/tests/retry-count.test.ts b/tests/retry-count.test.ts index 61fc964..7681c71 100644 --- a/tests/retry-count.test.ts +++ b/tests/retry-count.test.ts @@ -1,30 +1,59 @@ import { http, HttpResponse } from "msw"; -import { describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ZenRows } from "../src"; import { server } from "./_setup"; -describe("retryOn off-by-one fix", () => { +describe("retry count", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "Date"] }); + vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // Every `retries` value 2.6.0 accepted still builds a client and keeps its retry count, + // except NaN, Infinity and other non-numbers: 2.6.0 retried them without limit. test.each([ - { retries: 0, expectedCalls: 1 }, - { retries: 1, expectedCalls: 2 }, - { retries: 2, expectedCalls: 3 }, + { retries: undefined, requests: 1 }, + { retries: null, requests: 1 }, + { retries: "", requests: 1 }, + { retries: false, requests: 1 }, + { retries: -1, requests: 1 }, + { retries: 0, requests: 1 }, + { retries: 1, requests: 2 }, + { retries: 2, requests: 3 }, + { retries: true, requests: 2 }, + { retries: "2", requests: 3 }, + { retries: " 3 ", requests: 4 }, + { retries: 0.5, requests: 2 }, + { retries: 1.5, requests: 3 }, + { retries: Number.NaN, requests: 1, warns: true }, + { retries: Number.POSITIVE_INFINITY, requests: 1, warns: true }, + { retries: "abc", requests: 1, warns: true }, ])( - "retries: $retries should make exactly $expectedCalls fetch call(s) on 422", - async ({ retries, expectedCalls }) => { - let fetchCount = 0; - + "retries $retries sends $requests request(s) on a 503", + async ({ retries, requests, warns }) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let calls = 0; server.use( http.get("https://api.zenrows.com/v1/", () => { - fetchCount++; - return new HttpResponse("Could Not Get Content", { status: 422 }); + calls += 1; + return new HttpResponse(null, { status: 503 }); }), ); - const client = new ZenRows("API_KEY", { retries }); - const response = await client.get("https://example.com"); + const client = new ZenRows("API_KEY", { retries: retries as number }); + const pending = client.fetch("https://example.com"); + await vi.advanceTimersByTimeAsync(10_000); - expect(response.status).toBe(422); - expect(fetchCount).toBe(expectedCalls); + expect((await pending).status).toBe(503); + expect(calls).toBe(requests); + expect(warn).toHaveBeenCalledTimes(warns ? 1 : 0); + if (warns) + expect(warn.mock.calls[0]?.[0]).toMatch(/not a valid retry count, retries are off/); }, ); }); diff --git a/tests/retry-network.test.ts b/tests/retry-network.test.ts new file mode 100644 index 0000000..f32fa15 --- /dev/null +++ b/tests/retry-network.test.ts @@ -0,0 +1,77 @@ +// Retry decisions through Node's real fetch against a local server, so each failure reaches the +// policy in the shape Node produces. The request mocks are off in this file. +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { ZenRows } from "../src"; +import { server as mockServer } from "./_setup"; + +const realFetch = globalThis.fetch; + +beforeAll(() => mockServer.close()); +afterAll(() => mockServer.listen()); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +/** Starts a local server; `port` 0 picks a free port. */ +async function listen(handle?: http.RequestListener) { + const local = http.createServer(handle); + await new Promise((resolve) => local.listen(0, "127.0.0.1", resolve)); + return { local, port: (local.address() as AddressInfo).port }; +} + +/** Sends the client's API calls to `127.0.0.1:port` and counts them. Call before building the client. */ +function routeApiTo(port: number) { + const calls = { count: 0 }; + vi.stubGlobal("fetch", (input: string, init?: RequestInit) => { + calls.count += 1; + return realFetch(input.replace("https://api.zenrows.com", `http://127.0.0.1:${port}`), init); + }); + return calls; +} + +async function refusedPort(): Promise { + const { local, port } = await listen(); + await new Promise((resolve) => local.close(resolve)); + return port; +} + +describe("real network failures", () => { + test.each([ + { retries: undefined, requests: 1 }, + { retries: 2, requests: 3 }, + ])( + "a refused connection with retries $retries: $requests request(s), then the error", + async ({ retries, requests }) => { + vi.spyOn(Math, "random").mockReturnValue(0); + const calls = routeApiTo(await refusedPort()); + + const error = await new ZenRows("API_KEY", { retries }) + .fetch("https://example.com") + .catch((e) => e); + + expect(error).toBeInstanceOf(TypeError); + expect(error.cause.code).toBe("ECONNREFUSED"); + expect(calls.count).toBe(requests); + }, + ); + + test("a socket closed after the API read the request is not retried", async () => { + let received = 0; + const { local, port } = await listen((req) => { + received += 1; + req.socket.destroy(); + }); + routeApiTo(port); + + const error = await new ZenRows("API_KEY", { retries: 2 }) + .fetch("https://example.com") + .catch((e) => e); + local.close(); + + expect(error.cause.code).toBe("UND_ERR_SOCKET"); + expect(received).toBe(1); + }); +});