@zenrows/cli 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,114 @@
1
+ # Changelog
2
+
3
+ ## [1.13.1](https://github.com/ZenRows/cli/compare/v1.13.0...v1.13.1) (2026-11-06)
4
+
5
+
6
+ ### Behaviour Changes
7
+
8
+ * **fetch:** the default --timeout is 200 s instead of 120 s, so a slow request gets the API's full 3 minutes ([07dc59f](https://github.com/ZenRows/cli/commit/07dc59ffecab780e3bfd78ceeee939c14c3b9891))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **fetch:** wait past the API's 3-minute budget before giving up ([07dc59f](https://github.com/ZenRows/cli/commit/07dc59ffecab780e3bfd78ceeee939c14c3b9891))
14
+
15
+ ## [1.13.0](https://github.com/ZenRows/cli/compare/v1.12.0...v1.13.0) (2026-11-06)
16
+
17
+
18
+ ### Features
19
+
20
+ * **fetch:** add --retries to fetch and extract, off by default, following the shared retry policy ([ea4d557](https://github.com/ZenRows/cli/commit/ea4d5578fef496d0a01123259c139dd386ee55a1))
21
+
22
+ ## [1.12.0](https://github.com/ZenRows/cli/compare/v1.11.0...v1.12.0) (2026-10-30)
23
+
24
+
25
+ ### Features
26
+
27
+ * **crawl:** get notified by webhook when a crawl starts, completes, stops or fails ([#75](https://github.com/ZenRows/cli/issues/75)) ([d71df97](https://github.com/ZenRows/cli/commit/d71df972a6a9e29d619d5c8a829022c6156530ea))
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * **batch:** stop a wait before a back-off that ends on its --timeout ([#76](https://github.com/ZenRows/cli/issues/76)) ([090c75f](https://github.com/ZenRows/cli/commit/090c75f4fabbfc19f86c2c1c6c79354175677636))
33
+
34
+ ## [1.11.0](https://github.com/ZenRows/cli/compare/v1.10.0...v1.11.0) (2026-10-27)
35
+
36
+
37
+ ### Features
38
+
39
+ * **extract:** steer prepare callers to send several example URLs of one page type ([#72](https://github.com/ZenRows/cli/issues/72)) ([c8dc644](https://github.com/ZenRows/cli/commit/c8dc644d7b04d6a59ae8a72900961438e3b76b6f))
40
+
41
+ ## [1.10.0](https://github.com/ZenRows/cli/compare/v1.9.0...v1.10.0) (2026-10-23)
42
+
43
+
44
+ ### Features
45
+
46
+ * **signup:** agents could not get past the signup question, which only surfaced as a raw HTTP 428 body ([#73](https://github.com/ZenRows/cli/issues/73)) ([eac6f7a](https://github.com/ZenRows/cli/commit/eac6f7a625fb51ab081f282a428966f187493cb6))
47
+
48
+
49
+ ### Bug Fixes
50
+
51
+ * **skills:** name the local storage tools the MCP server registers since 2.15.0 ([#70](https://github.com/ZenRows/cli/issues/70)) ([eb61828](https://github.com/ZenRows/cli/commit/eb61828df42df32bf1b6c497caf22185ed6ec545))
52
+
53
+ ## [1.9.0](https://github.com/ZenRows/cli/compare/v1.8.0...v1.9.0) (2026-10-20)
54
+
55
+
56
+ ### Features
57
+
58
+ * **extract:** remove the autoparse fallback and add zenrows extract prepare ([#47](https://github.com/ZenRows/cli/issues/47)) ([88a33d6](https://github.com/ZenRows/cli/commit/88a33d67f0c6c94accde1061f9ea10e7911f047b))
59
+
60
+ ## [1.8.0](https://github.com/ZenRows/cli/compare/v1.7.0...v1.8.0) (2026-10-20)
61
+
62
+
63
+ ### Features
64
+
65
+ * **batch:** agents could not recover, inspect or manage batch jobs from the CLI ([#67](https://github.com/ZenRows/cli/issues/67)) ([a850ea4](https://github.com/ZenRows/cli/commit/a850ea437b013cf10e584e6b87366b94037ae81b))
66
+
67
+ ## [1.7.0](https://github.com/ZenRows/cli/compare/v1.6.2...v1.7.0) (2026-10-18)
68
+
69
+
70
+ ### Features
71
+
72
+ * **account:** claim links expire after 30 days and accounts get claimed in the browser, so refresh the stored link and stop offering it once claimed ([#65](https://github.com/ZenRows/cli/issues/65)) ([ded5602](https://github.com/ZenRows/cli/commit/ded5602caa300aa288f6532a27abd38de83362a1))
73
+
74
+ ## [1.6.2](https://github.com/ZenRows/cli/compare/v1.6.1...v1.6.2) (2026-10-16)
75
+
76
+
77
+ ### Bug Fixes
78
+
79
+ * **crawl:** say a crawl with nothing new to follow completes, so users stop chasing their patterns ([#50](https://github.com/ZenRows/cli/issues/50)) ([0301d47](https://github.com/ZenRows/cli/commit/0301d47036a8a8dacd7549c1aacf0f3c29be5afe))
80
+
81
+ ## [1.6.1](https://github.com/ZenRows/cli/compare/v1.6.0...v1.6.1) (2026-10-16)
82
+
83
+
84
+ ### Bug Fixes
85
+
86
+ * **crawl:** exit 1 when a crawl stops because credits ran out ([#49](https://github.com/ZenRows/cli/issues/49)) ([8661688](https://github.com/ZenRows/cli/commit/8661688d97029f0ab8ff18fe1a0ce5cb54f6b416))
87
+
88
+ ## [1.6.0](https://github.com/ZenRows/cli/compare/v1.5.5...v1.6.0) (2026-10-16)
89
+
90
+
91
+ ### Features
92
+
93
+ * **crawl:** return each page as Markdown, and choose how a crawl finds URLs ([#44](https://github.com/ZenRows/cli/issues/44)) ([6cd34a2](https://github.com/ZenRows/cli/commit/6cd34a2de8ed2c7a5fcfedc4ecddc8f1b8480a81))
94
+
95
+
96
+ ### Bug Fixes
97
+
98
+ * **batch:** point an inactive subscription to Billing, not to re-login ([#45](https://github.com/ZenRows/cli/issues/45)) ([58a1229](https://github.com/ZenRows/cli/commit/58a12297728690af3d45315fe74a4b6438e1935f))
99
+
100
+ ## [1.5.5](https://github.com/ZenRows/cli/compare/v1.5.4...v1.5.5) (2026-10-15)
101
+
102
+
103
+ ### Bug Fixes
104
+
105
+ * **release:** npm rejected the 1.5.4 publish because the repository URL casing did not match the provenance ([#54](https://github.com/ZenRows/cli/issues/54)) ([f778864](https://github.com/ZenRows/cli/commit/f7788649d7f834ba01b88e1bfe45dfd0cb67518d))
106
+
107
+ ## [1.5.4](https://github.com/ZenRows/cli/compare/v1.5.3...v1.5.4) (2026-10-15)
108
+
109
+
110
+ ### Bug Fixes
111
+
112
+ * **skills:** strict YAML parsers skipped the crawl skill over an unquoted colon ([#51](https://github.com/ZenRows/cli/issues/51)) ([fdd9eaa](https://github.com/ZenRows/cli/commit/fdd9eaa839f041c27b6f7f8d1ef97f183983efb9))
113
+
114
+ Releases before 1.5.4 are on [GitHub Releases](https://github.com/ZenRows/cli/releases).
package/README.md CHANGED
@@ -125,8 +125,22 @@ zenrows fetch <url> # Adaptive Stealth Mode (mode=auto)
125
125
  zenrows fetch <url> --output markdown
126
126
  zenrows fetch <url> --manual --js-render --premium-proxy
127
127
  zenrows fetch <url> --proxy-country us --wait-for ".price"
128
+ zenrows fetch <url> --retries 2 # retry a busy answer up to 2 times; off by default
128
129
  ```
129
130
 
131
+ `fetch` and `extract` send each request once. With `--retries <n>`, they retry
132
+ up to `n` times on a 429, 502, 503 or 504 from the API, and on a network error
133
+ before the request reached the API (refused connection, DNS failure, connect
134
+ timeout, unreachable network). Each wait is the `Retry-After` header in whole
135
+ seconds (0.25 s to 30 s) or a random delay that grows with each retry, and no
136
+ retry starts more than 90 s after the first request. They never retry a client
137
+ timeout (`--timeout`) or a connection that closed after the request was sent,
138
+ because the API may still finish and charge it; a status the target site sent
139
+ (`--original-status`); or any request of `zenrows extract prepare`. If a 502,
140
+ 503 or 504 is still the answer after one or more retries, the error says how
141
+ many retries ran and to wait a minute and run the same command again, without
142
+ suggesting costlier options. A 429 keeps the error it has without `--retries`.
143
+
130
144
  ## 8. Extract
131
145
 
132
146
  ```bash
@@ -100,6 +100,6 @@ projectRoot) {
100
100
  validateAutoManual(opts, config);
101
101
  const params = buildParams(opts, config);
102
102
  const mode = params.mode === "auto" ? "auto" : "manual";
103
- const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs, projectRoot });
103
+ const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs, retries: opts.retries, projectRoot });
104
104
  return { result, params, mode };
105
105
  }
@@ -21,6 +21,7 @@ import { formatRequestCost } from "../../core/http.js";
21
21
  import { parse, asString, asNumber } from "../command.js";
22
22
  import { printError, writeOut } from "../output.js";
23
23
  import { refreshClaimAdvice } from "../../core/claim-link.js";
24
+ import { normalizeRetries } from "./fetch.js";
24
25
  import { PREPARE_HELP, PREPARE_USAGE, runPrepare } from "./extract-prepare.js";
25
26
  export const extract = {
26
27
  name: "extract",
@@ -40,6 +41,7 @@ export const extract = {
40
41
  "Shared:",
41
42
  " --manual --js-render --premium-proxy manual fetch controls",
42
43
  " --validate fail if the result is not valid JSON",
44
+ " --retries <n> retry 429/502/503/504 and failed connects up to n times (default 0)",
43
45
  " --out <file> write output to a file",
44
46
  " --no-signup do not auto-create a Free plan account if no key exists",
45
47
  " --json structured result",
@@ -65,6 +67,7 @@ export const extract = {
65
67
  wait: { type: "string" },
66
68
  "wait-for": { type: "string" },
67
69
  validate: { type: "boolean" },
70
+ retries: { type: "string" },
68
71
  out: { type: "string" },
69
72
  "no-signup": { type: "boolean" },
70
73
  json: { type: "boolean" },
@@ -112,6 +115,7 @@ export const extract = {
112
115
  wait: asNumber(values.wait),
113
116
  waitFor: asString(values["wait-for"]),
114
117
  validate: values.validate === true,
118
+ retries: normalizeRetries(values.retries),
115
119
  };
116
120
  assertUsable("extract");
117
121
  const config = loadConfig();
@@ -35,7 +35,8 @@ export const fetch_ = {
35
35
  " --output <fmt> html (default) | markdown | text | pdf",
36
36
  " --screenshot capture an above-the-fold screenshot",
37
37
  " --out <file> write the response body to a file",
38
- " --timeout <ms> client-side timeout (default 120000; above the API's own 90s budget)",
38
+ " --timeout <ms> client-side timeout (default 200000; above the API's own 180s budget)",
39
+ " --retries <n> retry 429/502/503/504 and failed connects up to n times (default 0)",
39
40
  " --no-signup do not auto-create a Free plan account if no key exists",
40
41
  " --json print a structured result",
41
42
  "",
@@ -58,6 +59,7 @@ export const fetch_ = {
58
59
  screenshot: { type: "boolean" },
59
60
  out: { type: "string" },
60
61
  timeout: { type: "string" },
62
+ retries: { type: "string" },
61
63
  "no-signup": { type: "boolean" },
62
64
  json: { type: "boolean" },
63
65
  });
@@ -94,6 +96,7 @@ export const fetch_ = {
94
96
  output: normalizeOutput(asString(values.output)),
95
97
  screenshot: values.screenshot === true,
96
98
  timeoutMs: normalizeTimeout(values.timeout),
99
+ retries: normalizeRetries(values.retries),
97
100
  };
98
101
  const runId = newRunId();
99
102
  const startedAt = new Date().toISOString();
@@ -194,6 +197,20 @@ export function normalizeTimeout(v) {
194
197
  }
195
198
  return ms;
196
199
  }
200
+ /** `--retries <n>`: a whole number, 0 or more. Like `--timeout`, a bad value is an error. */
201
+ export function normalizeRetries(v) {
202
+ if (v === undefined)
203
+ return undefined;
204
+ if (typeof v !== "string" || !/^\d+$/.test(v)) {
205
+ throw new ToolkitError({
206
+ code: "INVALID_USAGE",
207
+ message: `Invalid --retries value '${String(v)}'.`,
208
+ likely_cause: "--retries takes a whole number, 0 or more.",
209
+ next_action: "Pass a count, e.g. --retries 2. 0, the default, sends the request once.",
210
+ });
211
+ }
212
+ return Number(v);
213
+ }
197
214
  export function normalizeOutput(v) {
198
215
  if (!v)
199
216
  return undefined;
@@ -14,7 +14,7 @@ export const CONFIG_VERSION = "0.1.0";
14
14
  * entrypoint) so core modules — telemetry, signup provenance — can read it
15
15
  * without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
16
16
  */
17
- export const CLI_VERSION = "1.12.0"; // x-release-please-version
17
+ export const CLI_VERSION = "1.13.1"; // x-release-please-version
18
18
  /** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */
19
19
  export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`;
20
20
  /** Env var to override the Fetch and Extract API base (local/staging testing). */
@@ -9,13 +9,13 @@ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./
9
9
  import { storedClaimHint, usableClaimUrl } from "./claim-link.js";
10
10
  import { readAccount } from "./agent-account.js";
11
11
  import { ENV_KEY, resolveApiKey } from "./auth.js";
12
- import { registerSecret } from "./logger.js";
12
+ import { log, registerSecret } from "./logger.js";
13
13
  import { CLI_USER_AGENT } from "./config.js";
14
14
  import { agentClientHeader } from "./agent-client.js";
15
15
  import { normalizeDomain, PAGE_NOT_RECOGNISED_STEPS, prepareCommand } from "./prepared-domains-api.js";
16
16
  /**
17
17
  * Client-side timeout for a Fetch and Extract request, deliberately ABOVE the
18
- * gateway's own 90s request budget.
18
+ * gateway's own request budget: a Fetch request can take up to 3 minutes.
19
19
  *
20
20
  * The two must not be equal. When they were both 90s, any request that used
21
21
  * the full server budget became a race between our own abort and the API's
@@ -24,9 +24,47 @@ import { normalizeDomain, PAGE_NOT_RECOGNISED_STEPS, prepareCommand } from "./pr
24
24
  * client must outlive the server budget so the API always gets the chance to
25
25
  * answer for itself.
26
26
  */
27
- export const DEFAULT_TIMEOUT_MS = 120_000;
27
+ export const DEFAULT_TIMEOUT_MS = 200_000;
28
28
  /** The gateway's own request budget. Kept here only to justify the default above. */
29
- export const SERVER_BUDGET_MS = 90_000;
29
+ export const SERVER_BUDGET_MS = 180_000;
30
+ /** No retry starts later than this after the first request. */
31
+ const RETRY_BUDGET_MS = 90_000;
32
+ /** Busy answers from the API, retried with `--retries`. */
33
+ const RETRY_STATUSES = new Set([429, 502, 503, 504]);
34
+ /** Network error codes that mean the request never reached the API. */
35
+ const NEVER_SENT_CODES = new Set([
36
+ "ECONNREFUSED",
37
+ "ENOTFOUND",
38
+ "EAI_AGAIN",
39
+ "ENETUNREACH",
40
+ "EHOSTUNREACH",
41
+ "UND_ERR_CONNECT_TIMEOUT",
42
+ ]);
43
+ /** `Retry-After` in whole seconds, clamped to 0.25-30 s; undefined for any other value. */
44
+ export function retryAfterMs(value) {
45
+ if (!value || !/^\d+$/.test(value.trim()))
46
+ return undefined;
47
+ return Math.min(Math.max(Number(value) * 1000, 250), 30_000);
48
+ }
49
+ /** Full jitter before retry `n` (0-based): uniform(0.25 s, min(30 s, 2^n s)). */
50
+ export function jitterMs(n, random = Math.random) {
51
+ return 250 + random() * (Math.min(30_000, 1000 * 2 ** n) - 250);
52
+ }
53
+ /**
54
+ * True when a fetch error's `cause` means the request never reached the API.
55
+ * ETIMEDOUT counts only on connect. A happy-eyeballs AggregateError counts
56
+ * only when every address failed to connect.
57
+ */
58
+ export function neverSent(cause) {
59
+ if (cause instanceof AggregateError)
60
+ return cause.errors.length > 0 && cause.errors.every(neverSent);
61
+ const { code, syscall } = (cause ?? {});
62
+ return code === "ETIMEDOUT" ? syscall === "connect" : NEVER_SENT_CODES.has(String(code));
63
+ }
64
+ /** A busy answer from the API. A status the target site sent is never retried. */
65
+ function isRetryable(status, params) {
66
+ return RETRY_STATUSES.has(status) && !params.original_status && params.allowed_status_codes === undefined;
67
+ }
30
68
  function buildUrl(apiBase, apiKey, params) {
31
69
  const u = new URL(apiBase);
32
70
  u.searchParams.set("apikey", apiKey);
@@ -45,43 +83,66 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
45
83
  registerSecret(apiKey);
46
84
  const { full, redacted } = buildUrl(apiBase, apiKey, params);
47
85
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
48
- const controller = new AbortController();
49
- // Our own timer is the only thing that aborts this controller, so this flag —
50
- // not `err.name === "AbortError"` — is what tells a client-side give-up apart
51
- // from a genuine transport failure. The two must never share an error code.
52
- let timedOut = false;
53
- const timeout = setTimeout(() => {
54
- timedOut = true;
55
- controller.abort();
56
- }, timeoutMs);
57
- const startedAt = Date.now();
86
+ const retries = opts.retries ?? 0;
87
+ const now = opts.now ?? Date.now;
88
+ const firstSentAt = now();
89
+ /** Waits before retry `n` and returns true, unless no retry is left or it would start after the budget. */
90
+ const retry = async (n, delayMs, reason) => {
91
+ if (n >= retries || now() - firstSentAt + delayMs > RETRY_BUDGET_MS)
92
+ return false;
93
+ log.dim(`${reason}; retry ${n + 1}/${retries} in ${formatMs(delayMs)}`);
94
+ await (opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(delayMs);
95
+ return true;
96
+ };
58
97
  let res;
59
- try {
60
- res = await fetch(full, {
61
- method: "GET",
62
- headers: { "User-Agent": CLI_USER_AGENT, "Accept-Encoding": "gzip, deflate", ...agentClientHeader({ projectRoot: opts.projectRoot }) },
63
- signal: controller.signal,
64
- });
65
- }
66
- catch (err) {
98
+ let raw;
99
+ let retriesUsed = 0;
100
+ for (;; retriesUsed++) {
101
+ const controller = new AbortController();
102
+ // Our own timer is the only thing that aborts this controller, so this flag —
103
+ // not `err.name === "AbortError"` — is what tells a client-side give-up apart
104
+ // from a genuine transport failure. The two must never share an error code.
105
+ let timedOut = false;
106
+ const timeout = setTimeout(() => {
107
+ timedOut = true;
108
+ controller.abort();
109
+ }, timeoutMs);
110
+ const startedAt = Date.now();
111
+ try {
112
+ res = await fetch(full, {
113
+ method: "GET",
114
+ headers: { "User-Agent": CLI_USER_AGENT, "Accept-Encoding": "gzip, deflate", ...agentClientHeader({ projectRoot: opts.projectRoot }) },
115
+ signal: controller.signal,
116
+ });
117
+ }
118
+ catch (err) {
119
+ clearTimeout(timeout);
120
+ const elapsedMs = Date.now() - startedAt;
121
+ // A timeout, or an error after the request was sent, is never retried:
122
+ // the API may still finish and charge that request.
123
+ if (timedOut)
124
+ throw requestTimeout(params.url, timeoutMs, elapsedMs);
125
+ const retryable = err instanceof Error && neverSent(err.cause);
126
+ if (retryable && (await retry(retriesUsed, jitterMs(retriesUsed), "Network error before the request reached the API")))
127
+ continue;
128
+ const cause = err instanceof Error ? err.message : String(err);
129
+ throw new ToolkitError({
130
+ code: "BACKEND_UNAVAILABLE",
131
+ message: `Could not reach the Zenrows API.`,
132
+ likely_cause: `Network error after ${formatMs(elapsedMs)}: ${cause}`,
133
+ next_action: "Check connectivity and retry. Verify api base in `zenrows config show`.",
134
+ suggested_commands: ["zenrows status", "zenrows config show"],
135
+ });
136
+ }
67
137
  clearTimeout(timeout);
68
- const elapsedMs = Date.now() - startedAt;
69
- if (timedOut)
70
- throw requestTimeout(params.url, timeoutMs, elapsedMs);
71
- const cause = err instanceof Error ? err.message : String(err);
72
- throw new ToolkitError({
73
- code: "BACKEND_UNAVAILABLE",
74
- message: `Could not reach the Zenrows API.`,
75
- likely_cause: `Network error after ${formatMs(elapsedMs)}: ${cause}`,
76
- next_action: "Check connectivity and retry. Verify api base in `zenrows config show`.",
77
- suggested_commands: ["zenrows status", "zenrows config show"],
78
- });
138
+ // Read the raw bytes once. `body` (UTF-8) drives all error detection and text
139
+ // output; `raw` preserves the exact bytes so binary responses (screenshot /
140
+ // PDF) can be written without the UTF-8 round-trip that corrupts them.
141
+ raw = Buffer.from(await res.arrayBuffer());
142
+ const delayMs = retryAfterMs(res.headers.get("retry-after")) ?? jitterMs(retriesUsed);
143
+ if (!isRetryable(res.status, params) || !(await retry(retriesUsed, delayMs, `HTTP ${res.status}`)))
144
+ break;
79
145
  }
80
- clearTimeout(timeout);
81
- // Read the raw bytes once. `body` (UTF-8) drives all error detection and text
82
- // output; `raw` preserves the exact bytes so binary responses (screenshot /
83
- // PDF) can be written without the UTF-8 round-trip that corrupts them.
84
- const raw = Buffer.from(await res.arrayBuffer());
85
146
  const body = raw.toString("utf8");
86
147
  // A binary body is only ever produced by a successful screenshot/PDF request.
87
148
  // Zenrows error responses are always a JSON envelope (text), so exclude those.
@@ -228,6 +289,15 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
228
289
  : "Verify the URL. Rendering or premium proxies will not turn a 404 into content — do not retry; it will fail identically and still cost credits.",
229
290
  });
230
291
  }
292
+ if (retriesUsed > 0 && isRetryable(res.status, params)) {
293
+ // A busy answer that outlasted the retries: a costlier configuration does not fix it.
294
+ throw new ToolkitError({
295
+ code: "FETCH_FAILED",
296
+ message: `The Zenrows API answered HTTP ${res.status} after ${retriesUsed} ${retriesUsed === 1 ? "retry" : "retries"}.`,
297
+ likely_cause: snippet(body) || "The Zenrows API is busy.",
298
+ next_action: "Wait a minute, then run the same command again. Do not add --js-render or --premium-proxy: they cost more and do not help here.",
299
+ });
300
+ }
231
301
  if (res.status === 422 || res.status >= 500 || (res.status >= 400 && !looksLikeContent(result))) {
232
302
  throw new ToolkitError({
233
303
  code: "FETCH_FAILED",
@@ -323,7 +393,7 @@ export function extractDomainNotPrepared(url, body) {
323
393
  * The CLI gave up waiting before the API answered. This is NOT unreachability:
324
394
  * the connection was established and the gateway was still working, so telling
325
395
  * the operator to check connectivity would send them at the wrong problem. The
326
- * elapsed time is included so the 90s server budget is visible in the output.
396
+ * elapsed time is included so the server budget is visible in the output.
327
397
  */
328
398
  export function requestTimeout(url, timeoutMs, elapsedMs) {
329
399
  const suggestedMs = Math.max(timeoutMs + 60_000, SERVER_BUDGET_MS + 60_000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Open-source CLI, MCP, skills, templates, workflows, recipes, and evals layer for giving AI agents reliable access to protected web data through Zenrows cloud infrastructure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "recipes",
43
43
  "evals",
44
44
  "README.md",
45
+ "CHANGELOG.md",
45
46
  "LICENSE"
46
47
  ],
47
48
  "scripts": {
@@ -68,6 +68,7 @@ zenrows extract https://www.example.com/products/a # 3. extract again
68
68
  `limit` / `remaining`.
69
69
  - `EXTRACT_PREPARE_OUT_OF_ALLOWANCE`: no slots left this month; use `--css`.
70
70
  `EXTRACT_PREPARE_UNAVAILABLE`: retry in a few minutes; do not loop.
71
+ `prepare` sends each request once: `--retries` does not apply to it.
71
72
  A `failed` status prints the next step for its failure code and says whether
72
73
  the attempt used an allowance slot.
73
74
  - A domain that is already prepared answers `ready` at once
@@ -47,6 +47,11 @@ zenrows fetch <url> --manual --js-render --premium-proxy # the most expensive
47
47
  needs `--premium-proxy` (else `PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM`).
48
48
  - You are billed only for the configuration that succeeds.
49
49
  - On failure, read `zenrows trace explain <run-id>` before escalating.
50
+ - The CLI sends each request once. `--retries <n>` retries a 429, 502, 503 or
51
+ 504 and a network error before the request reached the API, up to `n` times,
52
+ within 90 s; prefer it to your own retry loop. It never retries a timeout, a
53
+ connection closed after the request was sent, or a target status under
54
+ `--original-status`.
50
55
 
51
56
  Every run is saved under `.zenrows/runs/<run-id>/` (no secrets). See [[extract]]
52
57
  for structured output and [[cost-control]] for the cost model.
@@ -26,8 +26,13 @@ zenrows trace export <run-id> # JSON for sharing
26
26
  `--manual --js-render` run; auto mode may ignore it. Each step
27
27
  multiplies the cost of the request, and both together are the most expensive
28
28
  configuration available ([[cost-control]]).
29
+ - `FETCH_FAILED` "The Zenrows API answered HTTP 502/503/504 after N retries" →
30
+ the CLI already retried a busy answer (`--retries`). Wait a minute and run the
31
+ same command again; do not escalate, a costlier configuration does not fix
32
+ it. A 429 ("Rate limited") keeps its advice with or without `--retries`: wait
33
+ a few seconds and retry; if it persists, lower concurrency.
29
34
  - `REQUEST_TIMEOUT` → the CLI stopped waiting; the API was reached. Raise
30
- `--timeout` (default 120000ms, above the API's own 90s budget), or drop
35
+ `--timeout` (default 200000ms, above the API's own 180s budget), or drop
31
36
  `--wait-for` so the request finishes inside that budget and the API returns
32
37
  its own error. Not a connectivity problem — do not chase the network.
33
38
  - `BACKEND_UNAVAILABLE` → a genuine transport failure (DNS/TCP/TLS). Check