@zenrows/cli 1.7.0 → 1.8.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.
@@ -4,18 +4,27 @@
4
4
  * A SEPARATE host from Fetch and Extract (`api.zenrows.com/v1`): batch
5
5
  * jobs live at `https://async.api.zenrows.com/v1`. Auth is the `X-API-Key`
6
6
  * header (same key as Fetch and Extract), never the `apikey` query param. Bodies
7
- * are JSON; errors come back as `application/problem+json` (RFC 7807) with a
8
- * stable `code` we branch on. The key is registered as a secret so it is
9
- * redacted from any logged output. `fetchImpl` is injectable for tests, mirroring
10
- * `usage.ts` / `agent-account.ts`.
7
+ * are JSON; errors come back as `application/problem+json` (RFC 9457) with a
8
+ * stable `code` we branch on and carry as `server_code`. The key is registered
9
+ * as a secret so it is redacted from any logged output. `fetchImpl` is
10
+ * injectable for tests, mirroring `usage.ts` / `agent-account.ts`.
11
11
  *
12
- * Deferred (documented, not wired here): CSV upload (`/job_inputs` + presigned
13
- * PUT), open/queue jobs (`/tasks`, `last_batch`), scheduled jobs
14
- * (`schedule` / `PUT /schedule` / `schedule/state`), webhooks + HMAC, ZIP
15
- * archive export, and the `Idempotency-Key` header.
12
+ * Retries: reads (GET, DELETE) retry 429/502/503/504 and connection errors;
13
+ * stop, pause and resume retry 502/503/504 and connection errors, since
14
+ * repeating them is harmless; a submit or rerun retries the same way only
15
+ * under its `Idempotency-Key`, which `createJob` and `rerunJob` always send and
16
+ * reuse across the retries of one call. The API keeps a key for 24 hours and
17
+ * replays the original answer to a repeat with the same body, so a retry never
18
+ * starts a second job or run. A keyed write that still has no answer after its
19
+ * retries fails with `idempotency_key` set: repeating it with that key is safe.
20
+ *
21
+ * Not in the CLI (the API and the SDKs have them): CSV inputs, open jobs and
22
+ * add-tasks, scheduled jobs, webhooks and HMAC keys, ZIP exports, the runs
23
+ * list, and task history.
16
24
  */
17
25
  import { mkdirSync, writeFileSync } from "node:fs";
18
26
  import { join } from "node:path";
27
+ import { randomUUID } from "node:crypto";
19
28
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
20
29
  import { storedClaimHint } from "./claim-link.js";
21
30
  import { readAccount } from "./agent-account.js";
@@ -33,193 +42,413 @@ export function batchBase() {
33
42
  const base = env && env.trim() ? env.trim() : DEFAULT_BATCH_API_BASE;
34
43
  return base.replace(/\/+$/, "");
35
44
  }
36
- /** Terminal run states — a run stops progressing once it reaches one of these. */
45
+ /** Terminal run states: a run stops progressing once it reaches one of these. */
37
46
  export const TERMINAL_STATUSES = new Set(["completed", "stopped", "failed", "deleted"]);
47
+ /** Task statuses a rerun can select (`?status=` on `/rerun`). */
48
+ export const RERUN_STATUSES = ["pending", "processing", "failed", "successful"];
49
+ /** True when the run reached a terminal state. */
50
+ export function isTerminal(run) {
51
+ return !!run && TERMINAL_STATUSES.has(run.status);
52
+ }
53
+ /** True when a live run is paused: it picks up no new task until resumed. */
54
+ export function isPaused(run) {
55
+ return run?.pause_state === "paused" && !isTerminal(run);
56
+ }
57
+ const MAX_RETRIES = 3;
58
+ /** The longest a Retry-After may make one retry wait, so a large value cannot hang a command. */
59
+ const MAX_RETRY_AFTER_SECONDS = 30;
60
+ const READ_RETRY_STATUSES = new Set([429, 502, 503, 504]);
61
+ // 429 on a write is an account limit (active jobs): a retry only waits for a slot that may never free.
62
+ const WRITE_RETRY_STATUSES = new Set([502, 503, 504]);
63
+ /** 250 ms x 2^attempt, capped at 10 s, +/-20% jitter. */
64
+ function backoffMs(attempt) {
65
+ return Math.min(250 * 2 ** attempt, 10_000) * (0.8 + Math.random() * 0.4);
66
+ }
67
+ /** `Retry-After` in whole seconds, as milliseconds, at most `MAX_RETRY_AFTER_SECONDS`. */
68
+ function retryAfterMs(value) {
69
+ return value && /^\d+$/.test(value.trim()) ? Math.min(Number(value), MAX_RETRY_AFTER_SECONDS) * 1000 : undefined;
70
+ }
71
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
72
+ /** A fresh Idempotency-Key for one logical submit or rerun. */
73
+ export function newIdempotencyKey() {
74
+ return randomUUID();
75
+ }
76
+ /** The longest Idempotency-Key the Batch API accepts. */
77
+ export const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
38
78
  /**
39
- * Perform a Batch API request. Sets `X-API-Key`, adds a JSON content-type when a
40
- * body is present, parses the JSON response, and on a non-2xx parses the
41
- * problem+json body into a normalized ToolkitError. Returns `null` for empty
42
- * (204 / no-body) responses.
79
+ * Throw before any request if `key` cannot work as an Idempotency-Key: HTTP
80
+ * strips a header's surrounding whitespace, so a blank or padded key would
81
+ * reach the API as a different key, and control characters cannot be sent.
43
82
  */
44
- export async function batchRequest(method, path, opts) {
83
+ export function assertIdempotencyKey(key) {
84
+ let reason;
85
+ if (key.trim() === "")
86
+ reason = "it is empty";
87
+ else if (key !== key.trim())
88
+ reason = "it starts or ends with whitespace";
89
+ else if (/[\u0000-\u001f\u007f]/.test(key))
90
+ reason = "it contains a control character";
91
+ else if (key.length > MAX_IDEMPOTENCY_KEY_LENGTH)
92
+ reason = `it is longer than ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`;
93
+ if (!reason)
94
+ return;
95
+ throw new ToolkitError({
96
+ code: "INVALID_USAGE",
97
+ message: `Invalid --idempotency-key: ${reason}.`,
98
+ likely_cause: `An Idempotency-Key is 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters, with no surrounding whitespace and no control characters.`,
99
+ next_action: "Pass a key such as a UUID, or leave --idempotency-key out to have one generated.",
100
+ });
101
+ }
102
+ /** Errors a poll may ride out: the API was unreachable or answered 429/5xx. */
103
+ const transientErrors = new WeakSet();
104
+ /** True for an error worth polling past (a connection failure, a 429 or a 5xx). */
105
+ export function isTransient(err) {
106
+ return err instanceof ToolkitError && transientErrors.has(err);
107
+ }
108
+ function transient(err) {
109
+ transientErrors.add(err);
110
+ return err;
111
+ }
112
+ /** One HTTP attempt with its own timeout; the body is read inside it. */
113
+ async function attempt(url, init, opts) {
114
+ const controller = new AbortController();
115
+ const perAttempt = opts.timeoutMs ?? 60_000;
116
+ const ms = opts.deadline !== undefined ? Math.max(1, Math.min(perAttempt, opts.deadline - Date.now())) : perAttempt;
117
+ const timeout = setTimeout(() => controller.abort(), ms);
118
+ try {
119
+ const res = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
120
+ return { status: res.status, headers: res.headers, body: Buffer.from(await res.arrayBuffer()) };
121
+ }
122
+ finally {
123
+ clearTimeout(timeout);
124
+ }
125
+ }
126
+ /**
127
+ * Perform a Batch API request and return the raw response. Sets `X-API-Key`,
128
+ * adds a JSON content-type when a body is present, retries transient failures
129
+ * as `opts.retry` allows, and on a non-2xx parses the problem+json body into a
130
+ * normalized ToolkitError.
131
+ */
132
+ async function batchFetch(method, path, opts) {
45
133
  registerSecret(opts.apiKey);
46
134
  const url = new URL(batchBase() + path);
47
135
  for (const [k, v] of Object.entries(opts.query ?? {})) {
48
136
  if (v !== undefined && v !== null)
49
- url.searchParams.set(k, v);
137
+ url.searchParams.set(k, String(v));
50
138
  }
51
- const doFetch = opts.fetchImpl ?? fetch;
52
- const controller = new AbortController();
53
- const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
54
139
  const headers = {
55
140
  "X-API-Key": opts.apiKey,
56
141
  Accept: "application/json",
57
142
  "User-Agent": CLI_USER_AGENT,
58
143
  ...agentClientHeader(),
59
144
  };
145
+ if (opts.idempotencyKey)
146
+ headers["Idempotency-Key"] = opts.idempotencyKey;
60
147
  if (opts.body !== undefined)
61
148
  headers["Content-Type"] = "application/json";
62
- let res;
63
- try {
64
- res = await doFetch(url.toString(), {
65
- method,
66
- headers,
67
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
68
- signal: controller.signal,
69
- });
70
- }
71
- catch (err) {
72
- clearTimeout(timeout);
73
- throw new ToolkitError({
74
- code: "BACKEND_UNAVAILABLE",
75
- message: "Could not reach the Zenrows Batch API.",
76
- likely_cause: err instanceof Error ? err.message : String(err),
77
- next_action: "Check connectivity and retry. Override the host with ZENROWS_BATCH_API_BASE if you are testing against staging.",
78
- suggested_commands: ["zenrows status"],
79
- });
80
- }
81
- clearTimeout(timeout);
82
- const text = await res.text();
83
- if (res.status < 200 || res.status >= 300) {
84
- throw problemToError(res.status, text, method, path);
149
+ const init = { method, headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined };
150
+ const retryOn = opts.retry === "read" ? READ_RETRY_STATUSES : opts.retry === "write" ? WRITE_RETRY_STATUSES : undefined;
151
+ const sleep = opts.sleepImpl ?? defaultSleep;
152
+ const key = opts.idempotencyKey;
153
+ // Set once an attempt of a keyed write got no answer: the API may have done the work.
154
+ let mayHaveLanded = false;
155
+ // A retry that would sleep past the call's deadline is not started.
156
+ const fits = (waitMs) => opts.deadline === undefined || Date.now() + waitMs < opts.deadline;
157
+ for (let n = 0;; n++) {
158
+ const canRetry = retryOn !== undefined && n < MAX_RETRIES;
159
+ let res;
160
+ try {
161
+ res = await attempt(url.toString(), init, opts);
162
+ }
163
+ catch (err) {
164
+ const wait = backoffMs(n);
165
+ if (canRetry && fits(wait)) {
166
+ mayHaveLanded = true;
167
+ await sleep(wait);
168
+ continue;
169
+ }
170
+ if (key)
171
+ mayHaveLanded = true;
172
+ const failure = transient(new ToolkitError({
173
+ code: "BACKEND_UNAVAILABLE",
174
+ message: "Could not reach the Zenrows Batch API.",
175
+ likely_cause: err instanceof Error ? err.message : String(err),
176
+ next_action: "Check connectivity and retry. Override the host with ZENROWS_BATCH_API_BASE if you are testing against staging.",
177
+ suggested_commands: ["zenrows status"],
178
+ ...(opts.jobId ? { job_id: opts.jobId } : {}),
179
+ }));
180
+ throw key ? outcomeUnknown(failure, key) : failure;
181
+ }
182
+ if (canRetry && retryOn.has(res.status)) {
183
+ const wait = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(n);
184
+ if (fits(wait)) {
185
+ if (res.status >= 500)
186
+ mayHaveLanded = true;
187
+ await sleep(wait);
188
+ continue;
189
+ }
190
+ }
191
+ if (res.status < 200 || res.status >= 300) {
192
+ let failure = problemToError(res.status, res.body.toString("utf8"), method, path, opts.jobId);
193
+ if (res.status === 429 || res.status >= 500)
194
+ failure = transient(failure);
195
+ if (key && res.status >= 500)
196
+ throw outcomeUnknown(failure, key);
197
+ // An earlier attempt may have been accepted, and the API checks credit
198
+ // (402) and other gates before it looks at the key: whatever this attempt
199
+ // answers, the safe next step is still to repeat with the same key.
200
+ if (key && mayHaveLanded)
201
+ throw outcomeUnknown(failure, key, { keepAdvice: true });
202
+ throw failure;
203
+ }
204
+ return res;
85
205
  }
206
+ }
207
+ /** `batchFetch` + JSON parse. Returns `null` for an empty body. */
208
+ export async function batchRequest(method, path, opts) {
209
+ return batchJson(method, path, opts);
210
+ }
211
+ async function batchJson(method, path, opts) {
212
+ const { status, body } = await batchFetch(method, path, opts);
213
+ const text = body.toString("utf8");
86
214
  if (!text)
87
215
  return null;
88
216
  try {
89
217
  return JSON.parse(text);
90
218
  }
91
219
  catch {
92
- throw new ToolkitError({
220
+ const failure = new ToolkitError({
93
221
  code: "BATCH_FAILED",
94
- message: "The Batch API response was not valid JSON.",
222
+ message: `The Batch API answered ${method} ${path} (HTTP ${status}) with a body that is not JSON.`,
95
223
  likely_cause: text.slice(0, 240),
96
- next_action: "Retry, or inspect the job in the Zenrows dashboard.",
224
+ next_action: "Retry, or check the job with `zenrows batch list`.",
225
+ ...(opts.jobId ? { job_id: opts.jobId } : {}),
97
226
  });
227
+ throw opts.idempotencyKey ? outcomeUnknown(failure, opts.idempotencyKey) : failure;
98
228
  }
99
229
  }
100
- /** Map an RFC 7807 problem body + HTTP status to a normalized ToolkitError. */
101
- function problemToError(status, body, method, path) {
230
+ /**
231
+ * A keyed write (submit or rerun) whose outcome is unknown: the API may have
232
+ * done the work although no usable answer arrived. Carry the key and say what
233
+ * is safe; the command layer adds the exact command to repeat.
234
+ */
235
+ function outcomeUnknown(err, key, opts = {}) {
236
+ const lead = opts.keepAdvice
237
+ ? `${err.next_action.replace(/[.\s]*$/, ".")} An earlier attempt of this request got no answer and may have been accepted. Once that is resolved, repeat it with the same Idempotency-Key (${key}) `
238
+ : `The request may have been accepted even though no usable answer arrived. Repeat it with the same Idempotency-Key (${key}) `;
239
+ const out = new ToolkitError({
240
+ ...err.toJSON(),
241
+ idempotency_key: key,
242
+ next_action: lead +
243
+ "within 24 hours: the API then answers with the original job or run instead of creating a second one. " +
244
+ "Do not repeat it with a new key, which can create a duplicate. Check `zenrows batch list` first.",
245
+ });
246
+ return isTransient(err) ? transient(out) : out;
247
+ }
248
+ /** `s` without a trailing period, so sentences can be joined. */
249
+ function trimDot(s) {
250
+ return s.replace(/[.\s]+$/, "");
251
+ }
252
+ /** The error for an account whose subscription is not active (401 subscription_inactive, 402 expired). */
253
+ export function subscriptionInactive(cause) {
254
+ // Same AUTH_INVALID code as any 401 so callers that branch on it keep
255
+ // working; only the guidance changes, since re-entering the key won't help.
256
+ return new ToolkitError({
257
+ code: "AUTH_INVALID",
258
+ message: "Zenrows refused the Batch API request because the subscription is not active.",
259
+ likely_cause: `${trimDot(cause)}. The API key is valid, but the subscription is expired, past due, unpaid, incomplete or paused.`,
260
+ next_action: `Fix the subscription in Billing (${BILLING_URL}), or in Azure Marketplace if you subscribed there, then retry.`,
261
+ });
262
+ }
263
+ /** Map a problem+json body + HTTP status to a normalized ToolkitError carrying `server_code` and `job_id`. */
264
+ function problemToError(status, text, method, path, jobId) {
102
265
  let problem = {};
103
266
  try {
104
- problem = JSON.parse(body);
267
+ problem = JSON.parse(text);
105
268
  }
106
269
  catch {
107
- // non-JSON error body — fall through with an empty problem
270
+ // non-JSON error body: fall through with an empty problem
108
271
  }
109
- const serverCode = problem.code ?? "";
110
- const detail = problem.detail || problem.title || body.slice(0, 240) || `HTTP ${status}`;
111
- const cause = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${method} ${path}: ${detail}`;
272
+ const serverCode = problem.code || undefined;
273
+ const detail = trimDot(problem.detail || problem.title || text.slice(0, 240) || `HTTP ${status}`);
274
+ const cause = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${method} ${path}: ${detail}.`;
275
+ const err = classifyProblem(status, problem, detail, cause, serverCode, method, path, jobId ?? "<id>");
276
+ return new ToolkitError({
277
+ ...err.toJSON(),
278
+ ...(serverCode ? { server_code: serverCode } : {}),
279
+ ...(jobId ? { job_id: jobId } : {}),
280
+ });
281
+ }
282
+ function classifyProblem(status, problem, detail, cause, serverCode, method, path, id) {
283
+ const err = (code, message, next_action, suggested_commands, likely = cause) => new ToolkitError({ code, message, likely_cause: likely, next_action, suggested_commands });
112
284
  if (status === 403) {
113
- return new ToolkitError({
114
- code: "BATCH_ACCESS_DENIED",
115
- message: "The Batch API rejected this request (access denied).",
116
- likely_cause: `${cause}. Batch is not enabled for this account or API key.`,
117
- next_action: "Check that your Zenrows API key is active, and contact Zenrows support if the 403 persists. Meanwhile validate/estimate specs locally and fan out with `zenrows fetch` per URL.",
118
- suggested_commands: ["zenrows batch estimate jobs.jsonl"],
119
- });
120
- }
121
- if (status === 401 && serverCode === "subscription_inactive") {
122
- // The key is fine; the subscription needs a billing action. Same
123
- // AUTH_INVALID code as any 401 so callers that branch on it keep
124
- // working; only the guidance changes, since re-entering the key won't help.
125
- return new ToolkitError({
126
- code: "AUTH_INVALID",
127
- message: "Zenrows refused the Batch API request because the subscription is not active.",
128
- likely_cause: `${cause.replace(/\.?$/, ".")} The API key is valid, but the subscription is past due, unpaid, incomplete or paused.`,
129
- next_action: `Fix the subscription in Billing (${BILLING_URL}), or in Azure Marketplace if you subscribed there, then retry.`,
130
- });
285
+ return err("BATCH_ACCESS_DENIED", "The Batch API rejected this request (access denied).", "Check that your Zenrows API key is active, and contact Zenrows support if the 403 persists. Meanwhile validate/estimate specs locally and fan out with `zenrows fetch` per URL.", ["zenrows batch estimate jobs.jsonl"], `${cause} Batch is not enabled for this account or API key.`);
131
286
  }
287
+ if (status === 401 && serverCode === "subscription_inactive")
288
+ return subscriptionInactive(cause);
132
289
  if (status === 401) {
133
- return new ToolkitError({
134
- code: "AUTH_INVALID",
135
- message: "Zenrows rejected the API key for the Batch API.",
136
- likely_cause: cause,
137
- next_action: "Re-check your key and log in again.",
138
- suggested_commands: ["zenrows login --api-key <your-key>"],
139
- });
290
+ return err("AUTH_INVALID", "Zenrows rejected the API key for the Batch API.", "Re-check your key and log in again.", [
291
+ "zenrows login --api-key <your-key>",
292
+ ]);
140
293
  }
141
294
  if (status === 404) {
142
- return new ToolkitError({
143
- code: "BATCH_NOT_FOUND",
144
- message: "Batch job, run, or task not found.",
145
- likely_cause: `${cause}. The id may be wrong or the job is not owned by this account.`,
146
- next_action: "Check the job id, or list your recent jobs in the Zenrows dashboard.",
147
- });
295
+ const task = /\/tasks\//.test(path);
296
+ return err("BATCH_NOT_FOUND", task ? "Batch job, run or task not found." : "Batch job or run not found.", task
297
+ ? `Check the ids. List the job's task ids with \`zenrows batch results ${id}\`.`
298
+ : "Check the job id, or list your jobs with `zenrows batch list`.", [task ? `zenrows batch results ${id}` : "zenrows batch list"], `${cause} The id may be wrong, the job deleted, or not owned by this account.`);
148
299
  }
149
300
  if (status === 429) {
150
- return new ToolkitError({
151
- code: "BATCH_QUOTA_EXCEEDED",
152
- message: "Batch quota exceeded.",
153
- likely_cause: `${cause}. An account limit was reached (e.g. the maximum of 3 concurrent active jobs).`,
154
- next_action: "Wait for an in-flight job to finish (or stop one with `zenrows batch cancel <id>`), then retry.",
155
- suggested_commands: ["zenrows batch status <id>"],
156
- });
301
+ if (/scheduled job/i.test(detail)) {
302
+ return err("BATCH_QUOTA_EXCEEDED", "The account has reached its limit of scheduled jobs.", "Nothing was created. A scheduled job holds its slot until it is deleted, whatever its runs do: delete one you no longer need, then retry.", ["zenrows batch list --type scheduled", "zenrows batch delete <id> --yes"], `${cause} The account holds 5 scheduled jobs at most by default.`);
303
+ }
304
+ if (/task cap/i.test(detail)) {
305
+ return err("BATCH_QUOTA_EXCEEDED", "The open job has reached its task cap.", "Nothing was added. An open job holds 100,000 tasks at most: close it and start a new job for the rest.");
306
+ }
307
+ return err("BATCH_QUOTA_EXCEEDED", "The account has reached its limit of active jobs.", "Nothing was created. Active jobs are shared with Crawl. A closed job frees its slot when its run ends: wait for one (`zenrows batch wait <id>`) or stop it (`zenrows batch cancel <id>`). An open or scheduled job frees it only when deleted (`zenrows batch delete <id> --yes`). Then retry.", ["zenrows batch list", "zenrows crawl list"], `${cause} The account holds 3 active jobs at most by default, Batch and Crawl together.`);
157
308
  }
158
309
  if (status === 402 && isKeyCapReached(serverCode)) {
159
310
  return keyCapReached(`${method} ${path}`, { status: 402, detail: problem.detail || problem.title || undefined });
160
311
  }
312
+ if (status === 402 && /subscription has expired/i.test(detail))
313
+ return subscriptionInactive(cause);
161
314
  if (status === 402) {
162
- // Out of credits ("Subscription has no credit available") — the same
163
- // exhausted state as the scraper/usage 402. Surface the credits error with
164
- // a claim link (unclaimed Free plan) or dashboard/upgrade pointer, not a
165
- // generic "fix the problem and retry".
315
+ // Out of credits ("Subscription has no credit available"): the same
316
+ // exhausted state as the scraper/usage 402, with a claim link (unclaimed
317
+ // Free plan) or dashboard/upgrade pointer.
166
318
  const acct = readAccount();
167
319
  return quotaExhausted(`${method} ${path}`, storedClaimHint(acct), {
168
320
  status: 402,
169
321
  detail: problem.detail || problem.title || undefined,
170
322
  });
171
323
  }
172
- // 400 invalid_argument, 409 conflict, 5xx, etc.
324
+ if (status === 409)
325
+ return conflictError(serverCode, detail, cause, id);
326
+ if (status === 410) {
327
+ return err("BATCH_RESULT_EXPIRED", "This result has expired.", `Results are kept 14 days by default; after that a task reads as expired and its body is gone. Scrape it again with a full rerun: \`zenrows batch rerun ${id} --all\`.`, [`zenrows batch rerun ${id} --all`]);
328
+ }
329
+ if (status === 422 && /\/content$/.test(path)) {
330
+ return err("BATCH_TASK_FAILED", "This task failed, so it has no content.", `Read why in its result row (\`zenrows batch results ${id} --status failed\`), then rerun the failures with \`zenrows batch retry-failed ${id}\`.`, [`zenrows batch results ${id} --status failed`]);
331
+ }
332
+ // 400 invalid_argument, 501, 5xx, etc.
173
333
  const invalid = problem.invalid_tasks?.length
174
334
  ? ` invalid_tasks: ${problem.invalid_tasks
175
335
  .slice(0, 10)
176
- .map((t) => `#${t.index}: ${t.reason}`)
336
+ .map((t) => `#${t.index}: ${t.reason}${t.value ? ` (${t.value})` : ""}`)
177
337
  .join("; ")}`
178
338
  : "";
179
- return new ToolkitError({
180
- code: "BATCH_FAILED",
181
- message: `Batch request failed (HTTP ${status}).`,
182
- likely_cause: `${cause}.${invalid}`,
183
- next_action: status >= 500
184
- ? "This is transient — retry with a short backoff."
185
- : "Fix the reported problem and retry. For validation errors, correct the flagged tasks.",
186
- });
339
+ return err("BATCH_FAILED", `Batch request failed (HTTP ${status}).`, status === 501
340
+ ? "This operation is not available for this job."
341
+ : status >= 500
342
+ ? "This is transient: retry with a short backoff."
343
+ : "Fix the reported problem and retry. For validation errors, correct the flagged tasks (#n is the task's 0-based position in the submit).", undefined, `${cause}${invalid}`);
187
344
  }
188
- /** Create a job. Accepts both 201 (<10k tasks) and 202 (≥10k) as success. */
345
+ /** One code and next action per 409 the Batch API answers. */
346
+ function conflictError(serverCode, detail, cause, id) {
347
+ const e = (code, message, next_action, suggested_commands = [`zenrows batch status ${id}`]) => new ToolkitError({ code, message, likely_cause: cause, next_action, suggested_commands });
348
+ switch (serverCode) {
349
+ case "run_not_terminal":
350
+ if (/ingestion did not complete/i.test(detail)) {
351
+ return e("BATCH_RUN_IN_PROGRESS", "The job's last run never finished receiving its tasks.", `It cannot be rerun. Delete the job (\`zenrows batch delete ${id} --yes\`) and submit the URLs again as a new job.`);
352
+ }
353
+ return e("BATCH_RUN_IN_PROGRESS", "The job's current run is still in progress.", `Wait for it with \`zenrows batch wait ${id}\`, or stop it with \`zenrows batch cancel ${id}\`, then retry. A large submit or a rerun also reads as in progress while it is still receiving its tasks.`, [`zenrows batch wait ${id}`]);
354
+ case "run_not_stoppable":
355
+ return e("BATCH_RUN_ENDED", "The job's run already ended.", `Only a pending or running run can be stopped, paused or resumed. Start a new run with \`zenrows batch rerun ${id}\`.`);
356
+ case "no_matching_tasks":
357
+ return e("BATCH_NO_MATCHING_TASKS", "No task of the last run has the statuses you asked to rerun.", `Tasks a stop or a credit failure left unfinished are pending or processing: rerun them with \`zenrows batch rerun ${id} --status failed,pending,processing\`, or rerun every task with \`zenrows batch rerun ${id} --all\`.`, [`zenrows batch rerun ${id} --status failed,pending,processing`]);
358
+ case "partial_rerun_window_expired":
359
+ return e("BATCH_RERUN_WINDOW_EXPIRED", "Rerunning only some tasks is no longer allowed for this run.", `A partial rerun (the default, or --status) works for 10 days after the run started. Rerun every task with \`zenrows batch rerun ${id} --all\` instead.`, [`zenrows batch rerun ${id} --all`]);
360
+ case "idempotency_key_conflict":
361
+ return e("BATCH_REQUEST_IN_FLIGHT", "This Idempotency-Key is already in use.", "Either the first request with this key is still being processed, or the key was used for a different request body. Wait a moment and repeat the same command with the same key; use a new key only for a deliberately different request.", ["zenrows batch list"]);
362
+ case "task_not_ready":
363
+ return e("BATCH_TASK_NOT_READY", "This task has not finished yet.", `Wait for the run with \`zenrows batch wait ${id}\`, then read the task again.`, [`zenrows batch wait ${id}`]);
364
+ case "conflict":
365
+ if (/no longer valid/i.test(detail)) {
366
+ return e("BATCH_CONFLICT", "The API key that created this job no longer works.", "The job cannot run again. Submit its URLs as a new job.", []);
367
+ }
368
+ return e("BATCH_CONFLICT", "The job changed while the request ran.", "Retry the same command.");
369
+ default:
370
+ return e("BATCH_CONFLICT", "The job's state does not allow this request.", `Check the job with \`zenrows batch status ${id}\`; the cause above says what blocked it.`);
371
+ }
372
+ }
373
+ const jobPath = (id, rest = "") => `/jobs/${encodeURIComponent(id)}${rest}`;
374
+ /**
375
+ * Create a job. Both 201 (tasks stored) and 202 (a large submit, still
376
+ * receiving its tasks: `latest_run.ingest_status` is `pending`) are success.
377
+ */
189
378
  export function createJob(body, opts) {
190
- return batchRequest("POST", "/jobs", { apiKey: opts.apiKey, body, fetchImpl: opts.fetchImpl, timeoutMs: opts.timeoutMs });
379
+ const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
380
+ assertIdempotencyKey(idempotencyKey);
381
+ return batchJson("POST", "/jobs", { ...opts, body, idempotencyKey, retry: "write" });
191
382
  }
192
383
  /** Read a job (`latest_run.status` + `latest_run.stats`). */
193
384
  export function getJob(id, opts) {
194
- return batchRequest("GET", `/jobs/${encodeURIComponent(id)}`, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl, timeoutMs: opts.timeoutMs });
385
+ return batchJson("GET", jobPath(id), { ...opts, retry: "read", jobId: id });
386
+ }
387
+ /** Read one run of a job. */
388
+ export function getRun(id, runId, opts) {
389
+ return batchJson("GET", jobPath(id, `/runs/${encodeURIComponent(runId)}`), { ...opts, retry: "read", jobId: id });
195
390
  }
196
- /** Abort an in-flight run. */
391
+ /** One page of the account's jobs, newest first. */
392
+ export async function listJobs(opts) {
393
+ const page = await batchJson("GET", "/jobs", {
394
+ ...opts,
395
+ retry: "read",
396
+ query: { limit: opts.limit, cursor: opts.cursor, status: opts.status, type: opts.type },
397
+ });
398
+ return { jobs: page?.jobs ?? [], next_cursor: page?.next_cursor || null };
399
+ }
400
+ /** Stop the job's in-flight run. The API answers with the refreshed run, not the job. */
197
401
  export function stopJob(id, opts) {
198
- return batchRequest("POST", `/jobs/${encodeURIComponent(id)}/stop`, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl, timeoutMs: opts.timeoutMs });
402
+ return batchJson("POST", jobPath(id, "/stop"), { ...opts, retry: "write", jobId: id });
403
+ }
404
+ /** Pause the job's in-flight run: no new task is picked up until it is resumed. Answers with the run. */
405
+ export function pauseJob(id, opts) {
406
+ return batchJson("POST", jobPath(id, "/pause"), { ...opts, retry: "write", jobId: id });
407
+ }
408
+ /** Resume a paused run. Needs credit on the job's API key. Answers with the run. */
409
+ export function resumeJob(id, opts) {
410
+ return batchJson("POST", jobPath(id, "/resume"), { ...opts, retry: "write", jobId: id });
199
411
  }
200
412
  /**
201
- * Replay a job as a new run. `status="failed"` re-runs only the failures
202
- * (append `,pending` to also include tasks that never started). Already-
203
- * successful tasks carry over, so you only re-scrape what failed.
413
+ * Delete a job, its runs and its stored results (202, asynchronous). Stops an
414
+ * in-flight run and frees the job's slot. Repeating it is harmless.
415
+ */
416
+ export async function deleteJob(id, opts) {
417
+ await batchFetch("DELETE", jobPath(id), { ...opts, retry: "read", jobId: id });
418
+ }
419
+ /**
420
+ * Start a new run of a job. With no `status`, every task runs again (a full
421
+ * rerun). `status` is a comma-separated list of task statuses to rerun
422
+ * (`pending,processing,failed,successful`); the rest carry over from the
423
+ * previous run. After a stop or a credit failure, unstarted tasks stay
424
+ * `pending` and tasks cut off mid-flight can stay `processing`, so
425
+ * `failed,pending,processing` recovers everything that did not succeed.
204
426
  */
205
427
  export function rerunJob(id, opts) {
206
- return batchRequest("POST", `/jobs/${encodeURIComponent(id)}/rerun`, {
207
- apiKey: opts.apiKey,
428
+ const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
429
+ assertIdempotencyKey(idempotencyKey);
430
+ return batchJson("POST", jobPath(id, "/rerun"), {
431
+ ...opts,
208
432
  query: opts.status ? { status: opts.status } : undefined,
209
- fetchImpl: opts.fetchImpl,
210
- timeoutMs: opts.timeoutMs,
433
+ idempotencyKey,
434
+ retry: "write",
435
+ jobId: id,
211
436
  });
212
437
  }
213
- /** List all result rows for a run, paginating over the `cursor` until `next_cursor` is null. */
438
+ /**
439
+ * List all result rows of the latest run (or `runId`), following `next_cursor`
440
+ * until it is null.
441
+ */
214
442
  export async function listResults(id, opts) {
443
+ const path = opts.runId ? jobPath(id, `/runs/${encodeURIComponent(opts.runId)}/results`) : jobPath(id, "/results");
215
444
  const all = [];
216
445
  let cursor;
217
446
  do {
218
- const page = await batchRequest("GET", `/jobs/${encodeURIComponent(id)}/results`, {
219
- apiKey: opts.apiKey,
447
+ const page = await batchJson("GET", path, {
448
+ ...opts,
449
+ retry: "read",
450
+ jobId: id,
220
451
  query: { status: opts.status, cursor },
221
- fetchImpl: opts.fetchImpl,
222
- timeoutMs: opts.timeoutMs,
223
452
  });
224
453
  if (page?.results)
225
454
  all.push(...page.results);
@@ -227,6 +456,13 @@ export async function listResults(id, opts) {
227
456
  } while (cursor);
228
457
  return all;
229
458
  }
459
+ /** One task's result body (latest run, or `runId`), as returned by the content endpoint. */
460
+ export async function getTaskContent(id, taskId, opts) {
461
+ const task = `/tasks/${encodeURIComponent(taskId)}/content`;
462
+ const path = opts.runId ? jobPath(id, `/runs/${encodeURIComponent(opts.runId)}${task}`) : jobPath(id, task);
463
+ const res = await batchFetch("GET", path, { ...opts, retry: "read", jobId: id });
464
+ return { contentType: res.headers.get("content-type") ?? "", body: res.body };
465
+ }
230
466
  /** Map a result `type` to a file extension for downloaded bodies. */
231
467
  const EXT_BY_TYPE = {
232
468
  json: "json",
@@ -253,15 +489,25 @@ function uniqueName(used, base, ext) {
253
489
  used.add(name);
254
490
  return name;
255
491
  }
492
+ /** Why a row has no body to download. */
493
+ function skipReason(row) {
494
+ if (row.status === "failed")
495
+ return "task failed: no result body";
496
+ if (row.status === "expired")
497
+ return "result expired (kept 14 days by default): a full rerun (--all) scrapes it again";
498
+ if (row.status === "pending" || row.status === "processing")
499
+ return "task not finished yet";
500
+ return "no result_url";
501
+ }
256
502
  /**
257
503
  * Stream each result body into its own file under `dir` (created if needed),
258
- * plus a self-describing `_manifest.jsonl` index (result_url dropped — it is a
504
+ * plus a self-describing `_manifest.jsonl` index (result_url dropped: it is a
259
505
  * short-lived presigned link). Fetches run `concurrency` at a time and a single
260
506
  * failure never aborts the rest: per-task outcomes come back in the result.
261
507
  *
262
- * Only tasks with a `result_url` have a body to fetch; failed tasks carry an
263
- * `error` instead and are reported as `skipped`. Because the presigned links
264
- * expire, download soon after `listResults`. Deliberately opt-in (not the
508
+ * Only tasks with a `result_url` have a body to fetch; failed, expired and
509
+ * unfinished tasks are reported as `skipped`. The presigned links are valid for
510
+ * 2 hours, so download soon after `listResults`. Deliberately opt-in (not the
265
511
  * `results` default): a large job's bodies can be many GB, and the URL manifest
266
512
  * is the right primitive to pull selectively.
267
513
  */
@@ -282,10 +528,7 @@ export async function downloadResults(rows, dir, opts = {}) {
282
528
  const { row, file } = planned[next++];
283
529
  const ref = { task_id: row.task_id, external_id: row.external_id };
284
530
  if (!file) {
285
- outcome.skipped.push({
286
- ...ref,
287
- reason: row.status === "failed" ? "task failed — no result body" : "no result_url",
288
- });
531
+ outcome.skipped.push({ ...ref, reason: skipReason(row) });
289
532
  continue;
290
533
  }
291
534
  try {
@@ -306,33 +549,43 @@ export async function downloadResults(rows, dir, opts = {}) {
306
549
  const fileByTask = new Map(outcome.downloaded.map((d) => [d.task_id, d.file]));
307
550
  const manifest = rows.map((r) => {
308
551
  const rest = { ...r };
309
- delete rest.result_url; // expires — the local `file` is the durable pointer
552
+ delete rest.result_url; // expires: the local `file` is the durable pointer
310
553
  return { ...rest, file: fileByTask.get(r.task_id) ?? null };
311
554
  });
312
555
  writeFileSync(join(dir, "_manifest.jsonl"), manifest.map((m) => JSON.stringify(m)).join("\n") + (manifest.length ? "\n" : ""));
313
556
  return outcome;
314
557
  }
315
558
  /**
316
- * Poll `GET /jobs/{id}` until `latest_run.status` is terminal, backing off
317
- * 2s → ×1.5 → capped at 15s. Throws BATCH_FAILED on timeout. `sleepImpl` is
318
- * injectable for tests.
559
+ * Poll `GET /jobs/{id}` until the latest run is terminal, the run is paused (a
560
+ * paused run never progresses, so the wait returns at once), or `waitMs`
561
+ * (default 10 minutes) runs out, backing off 2s, x1.5, capped at 15s. Returns
562
+ * the last job read in every case; the caller tells them apart with
563
+ * `isTerminal` / `isPaused`. A transient error (connection, 429, 5xx) is
564
+ * ridden out until the deadline. `sleepImpl` is injectable for tests.
319
565
  */
320
566
  export async function waitForJob(id, opts) {
321
- const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
322
- const deadline = Date.now() + (opts.timeoutMs ?? 600_000);
567
+ const sleep = opts.sleepImpl ?? defaultSleep;
568
+ const deadline = Date.now() + (opts.waitMs ?? 600_000);
323
569
  let delay = 2000;
570
+ const read = { ...opts, deadline };
571
+ let last;
572
+ let lastErr;
324
573
  for (;;) {
325
- const job = await getJob(id, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl });
326
- if (job.latest_run && TERMINAL_STATUSES.has(job.latest_run.status))
327
- return job;
328
- if (Date.now() > deadline) {
329
- throw new ToolkitError({
330
- code: "BATCH_FAILED",
331
- message: `Timed out waiting for batch job ${id} to finish.`,
332
- likely_cause: `The run did not reach a terminal state within ${Math.round((opts.timeoutMs ?? 600_000) / 1000)}s.`,
333
- next_action: "Re-check progress with `zenrows batch status <id>`, or raise --timeout.",
334
- suggested_commands: [`zenrows batch status ${id}`],
335
- });
574
+ try {
575
+ last = await getJob(id, read);
576
+ if (isTerminal(last.latest_run) || isPaused(last.latest_run))
577
+ return last;
578
+ }
579
+ catch (err) {
580
+ if (!isTransient(err))
581
+ throw err;
582
+ lastErr = err;
583
+ }
584
+ if (Date.now() + delay > deadline) {
585
+ // Out of time: report the last state read, or the error if none was.
586
+ if (!last)
587
+ throw lastErr;
588
+ return last;
336
589
  }
337
590
  await sleep(delay);
338
591
  delay = Math.min(delay * 1.5, 15_000);