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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions skills/batch-jobs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ zenrows batch retry-failed <id> # rerun only the failed tasks (new run)
`--premium-proxy` (or `mode=auto`) and is rejected before any request. Job-level
flags apply to every task; per-task keys in the JSONL override them.

A run ends in one of `completed`, `failed`, `stopped`, or `deleted`. `status`,
`wait`, and `create --wait` exit non-zero (`ok: false` under `--json`) only for
`failed`, and print the run's `failure_reason` and `failure_detail`. When
`failure_reason` is `api_key_cap_reached`, the key hit its own credit cap
(`KEY_CREDIT_CAP_REACHED`): the account still has credits, other keys keep
working, and the tasks not yet run stay pending. Wait for the cap to reset
(`zenrows usage`) or raise the cap; do not resubmit the job with the same key.

Deferred (documented, not yet in the CLI): CSV upload, open/queue jobs,
scheduled jobs, webhooks/HMAC, and ZIP export.

Expand Down
12 changes: 12 additions & 0 deletions skills/trace-debug/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ zenrows trace export <run-id> # JSON for sharing
- `BACKEND_UNAVAILABLE` → a genuine transport failure (DNS/TCP/TLS). Check
connectivity and `zenrows config show`.
- `AUTH_INVALID` → re-check the key, `zenrows login --api-key …`.
- `KEY_CREDIT_CAP_REACHED` → this API key hit one of its own credit caps (HTTP
402, gateway code `AUTH014`, Batch `api_key_cap_reached`). The account still
has credits and its other keys keep working. Do not retry and do not escalate:
`zenrows usage` shows the cap and when it resets. Wait for the reset, or have
the account owner raise or remove the cap at
https://app.zenrows.com/settings/api-keys.
- `POLICY_MAX_CREDITS_EXCEEDED` → the account itself is out of credits (or a
local policy credit limit was hit). Do not retry-loop; `zenrows usage` shows
when credits renew. Top up or upgrade, or claim the account if it is an
auto-created Free plan.
- `BATCH_FAILED` → the batch run ended `failed`; `failure_reason` and
`failure_detail` in `zenrows batch status <id> --json` say why.
- `PARAM_CONFLICT_AUTO_MANUAL` → drop the managed flags or add `--manual`.
- `CAPABILITY_UNAVAILABLE` → the primitive is not available on this account (e.g. beta/invite-only); use the local-spec path where offered.

Expand Down
85 changes: 67 additions & 18 deletions src/cli/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { assertDomainAllowed, assertWithinLimits, loadPolicy } from "../../core/
import { newRunId, writeRun } from "../../core/artifacts.ts";
import { createJob, downloadResults, getJob, listResults, rerunJob, stopJob, waitForJob, type Job } from "../../core/batch-api.ts";
import { asNumber, asString, parse, type Command, type RunContext } from "../command.ts";
import { ToolkitError } from "../../core/errors.ts";
import { ToolkitError, isKeyCapReached, keyCapReached } from "../../core/errors.ts";
import { printError, writeOut } from "../output.ts";

export const batch: Command = {
Expand Down Expand Up @@ -168,19 +168,21 @@ async function createCmd(rest: string[], ctx: RunContext): Promise<number> {
try {
const job = await createJob(body, { apiKey });
const finished = follow ? await waitForJob(job.job_id, { apiKey }) : job;
const runError = runFailure(finished);
const runDir = writeRun({
runId,
command: "zenrows batch create",
capability: "batch",
startedAt,
finishedAt: new Date().toISOString(),
status: "ok",
status: runError ? "error" : "ok",
request: { file, tasks: body.tasks.length, estimatedCredits: est.credits, jobParams },
result: { jobId: job.job_id, status: finished.latest_run?.status ?? "unknown" },
result: { jobId: job.job_id, status: finished.latest_run?.status ?? "unknown", ...failureFields(finished) },
...(runError ? { error: runError.toJSON() } : {}),
});
printJob(finished, json, `Submitted job ${job.job_id}`);
const code = printJob(finished, json, `Submitted job ${job.job_id}`);
if (runDir && !json) log.dim(` artifact: ${runDir}`);
return 0;
return code;
} catch (err) {
writeRun({
runId,
Expand All @@ -202,8 +204,7 @@ async function statusCmd(rest: string[], ctx: RunContext): Promise<number> {
assertUsable("batch");
const apiKey = requireApiKey();
const job = await getJob(id, { apiKey });
printJob(job, ctx.json, `Job ${id}`);
return 0;
return printJob(job, ctx.json, `Job ${id}`);
}

async function resultsCmd(rest: string[], ctx: RunContext): Promise<number> {
Expand Down Expand Up @@ -282,8 +283,7 @@ async function cancelCmd(rest: string[], ctx: RunContext): Promise<number> {
assertUsable("batch");
const apiKey = requireApiKey();
const job = await stopJob(id, { apiKey });
printJob(job, ctx.json, `Stopped job ${id}`);
return 0;
return printJob(job, ctx.json, `Stopped job ${id}`);
}

async function waitCmd(rest: string[], ctx: RunContext): Promise<number> {
Expand All @@ -293,8 +293,7 @@ async function waitCmd(rest: string[], ctx: RunContext): Promise<number> {
assertUsable("batch");
const apiKey = requireApiKey();
const job = await waitForJob(id, { apiKey, timeoutMs: asNumber(values.timeout) });
printJob(job, json, `Job ${id} finished`);
return 0;
return printJob(job, json, `Job ${id} finished`);
}

async function retryCmd(rest: string[], ctx: RunContext): Promise<number> {
Expand All @@ -305,22 +304,72 @@ async function retryCmd(rest: string[], ctx: RunContext): Promise<number> {
// "Reruns and retrying failures": POST /jobs/{id}/rerun?status=failed replays
// only the failures; already-successful tasks carry over.
const job = await rerunJob(id, { apiKey, status: "failed" });
printJob(job, ctx.json, `Reran failed tasks for job ${id}`);
return 0;
return printJob(job, ctx.json, `Reran failed tasks for job ${id}`);
}

/** The run's failure_reason / failure_detail, when the API reports them. */
function failureFields(job: Job): { failure_reason?: string; failure_detail?: string } {
const run = job.latest_run ?? ({} as Job["latest_run"]);
const out: { failure_reason?: string; failure_detail?: string } = {};
if (typeof run.failure_reason === "string" && run.failure_reason) out.failure_reason = run.failure_reason;
if (typeof run.failure_detail === "string" && run.failure_detail) out.failure_detail = run.failure_detail;
return out;
}

/**
* The error for a run that ended `failed`, or null for any other state. A run the
* key's credit cap stopped (`api_key_cap_reached`) gets the cap guidance: the
* account still has credits and its other keys keep working.
*/
export function runFailure(job: Job): ToolkitError | null {
if (job.latest_run?.status !== "failed") return null;
const { failure_reason, failure_detail } = failureFields(job);
const where = `batch job ${job.job_id}`;
if (isKeyCapReached(failure_reason)) {
return keyCapReached(where, { status: null, detail: failure_detail });
}
const why = [failure_reason, failure_detail].filter(Boolean).join(": ");
return new ToolkitError({
code: "BATCH_FAILED",
message: `Batch job ${job.job_id} failed.`,
likely_cause: why || "The run ended in status failed without a reason.",
next_action: "Inspect the per-task results, fix the cause, then rerun the failed tasks.",
suggested_commands: [`zenrows batch results ${job.job_id} --status failed`, `zenrows batch retry-failed ${job.job_id}`],
});
}

/** Print a job's status + stats, structured under --json. */
function printJob(job: Job, json: boolean, headline: string): void {
/**
* Print a job's status + stats, structured under --json, and return the exit
* code: 1 when the run ended `failed`, else 0. `stopped` and `deleted` are
* deliberate outcomes (someone cancelled or removed the run), so they exit 0
* but print as a warning, not a success.
*/
function printJob(job: Job, json: boolean, headline: string): number {
const run = job.latest_run ?? ({} as Job["latest_run"]);
const stats = run.stats;
const failure = failureFields(job);
const err = runFailure(job);
if (json) {
log.out(JSON.stringify({ ok: true, jobId: job.job_id, status: run.status, stats }, null, 2));
return;
log.out(
JSON.stringify(
{ ok: !err, jobId: job.job_id, status: run.status, stats, ...failure, ...(err ? { error: err.toJSON() } : {}) },
null,
2,
),
);
return err ? 1 : 0;
}
log.success(`${headline} · status: ${run.status ?? "unknown"}`);
const line = `${headline} · status: ${run.status ?? "unknown"}`;
if (err) log.error(line);
else if (run.status === "stopped" || run.status === "deleted") log.warn(line);
else log.success(line);
if (stats) {
log.info(` ${stats.completed}/${stats.total} completed · ${stats.successful} successful · ${stats.failed} failed`);
}
if (failure.failure_reason) log.info(` failure_reason: ${failure.failure_reason}`);
if (failure.failure_detail) log.info(` failure_detail: ${failure.failure_detail}`);
if (err) printError(err, false);
return err ? 1 : 0;
}

function normalizeResultStatus(v?: string): "successful" | "failed" | "all" | undefined {
Expand Down
20 changes: 19 additions & 1 deletion src/cli/commands/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { requireApiKey } from "../../core/auth.ts";
import { loadConfig } from "../../core/config.ts";
import { log } from "../../core/logger.ts";
import { fetchUsage } from "../../core/usage.ts";
import { fetchUsage, type KeyCreditCap } from "../../core/usage.ts";
import { parse, type Command, type RunContext } from "../command.ts";

/** Thousands separators, so a seven-digit credit limit stays readable. */
Expand All @@ -27,6 +27,23 @@ export function formatPlanStatus(status: string | undefined): string {
return status;
}

const CAP_WINDOW_LABEL: Record<string, string> = {
day: "daily",
week: "weekly",
month: "monthly",
billing_period: "billing-period",
};

/** One line per credit cap on the calling key; none when the key is uncapped. */
export function formatKeyCaps(caps: KeyCreditCap[] | undefined): string[] {
return (caps ?? []).map((c) => {
const label = `This key: ${CAP_WINDOW_LABEL[c.window] ?? c.window} cap ${fmt(c.credits)}`;
const reset = c.resets_at ? `, resets ${c.resets_at}` : "";
if (c.unavailable || c.used_credits === undefined) return `${label}, usage unavailable right now${reset}`;
return `${label}, ${fmt(c.used_credits)} used, ${fmt(c.remaining_credits ?? Math.max(0, c.credits - c.used_credits))} left${reset}`;
});
}

export const usage: Command = {
name: "usage",
summary: "Show plan usage, credits, and concurrency for the current API key.",
Expand Down Expand Up @@ -66,6 +83,7 @@ export const usage: Command = {
log.info(`API concurrency: ${api.concurrency.usage ?? 0} in use / ${api.concurrency.limit ?? "—"} max`);
}
if (u.period_ends_at) log.info(`Billing period ends: ${u.period_ends_at}`);
for (const line of formatKeyCaps(u.api_key?.caps)) log.info(line);
if (Array.isArray(u.top_ups) && u.top_ups.length) log.info(`Top-ups: ${u.top_ups.length}`);
return 0;
},
Expand Down
9 changes: 8 additions & 1 deletion src/core/batch-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ToolkitError, quotaExhausted } from "./errors.ts";
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts";
import { readAccount } from "./agent-account.ts";
import { registerSecret } from "./logger.ts";

Expand All @@ -43,6 +43,10 @@ export interface JobRun {
status: string;
stats: JobStats;
run_id?: string;
/** Why a `failed` run stopped, e.g. `api_key_cap_reached`. */
failure_reason?: string;
/** Human-readable detail for `failure_reason`. */
failure_detail?: string;
[k: string]: unknown;
}

Expand Down Expand Up @@ -196,6 +200,9 @@ function problemToError(status: number, body: string, method: string, path: stri
suggested_commands: ["zenrows batch status <id>"],
});
}
if (status === 402 && isKeyCapReached(serverCode)) {
return keyCapReached(`${method} ${path}`, { status: 402, detail: problem.detail || problem.title || undefined });
}
if (status === 402) {
// Out of credits ("Subscription has no credit available") — the same
// exhausted state as the scraper/usage 402. Surface the credits error with
Expand Down
8 changes: 7 additions & 1 deletion src/core/browser-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* Sessions are billed by bandwidth + session time, so callers must always close
* them (`closeSession`) — the `browser run` command does so in a `finally`.
*/
import { ToolkitError, quotaExhausted } from "./errors.ts";
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts";
import { readAccount } from "./agent-account.ts";
import { registerSecret } from "./logger.ts";
import { CLI_VERSION } from "./config.ts";
Expand Down Expand Up @@ -128,6 +128,12 @@ export function browserProblemToError(status: number, body: string, method: stri
suggested_commands: ["zenrows login --api-key <your-key>"],
});
}
if (status === 402 && isKeyCapReached(serverCode, body)) {
return keyCapReached(`${method} ${path}`, {
status: 402,
detail: parsed.error || parsed.detail || parsed.title || undefined,
});
}
if (status === 402) {
// Out of credits — same exhausted state as the scraper/batch 402.
const acct = readAccount();
Expand Down
31 changes: 31 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type ErrorCode =
| "PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM"
| "POLICY_BLOCKED_DOMAIN"
| "POLICY_MAX_CREDITS_EXCEEDED"
| "KEY_CREDIT_CAP_REACHED"
| "POLICY_LIMIT_EXCEEDED"
| "POLICY_EXPERIMENTAL_DISABLED"
| "POLICY_BROWSER_DISABLED"
Expand Down Expand Up @@ -107,3 +108,33 @@ export function quotaExhausted(
suggested_commands: ["zenrows usage"],
});
}

/** Where an account manages its API keys and their credit caps. */
export const API_KEYS_SETTINGS_URL = "https://app.zenrows.com/settings/api-keys";

/**
* True when a 402 is a per-key credit cap (gateway AUTH014, Batch
* `api_key_cap_reached`), not an account out of credits. The account still has
* credits, so the out-of-credits advice (top up, upgrade, claim) is wrong here.
*/
export function isKeyCapReached(code?: string, body?: string): boolean {
if (code === "AUTH014" || code === "api_key_cap_reached") return true;
return !!body && /\b(AUTH014|api_key_cap_reached)\b/.test(body);
}

/**
* The error for a request refused because this API key reached one of its credit
* caps. `opts.status: null` is for a Batch run the cap stopped: the run's status
* call succeeded, so there is no HTTP 402 to cite, only `url` (the job).
*/
export function keyCapReached(url: string, opts: { status?: number | null; detail?: string } = {}): ToolkitError {
const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
const where = opts.status === null ? `Stopped ${url}` : `HTTP ${opts.status ?? 402} for ${url}`;
return new ToolkitError({
code: "KEY_CREDIT_CAP_REACHED",
message: "This API key reached one of its credit caps.",
likely_cause: `${detail}${where}`,
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}.`,
suggested_commands: ["zenrows usage"],
});
}
18 changes: 17 additions & 1 deletion src/core/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* query parameter (per docs) and is registered as a secret so it is redacted
* from any logged URL.
*/
import { ToolkitError, quotaExhausted } from "./errors.ts";
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.ts";
import { readAccount } from "./agent-account.ts";
import { ENV_KEY, resolveApiKey } from "./auth.ts";
import { registerSecret } from "./logger.ts";
Expand Down Expand Up @@ -187,6 +187,11 @@ export async function scrape(
suggested_commands: [`zenrows extract ${params.url} --autoparse`],
});
}
// AUTH014: this key hit one of its credit caps; the account still has credits.
if (isKeyCapReached(zrErrorCode(body) ?? undefined)) {
// `detail` names the cap and its reset date; the title alone says neither.
throw keyCapReached(redacted, { status: 402, detail: zrErrorProblemDetail(body) ?? zrErrorDetail(body) ?? undefined });
}
// Zenrows returns 402 with a JSON error envelope (e.g. AUTH004 "reached its
// usage limit" / "Subscription has no credit available") when the account is
// out of credits. This is NOT scraped content — surface it as a credits
Expand Down Expand Up @@ -408,6 +413,17 @@ export function zrErrorCode(body: string): string | null {
}
}

/** The problem body's `detail` field alone, prefixed with its code. */
function zrErrorProblemDetail(body: string): string | null {
try {
const j = JSON.parse(body) as { code?: string; detail?: string };
if (!j.detail) return null;
return j.code ? `(${j.code}) ${j.detail}` : j.detail;
} catch {
return null;
}
}

/**
* Clean one-line detail from a Zenrows JSON error body
* (e.g. `(AUTH003) Invalid apikey provided`).
Expand Down
13 changes: 13 additions & 0 deletions src/core/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,22 @@ export interface UsageDetails {
};
};
top_ups?: unknown[];
/** The calling key's credit caps. Absent when they could not be loaded: unknown, not uncapped. */
api_key?: { caps?: KeyCreditCap[] };
[k: string]: unknown;
}

/** One credit cap on the calling API key. */
export interface KeyCreditCap {
window: "day" | "week" | "month" | "billing_period" | string;
credits: number;
used_credits?: number;
remaining_credits?: number;
resets_at?: string;
/** True when the cap's usage could not be read; the cap does not block the key meanwhile. */
unavailable?: boolean;
}

/** Build the plan-usage URL from the configured API base. */
export function usageUrl(apiBase: string): string {
const base = apiBase.endsWith("/") ? apiBase : apiBase + "/";
Expand Down
12 changes: 12 additions & 0 deletions tests/batch-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,18 @@ test("problem+json 402 (no credit available) maps to POLICY_MAX_CREDITS_EXCEEDED
);
});

test("problem+json 402 api_key_cap_reached maps to KEY_CREDIT_CAP_REACHED", async () => {
const { impl } = jsonFetch(
402,
{ title: "Payment Required", status: 402, code: "api_key_cap_reached", detail: "This API key has reached its daily cap of 200 credits." },
"application/problem+json",
);
await assert.rejects(
() => createJob({ type: "regular", status: "closed", tasks: [] }, { apiKey: "k", fetchImpl: impl }),
(e: unknown) => e instanceof ToolkitError && e.code === "KEY_CREDIT_CAP_REACHED" && /daily cap of 200/.test(e.likely_cause),
);
});

test("problem+json 404 maps to BATCH_NOT_FOUND", async () => {
const { impl } = jsonFetch(404, { code: "not_found", detail: "job missing" }, "application/problem+json");
await assert.rejects(
Expand Down
Loading
Loading