diff --git a/skills/batch-jobs/SKILL.md b/skills/batch-jobs/SKILL.md index df81fe5..dba04f0 100644 --- a/skills/batch-jobs/SKILL.md +++ b/skills/batch-jobs/SKILL.md @@ -54,6 +54,14 @@ zenrows batch retry-failed # 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. diff --git a/skills/trace-debug/SKILL.md b/skills/trace-debug/SKILL.md index 221dc5c..93ab4c2 100644 --- a/skills/trace-debug/SKILL.md +++ b/skills/trace-debug/SKILL.md @@ -32,6 +32,18 @@ zenrows trace export # 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 --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. diff --git a/src/cli/commands/batch.ts b/src/cli/commands/batch.ts index 6d55e03..f4ca035 100644 --- a/src/cli/commands/batch.ts +++ b/src/cli/commands/batch.ts @@ -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 = { @@ -168,19 +168,21 @@ async function createCmd(rest: string[], ctx: RunContext): Promise { 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, @@ -202,8 +204,7 @@ async function statusCmd(rest: string[], ctx: RunContext): Promise { 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 { @@ -282,8 +283,7 @@ async function cancelCmd(rest: string[], ctx: RunContext): Promise { 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 { @@ -293,8 +293,7 @@ async function waitCmd(rest: string[], ctx: RunContext): Promise { 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 { @@ -305,22 +304,72 @@ async function retryCmd(rest: string[], ctx: RunContext): Promise { // "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 { diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index 9a97719..63260bf 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -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. */ @@ -27,6 +27,23 @@ export function formatPlanStatus(status: string | undefined): string { return status; } +const CAP_WINDOW_LABEL: Record = { + 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.", @@ -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; }, diff --git a/src/core/batch-api.ts b/src/core/batch-api.ts index 586a72c..e2c0af1 100644 --- a/src/core/batch-api.ts +++ b/src/core/batch-api.ts @@ -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"; @@ -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; } @@ -196,6 +200,9 @@ function problemToError(status: number, body: string, method: string, path: stri suggested_commands: ["zenrows batch status "], }); } + 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 diff --git a/src/core/browser-api.ts b/src/core/browser-api.ts index c6bff46..884e5e1 100644 --- a/src/core/browser-api.ts +++ b/src/core/browser-api.ts @@ -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"; @@ -128,6 +128,12 @@ export function browserProblemToError(status: number, body: string, method: stri suggested_commands: ["zenrows login --api-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(); diff --git a/src/core/errors.ts b/src/core/errors.ts index 9a8fc55..b36f109 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -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" @@ -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"], + }); +} diff --git a/src/core/http.ts b/src/core/http.ts index aa1dc2d..513dedd 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -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"; @@ -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 @@ -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`). diff --git a/src/core/usage.ts b/src/core/usage.ts index 9128d26..a4b3a77 100644 --- a/src/core/usage.ts +++ b/src/core/usage.ts @@ -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 + "/"; diff --git a/tests/batch-api.test.ts b/tests/batch-api.test.ts index 0b69650..a85c6e6 100644 --- a/tests/batch-api.test.ts +++ b/tests/batch-api.test.ts @@ -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( diff --git a/tests/batch-command.test.ts b/tests/batch-command.test.ts index 6ab938b..1be8e4e 100644 --- a/tests/batch-command.test.ts +++ b/tests/batch-command.test.ts @@ -132,3 +132,127 @@ test("batch estimate --json emits an {ok,...} envelope (ok reflects spec validit assert.equal(badJson.ok, false); }); }); + +/** Run `fn` in a workspace whose Batch API always answers with `job`. */ +function withJobResponse(job: unknown, fn: () => Promise): Promise { + const { root, cleanup } = tempRoot(); + const cwd = process.cwd(); + createWorkspace(root); + savePolicy(defaultPolicy(), root); + saveApiKey("0".repeat(41), root); + process.chdir(root); + const orig = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify(job), { status: 200, headers: { "content-type": "application/json" } })) as unknown as typeof fetch; + return fn().finally(() => { + globalThis.fetch = orig; + process.chdir(cwd); + cleanup(); + }); +} + +/** Capture stderr (human output) for the duration of `fn`. */ +async function captureErr(fn: () => unknown): Promise { + const orig = process.stderr.write.bind(process.stderr); + let buf = ""; + process.stderr.write = ((s: string | Uint8Array) => { + buf += typeof s === "string" ? s : Buffer.from(s).toString(); + return true; + }) as typeof process.stderr.write; + try { + await fn(); + } finally { + process.stderr.write = orig; + } + return buf; +} + +const capFailedJob = { + job_id: "j_cap", + latest_run: { + status: "failed", + stats: { total: 3, completed: 1, successful: 1, failed: 0 }, + failure_reason: "api_key_cap_reached", + failure_detail: "This API key reached its weekly credit cap of 100. It resets on 2026-10-05.", + }, +}; + +test("batch status on a run the key cap stopped exits 1, ok:false, with reason, detail, and cap guidance", async () => { + await withJobResponse(capFailedJob, async () => { + let code = -1; + const out = await captureOut(async () => { + code = await batch.run(["status", "j_cap"], ctx); + }); + assert.equal(code, 1); + const j = JSON.parse(out) as Record; + assert.equal(j.ok, false); + assert.equal(j.status, "failed"); + assert.equal(j.failure_reason, "api_key_cap_reached"); + assert.match(j.failure_detail, /weekly credit cap/); + assert.equal(j.error.code, "KEY_CREDIT_CAP_REACHED"); + assert.match(j.error.next_action, /settings\/api-keys/); + // The status call itself succeeded, so the cause must not cite an HTTP 402. + assert.doesNotMatch(j.error.likely_cause, /HTTP 402/); + assert.match(j.error.likely_cause, /Stopped batch job j_cap/); + }); +}); + +test("batch wait (human) on a cap-failed run prints reason and detail, no success mark, exits 1", async () => { + await withJobResponse(capFailedJob, async () => { + let code = -1; + const err = await captureErr(async () => { + code = await batch.run(["wait", "j_cap"], { json: false, yes: false }); + }); + assert.equal(code, 1); + assert.doesNotMatch(err, /✓/); + assert.match(err, /failure_reason: api_key_cap_reached/); + assert.match(err, /failure_detail: .*weekly credit cap/); + assert.match(err, /KEY_CREDIT_CAP_REACHED/); + }); +}); + +test("batch status on a run failed for another reason exits 1 with BATCH_FAILED", async () => { + await withJobResponse( + { job_id: "j_f", latest_run: { status: "failed", stats: { total: 1, completed: 0, successful: 0, failed: 0 }, failure_reason: "internal_error", failure_detail: "boom" } }, + async () => { + let code = -1; + const out = await captureOut(async () => { + code = await batch.run(["status", "j_f"], ctx); + }); + assert.equal(code, 1); + const j = JSON.parse(out) as Record; + assert.equal(j.ok, false); + assert.equal(j.error.code, "BATCH_FAILED"); + assert.match(j.error.likely_cause, /internal_error: boom/); + }, + ); +}); + +test("batch status on completed and stopped runs exits 0 with ok:true", async () => { + for (const status of ["completed", "stopped"]) { + await withJobResponse({ job_id: "j_ok", latest_run: { status, stats: { total: 1, completed: 1, successful: 1, failed: 0 } } }, async () => { + let code = -1; + const out = await captureOut(async () => { + code = await batch.run(["status", "j_ok"], ctx); + }); + assert.equal(code, 0, status); + const j = JSON.parse(out) as Record; + assert.equal(j.ok, true, status); + assert.equal(j.error, undefined, status); + }); + } +}); + +test("batch create --wait whose run the cap stops exits 1 with ok:false", async () => { + await withJobResponse(capFailedJob, async () => { + const file = writeSpec(["https://ok.example/a"]); + let code = -1; + const out = await captureOut(async () => { + code = await batch.run(["create", file, "--wait"], ctx); + }); + assert.equal(code, 1); + const j = JSON.parse(out) as Record; + assert.equal(j.ok, false); + assert.equal(j.failure_reason, "api_key_cap_reached"); + }); +}); diff --git a/tests/browser-api.test.ts b/tests/browser-api.test.ts index 9a68f2f..0767693 100644 --- a/tests/browser-api.test.ts +++ b/tests/browser-api.test.ts @@ -87,6 +87,14 @@ test("browserRequest maps 402 → POLICY_MAX_CREDITS_EXCEEDED", async () => { ); }); +test("browserRequest maps 402 AUTH014 → KEY_CREDIT_CAP_REACHED", async () => { + const { impl } = stub(402, { code: "AUTH014", detail: "This API key has reached its monthly cap of 300 credits." }); + await assert.rejects( + () => browserRequest("POST", "/browser/sessions", { apiKey: "k", fetchImpl: impl }), + (e: unknown) => e instanceof ToolkitError && e.code === "KEY_CREDIT_CAP_REACHED", + ); +}); + test("browserRequest maps 5xx → BROWSER_UNAVAILABLE", async () => { const { impl } = stub(503, "upstream down", "text/plain"); await assert.rejects( diff --git a/tests/http.test.ts b/tests/http.test.ts index 99eb500..48f989a 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -246,3 +246,29 @@ test("scrape still returns non-Zenrows 4xx bodies (allowed_status_codes / origin }, ); }); + +test("scrape maps AUTH014 (this key hit its credit cap) to KEY_CREDIT_CAP_REACHED, not out of credits", async () => { + const AUTH014 = JSON.stringify({ + code: "AUTH014", + detail: "This API key has reached its daily cap of 200 credits. The cap resets on 2026-10-03 at 00:00 UTC.", + status: 402, + title: "API key credit cap reached (AUTH014)", + type: "https://docs.zenrows.com/api-error-codes#AUTH014", + }); + await withFetch( + () => new Response(AUTH014, { status: 402, headers: { "content-type": "application/problem+json" } }), + async () => { + await assert.rejects( + () => scrape("https://api.zenrows.com/v1/", "test-key", { url: "https://example.net" }), + (err: unknown) => { + const e = err as { code: string; likely_cause: string; next_action: string }; + assert.equal(e.code, "KEY_CREDIT_CAP_REACHED"); + assert.match(e.likely_cause, /resets on 2026-10-03/); + assert.match(e.next_action, /settings\/api-keys/); + assert.doesNotMatch(e.next_action, /credit pack|out of Zenrows credits/); + return true; + }, + ); + }, + ); +}); diff --git a/tests/skill-content.test.ts b/tests/skill-content.test.ts index ada4d18..15c2752 100644 --- a/tests/skill-content.test.ts +++ b/tests/skill-content.test.ts @@ -8,3 +8,10 @@ test("SKILL documents auto-signup and claim", () => { assert.match(skill, /claim/i); assert.match(skill, /--no-signup/); }); + +test("trace-debug maps the key-cap and out-of-credits codes to an action", () => { + const skill = readFileSync("skills/trace-debug/SKILL.md", "utf8"); + assert.match(skill, /KEY_CREDIT_CAP_REACHED/); + assert.match(skill, /settings\/api-keys/); + assert.match(skill, /POLICY_MAX_CREDITS_EXCEEDED/); +}); diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 275b3a9..0b04bd5 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { fetchUsage, usageUrl } from "../src/core/usage.ts"; import { ToolkitError } from "../src/core/errors.ts"; -import { fmt } from "../src/cli/commands/usage.ts"; +import { fmt, formatKeyCaps } from "../src/cli/commands/usage.ts"; import type { UsageDetails } from "../src/core/usage.ts"; test("usageUrl derives subscriptions/self/details from the api base", () => { @@ -87,3 +87,30 @@ test("UsageDetails carries credits and the per-plan rate", () => { assert.ok(Math.abs(sample.credit_limit! * sample.plan!.unit_cost! - sample.plan!.price!) < 0.01); assert.ok(Math.abs(sample.usage! / sample.usage_credits! - sample.plan!.unit_cost!) < 1e-8); }); + +test("formatKeyCaps prints one line per cap on the calling key, none when uncapped", () => { + assert.deepEqual(formatKeyCaps(undefined), []); + assert.deepEqual(formatKeyCaps([]), []); + assert.deepEqual( + formatKeyCaps([ + { window: "day", credits: 200, used_credits: 200, remaining_credits: 0, resets_at: "2026-10-03T00:00:00Z" }, + { window: "month", credits: 300000, used_credits: 1234, remaining_credits: 298766, resets_at: "2026-11-01T00:00:00Z" }, + { window: "week", credits: 100, unavailable: true, resets_at: "2026-10-05T00:00:00Z" }, + ]), + [ + "This key: daily cap 200, 200 used, 0 left, resets 2026-10-03T00:00:00Z", + "This key: monthly cap 300,000, 1,234 used, 298,766 left, resets 2026-11-01T00:00:00Z", + "This key: weekly cap 100, usage unavailable right now, resets 2026-10-05T00:00:00Z", + ], + ); +}); + +test("fetchUsage keeps api_key so --json shows the key's caps", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ status: "ACTIVE", api_key: { caps: [{ window: "day", credits: 10, used_credits: 1, remaining_credits: 9 }] } }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + const u = await fetchUsage("https://api.zenrows.com/v1/", "k", { fetchImpl: fakeFetch }); + assert.equal(u.api_key?.caps?.[0]?.remaining_credits, 9); +});