Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/cli/commands/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "—";
Expand Down Expand Up @@ -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)` : ""}`);
}
Expand Down
12 changes: 9 additions & 3 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
}
14 changes: 13 additions & 1 deletion src/core/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions tests/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});
Loading