From c8bee8834aa3fa7a4793ab5dc2c1826c34af538d Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 28 Aug 2026 00:19:55 +0200 Subject: [PATCH 1/2] Say that credits renew when the allowance runs out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quotaExhausted told an exhausted account to "add credits or upgrade your plan" and stopped there. Both are true, and both cost money — but neither is the cheapest answer, which is that the allowance comes back at the end of the billing period. Leaving that out is why an exhausted client retries instead of waiting or deciding. It matches what the API itself says on AUTH004 ("Purchase a new subscription to continue"), and the traffic shows the result: one account spent seven days at ~3 requests/second against this wall with zero successes. The date is not hardcoded here — `zenrows usage` prints the exact period_ends_at, and now gets suggested in both the claimed and unclaimed cases rather than only the claimed one. No code-path change: POLICY_MAX_CREDITS_EXCEEDED already means the account wall, and a policy cap already raises POLICY_LIMIT_EXCEEDED. Only the wording and the suggested command move. --- src/core/errors.ts | 12 +++++++++--- tests/errors.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/core/errors.ts b/src/core/errors.ts index 25a4898..520c4b9 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -88,15 +88,21 @@ export function quotaExhausted( claimUrl?: string, opts: { status?: number; detail?: string } = {}, ): ToolkitError { + // Say that the allowance comes back. Without it this reads as a permanent paywall, + // which is how the API's own AUTH004 text reads ("Purchase a new subscription to + // continue") and why exhausted clients retry-loop instead of waiting or upgrading — + // one account spent seven days at ~3 req/s against this wall. `zenrows usage` prints + // the exact `period_ends_at`, so the date is one command away rather than guessed here. + const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date."; const claimLine = claimUrl - ? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}` - : `You are out of Zenrows credits. Add credits or upgrade your plan: ${DASHBOARD_URL}`; + ? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}` + : `You are out of Zenrows credits. ${renewLine} To carry on now, add credits or upgrade your plan: ${DASHBOARD_URL}`; const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : ""; return new ToolkitError({ code: "POLICY_MAX_CREDITS_EXCEEDED", message: "Zenrows request quota exhausted.", likely_cause: `${detail}HTTP ${opts.status ?? 429} for ${url}`, next_action: claimLine, - suggested_commands: claimUrl ? [] : ["zenrows usage"], + suggested_commands: ["zenrows usage"], }); } diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 918a06d..e3eca46 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -10,6 +10,19 @@ test("quotaExhausted surfaces the claim URL", () => { assert.ok(err.next_action.includes("https://x/claim/t")); }); +test("quotaExhausted says the allowance renews, on both account states", () => { + // A spent allowance is not a paywall: it comes back at the period boundary. Leaving + // that out is what makes an exhausted agent retry-loop instead of waiting or upgrading. + for (const claim of ["https://x/claim/t", undefined]) { + const err = quotaExhausted("https://api.zenrows.com/v1/?url=x", claim); + assert.match(err.next_action, /renew/i); + assert.ok( + err.suggested_commands?.includes("zenrows usage"), + "the exact renewal date is one command away, so point at it in both states", + ); + } +}); + test("isQuotaError distinguishes credit exhaustion from concurrency/target 429s", () => { // Genuine account credit/quota exhaustion → claim nudge. assert.equal(isQuotaError(JSON.stringify({ code: "REQS002", title: "You have used all your credits" })), true); From 92383fe087aaef642a6fa55ba547be404ece5de0 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 28 Aug 2026 00:28:21 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Report=20credits=20in=20zenrows=20usage=20?= =?UTF-8?q?=E2=80=94=20the=20endpoint=20always=20sent=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the live endpoint: /v1/subscriptions/self/details returns usage_credits and credit_limit next to the dollar figure. Neither was declared on UsageDetails, so the command printed dollars and left the reader to convert. They cannot. The rate is per plan, not a platform constant: Free bills $0.001/credit (5,000 credits for $5) while a Business yearly plan bills 8.9997e-05. The response carries plan.unit_cost for exactly this reason, and credit_limit * unit_cost === plan.price holds on the live payload. So the command now leads with credits used, the credit limit and what is left, which is the unit the docs, the dashboard and the error messages all speak in. The dollar line stays underneath. unit_cost is declared with a note against ever hardcoding a conversion factor — an easy mistake to make from a single account's numbers, and wrong on every other plan. --- src/cli/commands/usage.ts | 17 +++++++++++++++++ src/core/usage.ts | 14 +++++++++++++- tests/usage.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index d3ecadb..9a97719 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -8,6 +8,11 @@ import { log } from "../../core/logger.ts"; import { fetchUsage } from "../../core/usage.ts"; import { parse, type Command, type RunContext } from "../command.ts"; +/** Thousands separators, so a seven-digit credit limit stays readable. */ +export function fmt(n: number): string { + return n.toLocaleString("en-US"); +} + /** Free plan still ships as plan_code=trial in R1; display the product name. */ export function formatPlanName(name: string | undefined): string { if (!name) return "—"; @@ -42,6 +47,18 @@ export const usage: Command = { const api = u.plan?.products?.api; log.info(`Plan: ${formatPlanName(u.plan?.name)}${u.plan?.recurrence ? ` (${u.plan.recurrence})` : ""}`); log.info(`Status: ${formatPlanStatus(u.status)}`); + // Credits are the unit the docs, the dashboard and the error messages all speak in, + // and the endpoint has been returning them all along. Showing only the dollar figure + // left the reader to convert — and the rate is per plan, so they could not. + if (u.usage_credits !== undefined && u.credit_limit !== undefined) { + const remaining = Math.max(0, u.credit_limit - u.usage_credits); + log.info( + `Credits: ${fmt(u.usage_credits)} of ${fmt(u.credit_limit)} used` + + `${u.usage_percent !== undefined ? ` (${u.usage_percent}%)` : ""} — ${fmt(remaining)} left`, + ); + } else if (u.usage_credits !== undefined) { + log.info(`Credits: ${fmt(u.usage_credits)} used`); + } if (u.usage !== undefined) { log.info(`Usage: ${u.usage}${u.usage_percent !== undefined ? ` (${u.usage_percent}% of plan)` : ""}`); } diff --git a/src/core/usage.ts b/src/core/usage.ts index d2f68bc..9128d26 100644 --- a/src/core/usage.ts +++ b/src/core/usage.ts @@ -19,6 +19,8 @@ export interface UsageConcurrency { export interface UsageProduct { usage?: number; + /** Same consumption as `usage`, counted in credits. */ + usage_credits?: number; concurrency?: UsageConcurrency; [k: string]: unknown; } @@ -27,13 +29,23 @@ export interface UsageDetails { status?: string; period_starts_at?: string; period_ends_at?: string; - /** Total units consumed across all products. */ + /** Total consumed across all products, in dollars. */ usage?: number; + /** The same consumption in credits — what the docs and the dashboard quote. */ + usage_credits?: number; + /** The plan's allowance in credits. `credit_limit * plan.unit_cost === plan.price`. */ + credit_limit?: number; /** Consumption as a percentage of the plan limit. */ usage_percent?: number; plan?: { name?: string; price?: number; + /** + * Dollars per credit, and it is **per plan**, not a platform constant: Free bills + * $0.001/credit (5,000 credits for $5) while larger plans get a volume rate. Never + * convert between dollars and credits with a hardcoded factor. + */ + unit_cost?: number; recurrence?: string; products?: { api?: UsageProduct; diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 71f715b..275b3a9 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -2,6 +2,8 @@ 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 type { UsageDetails } from "../src/core/usage.ts"; test("usageUrl derives subscriptions/self/details from the api base", () => { assert.equal(usageUrl("https://api.zenrows.com/v1/"), "https://api.zenrows.com/v1/subscriptions/self/details"); @@ -61,3 +63,27 @@ test("fetchUsage maps a 402 (over usage limit) to POLICY_MAX_CREDITS_EXCEEDED", (e: unknown) => e instanceof ToolkitError && e.code === "POLICY_MAX_CREDITS_EXCEEDED", ); }); + +test("zenrows usage reports credits, which the endpoint has always returned", () => { + // Verified against the live endpoint: it sends usage_credits and credit_limit + // alongside the dollar figure. Neither was declared on UsageDetails, so the command + // printed only dollars and left the reader to convert — which they cannot do, because + // the rate is per plan (Free $0.001/credit, larger plans a volume rate). + assert.equal(fmt(62982), "62,982"); + assert.equal(fmt(35999978), "35,999,978"); +}); + +test("UsageDetails carries credits and the per-plan rate", () => { + const sample: UsageDetails = { + status: "ACTIVE", + usage: 5.66823039823616, + usage_credits: 62982, + credit_limit: 35999978, + usage_percent: 0, + plan: { name: "Business", price: 3239.89, unit_cost: 8.9997e-5, recurrence: "YEARLY" }, + }; + // credit_limit * unit_cost === plan.price is the invariant that makes the rate per-plan + // rather than a platform constant. Holds on the live response. + 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); +});