@zenrows/cli 1.6.2 → 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,19 +4,29 @@
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";
29
+ import { storedClaimHint } from "./claim-link.js";
20
30
  import { readAccount } from "./agent-account.js";
21
31
  import { registerSecret } from "./logger.js";
22
32
  import { CLI_USER_AGENT } from "./config.js";
@@ -32,193 +42,413 @@ export function batchBase() {
32
42
  const base = env && env.trim() ? env.trim() : DEFAULT_BATCH_API_BASE;
33
43
  return base.replace(/\/+$/, "");
34
44
  }
35
- /** 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. */
36
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;
37
78
  /**
38
- * Perform a Batch API request. Sets `X-API-Key`, adds a JSON content-type when a
39
- * body is present, parses the JSON response, and on a non-2xx parses the
40
- * problem+json body into a normalized ToolkitError. Returns `null` for empty
41
- * (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.
42
82
  */
43
- 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) {
44
133
  registerSecret(opts.apiKey);
45
134
  const url = new URL(batchBase() + path);
46
135
  for (const [k, v] of Object.entries(opts.query ?? {})) {
47
136
  if (v !== undefined && v !== null)
48
- url.searchParams.set(k, v);
137
+ url.searchParams.set(k, String(v));
49
138
  }
50
- const doFetch = opts.fetchImpl ?? fetch;
51
- const controller = new AbortController();
52
- const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
53
139
  const headers = {
54
140
  "X-API-Key": opts.apiKey,
55
141
  Accept: "application/json",
56
142
  "User-Agent": CLI_USER_AGENT,
57
143
  ...agentClientHeader(),
58
144
  };
145
+ if (opts.idempotencyKey)
146
+ headers["Idempotency-Key"] = opts.idempotencyKey;
59
147
  if (opts.body !== undefined)
60
148
  headers["Content-Type"] = "application/json";
61
- let res;
62
- try {
63
- res = await doFetch(url.toString(), {
64
- method,
65
- headers,
66
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
67
- signal: controller.signal,
68
- });
69
- }
70
- catch (err) {
71
- clearTimeout(timeout);
72
- throw new ToolkitError({
73
- code: "BACKEND_UNAVAILABLE",
74
- message: "Could not reach the Zenrows Batch API.",
75
- likely_cause: err instanceof Error ? err.message : String(err),
76
- next_action: "Check connectivity and retry. Override the host with ZENROWS_BATCH_API_BASE if you are testing against staging.",
77
- suggested_commands: ["zenrows status"],
78
- });
79
- }
80
- clearTimeout(timeout);
81
- const text = await res.text();
82
- if (res.status < 200 || res.status >= 300) {
83
- 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;
84
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");
85
214
  if (!text)
86
215
  return null;
87
216
  try {
88
217
  return JSON.parse(text);
89
218
  }
90
219
  catch {
91
- throw new ToolkitError({
220
+ const failure = new ToolkitError({
92
221
  code: "BATCH_FAILED",
93
- 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.`,
94
223
  likely_cause: text.slice(0, 240),
95
- 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 } : {}),
96
226
  });
227
+ throw opts.idempotencyKey ? outcomeUnknown(failure, opts.idempotencyKey) : failure;
97
228
  }
98
229
  }
99
- /** Map an RFC 7807 problem body + HTTP status to a normalized ToolkitError. */
100
- 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) {
101
265
  let problem = {};
102
266
  try {
103
- problem = JSON.parse(body);
267
+ problem = JSON.parse(text);
104
268
  }
105
269
  catch {
106
- // non-JSON error body — fall through with an empty problem
270
+ // non-JSON error body: fall through with an empty problem
107
271
  }
108
- const serverCode = problem.code ?? "";
109
- const detail = problem.detail || problem.title || body.slice(0, 240) || `HTTP ${status}`;
110
- 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 });
111
284
  if (status === 403) {
112
- return new ToolkitError({
113
- code: "BATCH_ACCESS_DENIED",
114
- message: "The Batch API rejected this request (access denied).",
115
- likely_cause: `${cause}. Batch is not enabled for this account or API key.`,
116
- 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.",
117
- suggested_commands: ["zenrows batch estimate jobs.jsonl"],
118
- });
119
- }
120
- if (status === 401 && serverCode === "subscription_inactive") {
121
- // The key is fine; the subscription needs a billing action. Same
122
- // AUTH_INVALID code as any 401 so callers that branch on it keep
123
- // working; only the guidance changes, since re-entering the key won't help.
124
- return new ToolkitError({
125
- code: "AUTH_INVALID",
126
- message: "Zenrows refused the Batch API request because the subscription is not active.",
127
- likely_cause: `${cause.replace(/\.?$/, ".")} The API key is valid, but the subscription is past due, unpaid, incomplete or paused.`,
128
- next_action: `Fix the subscription in Billing (${BILLING_URL}), or in Azure Marketplace if you subscribed there, then retry.`,
129
- });
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.`);
130
286
  }
287
+ if (status === 401 && serverCode === "subscription_inactive")
288
+ return subscriptionInactive(cause);
131
289
  if (status === 401) {
132
- return new ToolkitError({
133
- code: "AUTH_INVALID",
134
- message: "Zenrows rejected the API key for the Batch API.",
135
- likely_cause: cause,
136
- next_action: "Re-check your key and log in again.",
137
- suggested_commands: ["zenrows login --api-key <your-key>"],
138
- });
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
+ ]);
139
293
  }
140
294
  if (status === 404) {
141
- return new ToolkitError({
142
- code: "BATCH_NOT_FOUND",
143
- message: "Batch job, run, or task not found.",
144
- likely_cause: `${cause}. The id may be wrong or the job is not owned by this account.`,
145
- next_action: "Check the job id, or list your recent jobs in the Zenrows dashboard.",
146
- });
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.`);
147
299
  }
148
300
  if (status === 429) {
149
- return new ToolkitError({
150
- code: "BATCH_QUOTA_EXCEEDED",
151
- message: "Batch quota exceeded.",
152
- likely_cause: `${cause}. An account limit was reached (e.g. the maximum of 3 concurrent active jobs).`,
153
- next_action: "Wait for an in-flight job to finish (or stop one with `zenrows batch cancel <id>`), then retry.",
154
- suggested_commands: ["zenrows batch status <id>"],
155
- });
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.`);
156
308
  }
157
309
  if (status === 402 && isKeyCapReached(serverCode)) {
158
310
  return keyCapReached(`${method} ${path}`, { status: 402, detail: problem.detail || problem.title || undefined });
159
311
  }
312
+ if (status === 402 && /subscription has expired/i.test(detail))
313
+ return subscriptionInactive(cause);
160
314
  if (status === 402) {
161
- // Out of credits ("Subscription has no credit available") — the same
162
- // exhausted state as the scraper/usage 402. Surface the credits error with
163
- // a claim link (unclaimed Free plan) or dashboard/upgrade pointer, not a
164
- // 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.
165
318
  const acct = readAccount();
166
- return quotaExhausted(`${method} ${path}`, acct?.unclaimed ? acct.claimUrl : undefined, {
319
+ return quotaExhausted(`${method} ${path}`, storedClaimHint(acct), {
167
320
  status: 402,
168
321
  detail: problem.detail || problem.title || undefined,
169
322
  });
170
323
  }
171
- // 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.
172
333
  const invalid = problem.invalid_tasks?.length
173
334
  ? ` invalid_tasks: ${problem.invalid_tasks
174
335
  .slice(0, 10)
175
- .map((t) => `#${t.index}: ${t.reason}`)
336
+ .map((t) => `#${t.index}: ${t.reason}${t.value ? ` (${t.value})` : ""}`)
176
337
  .join("; ")}`
177
338
  : "";
178
- return new ToolkitError({
179
- code: "BATCH_FAILED",
180
- message: `Batch request failed (HTTP ${status}).`,
181
- likely_cause: `${cause}.${invalid}`,
182
- next_action: status >= 500
183
- ? "This is transient — retry with a short backoff."
184
- : "Fix the reported problem and retry. For validation errors, correct the flagged tasks.",
185
- });
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}`);
186
344
  }
187
- /** 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
+ */
188
378
  export function createJob(body, opts) {
189
- 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" });
190
382
  }
191
383
  /** Read a job (`latest_run.status` + `latest_run.stats`). */
192
384
  export function getJob(id, opts) {
193
- 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 });
194
390
  }
195
- /** 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. */
196
401
  export function stopJob(id, opts) {
197
- 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 });
198
411
  }
199
412
  /**
200
- * Replay a job as a new run. `status="failed"` re-runs only the failures
201
- * (append `,pending` to also include tasks that never started). Already-
202
- * 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.
203
426
  */
204
427
  export function rerunJob(id, opts) {
205
- return batchRequest("POST", `/jobs/${encodeURIComponent(id)}/rerun`, {
206
- apiKey: opts.apiKey,
428
+ const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
429
+ assertIdempotencyKey(idempotencyKey);
430
+ return batchJson("POST", jobPath(id, "/rerun"), {
431
+ ...opts,
207
432
  query: opts.status ? { status: opts.status } : undefined,
208
- fetchImpl: opts.fetchImpl,
209
- timeoutMs: opts.timeoutMs,
433
+ idempotencyKey,
434
+ retry: "write",
435
+ jobId: id,
210
436
  });
211
437
  }
212
- /** 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
+ */
213
442
  export async function listResults(id, opts) {
443
+ const path = opts.runId ? jobPath(id, `/runs/${encodeURIComponent(opts.runId)}/results`) : jobPath(id, "/results");
214
444
  const all = [];
215
445
  let cursor;
216
446
  do {
217
- const page = await batchRequest("GET", `/jobs/${encodeURIComponent(id)}/results`, {
218
- apiKey: opts.apiKey,
447
+ const page = await batchJson("GET", path, {
448
+ ...opts,
449
+ retry: "read",
450
+ jobId: id,
219
451
  query: { status: opts.status, cursor },
220
- fetchImpl: opts.fetchImpl,
221
- timeoutMs: opts.timeoutMs,
222
452
  });
223
453
  if (page?.results)
224
454
  all.push(...page.results);
@@ -226,6 +456,13 @@ export async function listResults(id, opts) {
226
456
  } while (cursor);
227
457
  return all;
228
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
+ }
229
466
  /** Map a result `type` to a file extension for downloaded bodies. */
230
467
  const EXT_BY_TYPE = {
231
468
  json: "json",
@@ -252,15 +489,25 @@ function uniqueName(used, base, ext) {
252
489
  used.add(name);
253
490
  return name;
254
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
+ }
255
502
  /**
256
503
  * Stream each result body into its own file under `dir` (created if needed),
257
- * 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
258
505
  * short-lived presigned link). Fetches run `concurrency` at a time and a single
259
506
  * failure never aborts the rest: per-task outcomes come back in the result.
260
507
  *
261
- * Only tasks with a `result_url` have a body to fetch; failed tasks carry an
262
- * `error` instead and are reported as `skipped`. Because the presigned links
263
- * 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
264
511
  * `results` default): a large job's bodies can be many GB, and the URL manifest
265
512
  * is the right primitive to pull selectively.
266
513
  */
@@ -281,10 +528,7 @@ export async function downloadResults(rows, dir, opts = {}) {
281
528
  const { row, file } = planned[next++];
282
529
  const ref = { task_id: row.task_id, external_id: row.external_id };
283
530
  if (!file) {
284
- outcome.skipped.push({
285
- ...ref,
286
- reason: row.status === "failed" ? "task failed — no result body" : "no result_url",
287
- });
531
+ outcome.skipped.push({ ...ref, reason: skipReason(row) });
288
532
  continue;
289
533
  }
290
534
  try {
@@ -305,33 +549,43 @@ export async function downloadResults(rows, dir, opts = {}) {
305
549
  const fileByTask = new Map(outcome.downloaded.map((d) => [d.task_id, d.file]));
306
550
  const manifest = rows.map((r) => {
307
551
  const rest = { ...r };
308
- 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
309
553
  return { ...rest, file: fileByTask.get(r.task_id) ?? null };
310
554
  });
311
555
  writeFileSync(join(dir, "_manifest.jsonl"), manifest.map((m) => JSON.stringify(m)).join("\n") + (manifest.length ? "\n" : ""));
312
556
  return outcome;
313
557
  }
314
558
  /**
315
- * Poll `GET /jobs/{id}` until `latest_run.status` is terminal, backing off
316
- * 2s → ×1.5 → capped at 15s. Throws BATCH_FAILED on timeout. `sleepImpl` is
317
- * 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.
318
565
  */
319
566
  export async function waitForJob(id, opts) {
320
- const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
321
- const deadline = Date.now() + (opts.timeoutMs ?? 600_000);
567
+ const sleep = opts.sleepImpl ?? defaultSleep;
568
+ const deadline = Date.now() + (opts.waitMs ?? 600_000);
322
569
  let delay = 2000;
570
+ const read = { ...opts, deadline };
571
+ let last;
572
+ let lastErr;
323
573
  for (;;) {
324
- const job = await getJob(id, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl });
325
- if (job.latest_run && TERMINAL_STATUSES.has(job.latest_run.status))
326
- return job;
327
- if (Date.now() > deadline) {
328
- throw new ToolkitError({
329
- code: "BATCH_FAILED",
330
- message: `Timed out waiting for batch job ${id} to finish.`,
331
- likely_cause: `The run did not reach a terminal state within ${Math.round((opts.timeoutMs ?? 600_000) / 1000)}s.`,
332
- next_action: "Re-check progress with `zenrows batch status <id>`, or raise --timeout.",
333
- suggested_commands: [`zenrows batch status ${id}`],
334
- });
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;
335
589
  }
336
590
  await sleep(delay);
337
591
  delay = Math.min(delay * 1.5, 15_000);