@zenrows/cli 1.2.3 → 1.3.0

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.
@@ -17,7 +17,7 @@ import { assertDomainAllowed, assertWithinLimits, loadPolicy } from "../../core/
17
17
  import { newRunId, writeRun } from "../../core/artifacts.js";
18
18
  import { createJob, downloadResults, getJob, listResults, rerunJob, stopJob, waitForJob } from "../../core/batch-api.js";
19
19
  import { asNumber, asString, parse } from "../command.js";
20
- import { ToolkitError } from "../../core/errors.js";
20
+ import { ToolkitError, isKeyCapReached, keyCapReached } from "../../core/errors.js";
21
21
  import { printError, writeOut } from "../output.js";
22
22
  export const batch = {
23
23
  name: "batch",
@@ -166,20 +166,22 @@ async function createCmd(rest, ctx) {
166
166
  try {
167
167
  const job = await createJob(body, { apiKey });
168
168
  const finished = follow ? await waitForJob(job.job_id, { apiKey }) : job;
169
+ const runError = runFailure(finished);
169
170
  const runDir = writeRun({
170
171
  runId,
171
172
  command: "zenrows batch create",
172
173
  capability: "batch",
173
174
  startedAt,
174
175
  finishedAt: new Date().toISOString(),
175
- status: "ok",
176
+ status: runError ? "error" : "ok",
176
177
  request: { file, tasks: body.tasks.length, estimatedCredits: est.credits, jobParams },
177
- result: { jobId: job.job_id, status: finished.latest_run?.status ?? "unknown" },
178
+ result: { jobId: job.job_id, status: finished.latest_run?.status ?? "unknown", ...failureFields(finished) },
179
+ ...(runError ? { error: runError.toJSON() } : {}),
178
180
  });
179
- printJob(finished, json, `Submitted job ${job.job_id}`);
181
+ const code = printJob(finished, json, `Submitted job ${job.job_id}`);
180
182
  if (runDir && !json)
181
183
  log.dim(` artifact: ${runDir}`);
182
- return 0;
184
+ return code;
183
185
  }
184
186
  catch (err) {
185
187
  writeRun({
@@ -201,8 +203,7 @@ async function statusCmd(rest, ctx) {
201
203
  assertUsable("batch");
202
204
  const apiKey = requireApiKey();
203
205
  const job = await getJob(id, { apiKey });
204
- printJob(job, ctx.json, `Job ${id}`);
205
- return 0;
206
+ return printJob(job, ctx.json, `Job ${id}`);
206
207
  }
207
208
  async function resultsCmd(rest, ctx) {
208
209
  const { values, positionals } = parse(rest, {
@@ -273,8 +274,7 @@ async function cancelCmd(rest, ctx) {
273
274
  assertUsable("batch");
274
275
  const apiKey = requireApiKey();
275
276
  const job = await stopJob(id, { apiKey });
276
- printJob(job, ctx.json, `Stopped job ${id}`);
277
- return 0;
277
+ return printJob(job, ctx.json, `Stopped job ${id}`);
278
278
  }
279
279
  async function waitCmd(rest, ctx) {
280
280
  const { values, positionals } = parse(rest, { timeout: { type: "string" }, json: { type: "boolean" } });
@@ -283,8 +283,7 @@ async function waitCmd(rest, ctx) {
283
283
  assertUsable("batch");
284
284
  const apiKey = requireApiKey();
285
285
  const job = await waitForJob(id, { apiKey, timeoutMs: asNumber(values.timeout) });
286
- printJob(job, json, `Job ${id} finished`);
287
- return 0;
286
+ return printJob(job, json, `Job ${id} finished`);
288
287
  }
289
288
  async function retryCmd(rest, ctx) {
290
289
  const { positionals } = parse(rest, {});
@@ -294,21 +293,72 @@ async function retryCmd(rest, ctx) {
294
293
  // "Reruns and retrying failures": POST /jobs/{id}/rerun?status=failed replays
295
294
  // only the failures; already-successful tasks carry over.
296
295
  const job = await rerunJob(id, { apiKey, status: "failed" });
297
- printJob(job, ctx.json, `Reran failed tasks for job ${id}`);
298
- return 0;
296
+ return printJob(job, ctx.json, `Reran failed tasks for job ${id}`);
297
+ }
298
+ /** The run's failure_reason / failure_detail, when the API reports them. */
299
+ function failureFields(job) {
300
+ const run = job.latest_run ?? {};
301
+ const out = {};
302
+ if (typeof run.failure_reason === "string" && run.failure_reason)
303
+ out.failure_reason = run.failure_reason;
304
+ if (typeof run.failure_detail === "string" && run.failure_detail)
305
+ out.failure_detail = run.failure_detail;
306
+ return out;
307
+ }
308
+ /**
309
+ * The error for a run that ended `failed`, or null for any other state. A run the
310
+ * key's credit cap stopped (`api_key_cap_reached`) gets the cap guidance: the
311
+ * account still has credits and its other keys keep working.
312
+ */
313
+ export function runFailure(job) {
314
+ if (job.latest_run?.status !== "failed")
315
+ return null;
316
+ const { failure_reason, failure_detail } = failureFields(job);
317
+ const where = `batch job ${job.job_id}`;
318
+ if (isKeyCapReached(failure_reason)) {
319
+ return keyCapReached(where, { status: null, detail: failure_detail });
320
+ }
321
+ const why = [failure_reason, failure_detail].filter(Boolean).join(": ");
322
+ return new ToolkitError({
323
+ code: "BATCH_FAILED",
324
+ message: `Batch job ${job.job_id} failed.`,
325
+ likely_cause: why || "The run ended in status failed without a reason.",
326
+ next_action: "Inspect the per-task results, fix the cause, then rerun the failed tasks.",
327
+ suggested_commands: [`zenrows batch results ${job.job_id} --status failed`, `zenrows batch retry-failed ${job.job_id}`],
328
+ });
299
329
  }
300
- /** Print a job's status + stats, structured under --json. */
330
+ /**
331
+ * Print a job's status + stats, structured under --json, and return the exit
332
+ * code: 1 when the run ended `failed`, else 0. `stopped` and `deleted` are
333
+ * deliberate outcomes (someone cancelled or removed the run), so they exit 0
334
+ * but print as a warning, not a success.
335
+ */
301
336
  function printJob(job, json, headline) {
302
337
  const run = job.latest_run ?? {};
303
338
  const stats = run.stats;
339
+ const failure = failureFields(job);
340
+ const err = runFailure(job);
304
341
  if (json) {
305
- log.out(JSON.stringify({ ok: true, jobId: job.job_id, status: run.status, stats }, null, 2));
306
- return;
342
+ log.out(JSON.stringify({ ok: !err, jobId: job.job_id, status: run.status, stats, ...failure, ...(err ? { error: err.toJSON() } : {}) }, null, 2));
343
+ return err ? 1 : 0;
307
344
  }
308
- log.success(`${headline} · status: ${run.status ?? "unknown"}`);
345
+ const line = `${headline} · status: ${run.status ?? "unknown"}`;
346
+ if (err)
347
+ log.error(line);
348
+ else if (run.status === "stopped" || run.status === "deleted")
349
+ log.warn(line);
350
+ else
351
+ log.success(line);
309
352
  if (stats) {
310
353
  log.info(` ${stats.completed}/${stats.total} completed · ${stats.successful} successful · ${stats.failed} failed`);
311
354
  }
355
+ if (failure.failure_reason)
356
+ log.info(` failure_reason: ${failure.failure_reason}`);
357
+ if (failure.failure_detail)
358
+ log.info(` failure_detail: ${failure.failure_detail}`);
359
+ if (err)
360
+ printError(err, false);
361
+ return err ? 1 : 0;
312
362
  }
313
363
  function normalizeResultStatus(v) {
314
364
  if (!v)
@@ -27,6 +27,22 @@ export function formatPlanStatus(status) {
27
27
  return "active";
28
28
  return status;
29
29
  }
30
+ const CAP_WINDOW_LABEL = {
31
+ day: "daily",
32
+ week: "weekly",
33
+ month: "monthly",
34
+ billing_period: "billing-period",
35
+ };
36
+ /** One line per credit cap on the calling key; none when the key is uncapped. */
37
+ export function formatKeyCaps(caps) {
38
+ return (caps ?? []).map((c) => {
39
+ const label = `This key: ${CAP_WINDOW_LABEL[c.window] ?? c.window} cap ${fmt(c.credits)}`;
40
+ const reset = c.resets_at ? `, resets ${c.resets_at}` : "";
41
+ if (c.unavailable || c.used_credits === undefined)
42
+ return `${label}, usage unavailable right now${reset}`;
43
+ return `${label}, ${fmt(c.used_credits)} used, ${fmt(c.remaining_credits ?? Math.max(0, c.credits - c.used_credits))} left${reset}`;
44
+ });
45
+ }
30
46
  export const usage = {
31
47
  name: "usage",
32
48
  summary: "Show plan usage, credits, and concurrency for the current API key.",
@@ -63,6 +79,8 @@ export const usage = {
63
79
  }
64
80
  if (u.period_ends_at)
65
81
  log.info(`Billing period ends: ${u.period_ends_at}`);
82
+ for (const line of formatKeyCaps(u.api_key?.caps))
83
+ log.info(line);
66
84
  if (Array.isArray(u.top_ups) && u.top_ups.length)
67
85
  log.info(`Top-ups: ${u.top_ups.length}`);
68
86
  return 0;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { mkdirSync, writeFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { ToolkitError, quotaExhausted } from "./errors.js";
19
+ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
20
20
  import { readAccount } from "./agent-account.js";
21
21
  import { registerSecret } from "./logger.js";
22
22
  /** Confirmed Batch API base (no trailing slash). */
@@ -139,6 +139,9 @@ function problemToError(status, body, method, path) {
139
139
  suggested_commands: ["zenrows batch status <id>"],
140
140
  });
141
141
  }
142
+ if (status === 402 && isKeyCapReached(serverCode)) {
143
+ return keyCapReached(`${method} ${path}`, { status: 402, detail: problem.detail || problem.title || undefined });
144
+ }
142
145
  if (status === 402) {
143
146
  // Out of credits ("Subscription has no credit available") — the same
144
147
  // exhausted state as the scraper/usage 402. Surface the credits error with
@@ -11,7 +11,7 @@
11
11
  * Sessions are billed by bandwidth + session time, so callers must always close
12
12
  * them (`closeSession`) — the `browser run` command does so in a `finally`.
13
13
  */
14
- import { ToolkitError, quotaExhausted } from "./errors.js";
14
+ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
15
15
  import { readAccount } from "./agent-account.js";
16
16
  import { registerSecret } from "./logger.js";
17
17
  import { CLI_VERSION } from "./config.js";
@@ -105,6 +105,12 @@ export function browserProblemToError(status, body, method, path) {
105
105
  suggested_commands: ["zenrows login --api-key <your-key>"],
106
106
  });
107
107
  }
108
+ if (status === 402 && isKeyCapReached(serverCode, body)) {
109
+ return keyCapReached(`${method} ${path}`, {
110
+ status: 402,
111
+ detail: parsed.error || parsed.detail || parsed.title || undefined,
112
+ });
113
+ }
108
114
  if (status === 402) {
109
115
  // Out of credits — same exhausted state as the scraper/batch 402.
110
116
  const acct = readAccount();
@@ -11,7 +11,7 @@ export const CONFIG_VERSION = "0.1.0";
11
11
  * entrypoint) so core modules — telemetry, signup provenance — can read it
12
12
  * without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
13
13
  */
14
- export const CLI_VERSION = "1.2.3";
14
+ export const CLI_VERSION = "1.3.0";
15
15
  /** Env var to override the Fetch and Extract API base (local/staging testing). */
16
16
  export const API_BASE_ENV = "ZENROWS_API_BASE";
17
17
  /**
@@ -57,3 +57,31 @@ export function quotaExhausted(url, claimUrl, opts = {}) {
57
57
  suggested_commands: ["zenrows usage"],
58
58
  });
59
59
  }
60
+ /** Where an account manages its API keys and their credit caps. */
61
+ export const API_KEYS_SETTINGS_URL = "https://app.zenrows.com/settings/api-keys";
62
+ /**
63
+ * True when a 402 is a per-key credit cap (gateway AUTH014, Batch
64
+ * `api_key_cap_reached`), not an account out of credits. The account still has
65
+ * credits, so the out-of-credits advice (top up, upgrade, claim) is wrong here.
66
+ */
67
+ export function isKeyCapReached(code, body) {
68
+ if (code === "AUTH014" || code === "api_key_cap_reached")
69
+ return true;
70
+ return !!body && /\b(AUTH014|api_key_cap_reached)\b/.test(body);
71
+ }
72
+ /**
73
+ * The error for a request refused because this API key reached one of its credit
74
+ * caps. `opts.status: null` is for a Batch run the cap stopped: the run's status
75
+ * call succeeded, so there is no HTTP 402 to cite, only `url` (the job).
76
+ */
77
+ export function keyCapReached(url, opts = {}) {
78
+ const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
79
+ const where = opts.status === null ? `Stopped ${url}` : `HTTP ${opts.status ?? 402} for ${url}`;
80
+ return new ToolkitError({
81
+ code: "KEY_CREDIT_CAP_REACHED",
82
+ message: "This API key reached one of its credit caps.",
83
+ likely_cause: `${detail}${where}`,
84
+ next_action: `The account still has credits and its other API keys keep working. Wait for the cap to reset (\`zenrows usage\` shows when), or raise or remove this key's cap at ${API_KEYS_SETTINGS_URL}.`,
85
+ suggested_commands: ["zenrows usage"],
86
+ });
87
+ }
@@ -5,7 +5,7 @@
5
5
  * query parameter (per docs) and is registered as a secret so it is redacted
6
6
  * from any logged URL.
7
7
  */
8
- import { ToolkitError, quotaExhausted } from "./errors.js";
8
+ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
9
9
  import { readAccount } from "./agent-account.js";
10
10
  import { ENV_KEY, resolveApiKey } from "./auth.js";
11
11
  import { registerSecret } from "./logger.js";
@@ -142,6 +142,11 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
142
142
  suggested_commands: [`zenrows extract ${params.url} --autoparse`],
143
143
  });
144
144
  }
145
+ // AUTH014: this key hit one of its credit caps; the account still has credits.
146
+ if (isKeyCapReached(zrErrorCode(body) ?? undefined)) {
147
+ // `detail` names the cap and its reset date; the title alone says neither.
148
+ throw keyCapReached(redacted, { status: 402, detail: zrErrorProblemDetail(body) ?? zrErrorDetail(body) ?? undefined });
149
+ }
145
150
  // Zenrows returns 402 with a JSON error envelope (e.g. AUTH004 "reached its
146
151
  // usage limit" / "Subscription has no credit available") when the account is
147
152
  // out of credits. This is NOT scraped content — surface it as a credits
@@ -358,6 +363,18 @@ export function zrErrorCode(body) {
358
363
  return null;
359
364
  }
360
365
  }
366
+ /** The problem body's `detail` field alone, prefixed with its code. */
367
+ function zrErrorProblemDetail(body) {
368
+ try {
369
+ const j = JSON.parse(body);
370
+ if (!j.detail)
371
+ return null;
372
+ return j.code ? `(${j.code}) ${j.detail}` : j.detail;
373
+ }
374
+ catch {
375
+ return null;
376
+ }
377
+ }
361
378
  /**
362
379
  * Clean one-line detail from a Zenrows JSON error body
363
380
  * (e.g. `(AUTH003) Invalid apikey provided`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
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",
@@ -54,6 +54,14 @@ zenrows batch retry-failed <id> # rerun only the failed tasks (new run)
54
54
  `--premium-proxy` (or `mode=auto`) and is rejected before any request. Job-level
55
55
  flags apply to every task; per-task keys in the JSONL override them.
56
56
 
57
+ A run ends in one of `completed`, `failed`, `stopped`, or `deleted`. `status`,
58
+ `wait`, and `create --wait` exit non-zero (`ok: false` under `--json`) only for
59
+ `failed`, and print the run's `failure_reason` and `failure_detail`. When
60
+ `failure_reason` is `api_key_cap_reached`, the key hit its own credit cap
61
+ (`KEY_CREDIT_CAP_REACHED`): the account still has credits, other keys keep
62
+ working, and the tasks not yet run stay pending. Wait for the cap to reset
63
+ (`zenrows usage`) or raise the cap; do not resubmit the job with the same key.
64
+
57
65
  Deferred (documented, not yet in the CLI): CSV upload, open/queue jobs,
58
66
  scheduled jobs, webhooks/HMAC, and ZIP export.
59
67
 
@@ -28,7 +28,10 @@ zenrows extract <url> --validate # fail if not valid JSON
28
28
 
29
29
  ## Rules
30
30
  - Validate on a single page before scaling across many URLs.
31
- - If Autoparse misses fields, switch to `--css` with explicit selectors, or add
32
- `--manual --js-render` for JS-heavy pages.
31
+ - If Autoparse misses fields, switch to `--css` with explicit selectors. Auto
32
+ mode already renders JS when the target needs it, so reach for
33
+ `--manual --js-render` only after auto mode has returned an incomplete page:
34
+ it costs several times a basic call ([[cost-control]]) and it makes the
35
+ escalation yours.
33
36
 
34
37
  See [[protected-fetch]] for retrieval semantics.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: protected-fetch
3
- description: Use Zenrows Protected Fetch for anti-bot-protected page retrieval.
3
+ description: Use Zenrows Protected Fetch in auto mode for anti-bot-protected page retrieval. Auto mode handles the anti-bot escalation, so never enable JS rendering or premium proxies yourself on a first attempt.
4
4
  version: 0.1.0
5
5
  requires_backend_capabilities: [protected_fetch]
6
6
  ---
@@ -16,18 +16,33 @@ cannot. This is the **core primitive** — backed by Zenrows **Fetch**
16
16
  - The target has anti-bot protection, needs JS rendering, or geo-specific access.
17
17
 
18
18
  ## How to call
19
+
20
+ Auto mode is the answer for anti-bot targets, Cloudflare included. It escalates
21
+ for you and bills only for the configuration that succeeds.
22
+
19
23
  ```
20
- zenrows fetch <url> # Adaptive Stealth Mode (recommended)
24
+ zenrows fetch <url> # Adaptive Stealth Mode. Start here, always
21
25
  zenrows fetch <url> --output markdown # convert to Markdown
22
- zenrows fetch <url> --manual --js-render --premium-proxy # full manual control
23
- zenrows fetch <url> --proxy-country us # geo-target (auto mode; in manual mode needs --premium-proxy)
26
+ zenrows fetch <url> --proxy-country us # geo-target, works in auto mode
24
27
  zenrows fetch <url> --wait-for ".price" # wait for a selector
25
28
  ```
26
29
 
30
+ Manual mode exists for the rare case where auto mode has already failed and a
31
+ trace shows why. It costs more and it makes the escalation your problem:
32
+
33
+ ```
34
+ zenrows fetch <url> --manual --js-render --premium-proxy # the most expensive path, see [[cost-control]]
35
+ ```
36
+
27
37
  ## Rules
28
- - Start with **auto mode**. In auto mode, `js_render` and `premium_proxy` are
29
- managed for you — passing them manually requires `--manual`
30
- (otherwise you get `PARAM_CONFLICT_AUTO_MANUAL`).
38
+ - Start with **auto mode**. Enabling `--js-render` and `--premium-proxy` yourself
39
+ is the most expensive configuration this API offers, by a wide margin, and auto
40
+ mode reaches the same place only when the target actually needs it. The
41
+ multipliers are in [[cost-control]].
42
+ - A hard target is not a reason to skip auto mode. It is the reason auto mode
43
+ exists.
44
+ - In auto mode `js_render` and `premium_proxy` are managed for you. Passing them
45
+ manually requires `--manual`, otherwise you get `PARAM_CONFLICT_AUTO_MANUAL`.
31
46
  - `--proxy-country` works in auto mode on its own; in `--manual` mode it also
32
47
  needs `--premium-proxy` (else `PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM`).
33
48
  - You are billed only for the configuration that succeeds.
@@ -19,8 +19,12 @@ zenrows trace export <run-id> # JSON for sharing
19
19
  ```
20
20
 
21
21
  ## Failure → action map
22
- - `FETCH_FAILED` / empty content → retry `--manual --js-render`, then add
23
- `--premium-proxy`; for slow pages add `--wait-for <selector>`.
22
+ - `FETCH_FAILED` / empty content → first retry in auto mode (`mode=auto`), which
23
+ escalates for you and bills only for what succeeds. For slow pages add
24
+ `--wait-for <selector>`. Only when auto mode has failed on its own, take
25
+ manual control with `--manual --js-render`, then `--premium-proxy`. Each step
26
+ multiplies the cost of the request, and both together are the most expensive
27
+ configuration available ([[cost-control]]).
24
28
  - `REQUEST_TIMEOUT` → the CLI stopped waiting; the API was reached. Raise
25
29
  `--timeout` (default 120000ms, above the API's own 90s budget), or drop
26
30
  `--wait-for` so the request finishes inside that budget and the API returns
@@ -28,6 +32,18 @@ zenrows trace export <run-id> # JSON for sharing
28
32
  - `BACKEND_UNAVAILABLE` → a genuine transport failure (DNS/TCP/TLS). Check
29
33
  connectivity and `zenrows config show`.
30
34
  - `AUTH_INVALID` → re-check the key, `zenrows login --api-key …`.
35
+ - `KEY_CREDIT_CAP_REACHED` → this API key hit one of its own credit caps (HTTP
36
+ 402, gateway code `AUTH014`, Batch `api_key_cap_reached`). The account still
37
+ has credits and its other keys keep working. Do not retry and do not escalate:
38
+ `zenrows usage` shows the cap and when it resets. Wait for the reset, or have
39
+ the account owner raise or remove the cap at
40
+ https://app.zenrows.com/settings/api-keys.
41
+ - `POLICY_MAX_CREDITS_EXCEEDED` → the account itself is out of credits (or a
42
+ local policy credit limit was hit). Do not retry-loop; `zenrows usage` shows
43
+ when credits renew. Top up or upgrade, or claim the account if it is an
44
+ auto-created Free plan.
45
+ - `BATCH_FAILED` → the batch run ended `failed`; `failure_reason` and
46
+ `failure_detail` in `zenrows batch status <id> --json` say why.
31
47
  - `PARAM_CONFLICT_AUTO_MANUAL` → drop the managed flags or add `--manual`.
32
48
  - `CAPABILITY_UNAVAILABLE` → the primitive is not available on this account (e.g. beta/invite-only); use the local-spec path where offered.
33
49