Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()`.

Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
60 changes: 23 additions & 37 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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/";

Expand Down Expand Up @@ -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<boolean> {
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;
}
Expand Down Expand Up @@ -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 });
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down
123 changes: 123 additions & 0 deletions src/retry.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string, unknown>) {
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<string, unknown> = {}) {
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,
};
}
Loading
Loading