From 450fdccc80300909c24ff13270a43ed741b79a99 Mon Sep 17 00:00:00 2001 From: Emirlan Rasulov Date: Thu, 15 Oct 2026 11:24:27 +0600 Subject: [PATCH 1/2] fix(batch): point an inactive subscription to Billing, not to re-login The Batch API now answers a valid key whose subscription is past due, unpaid, incomplete or paused with 401 code subscription_inactive. The CLI treated every 401 as a bad key and told the user to log in again, which cannot fix a billing state. Keep the AUTH_INVALID code for every 401 so scripts and agents that branch on it are unaffected; for subscription_inactive only the message and next action change, pointing to Billing. --- src/core/batch-api.ts | 12 ++++++++++++ src/core/open-url.ts | 1 + tests/batch-api.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/src/core/batch-api.ts b/src/core/batch-api.ts index cb0fad9..f86d9aa 100644 --- a/src/core/batch-api.ts +++ b/src/core/batch-api.ts @@ -21,6 +21,7 @@ import { readAccount } from "./agent-account.ts"; import { registerSecret } from "./logger.ts"; import { CLI_USER_AGENT } from "./config.ts"; import { agentClientHeader } from "./agent-client.ts"; +import { BILLING_URL } from "./open-url.ts"; /** Confirmed Batch API base (no trailing slash). */ export const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1"; @@ -176,6 +177,17 @@ function problemToError(status: number, body: string, method: string, path: stri suggested_commands: ["zenrows batch estimate jobs.jsonl"], }); } + if (status === 401 && serverCode === "subscription_inactive") { + // The key is fine; the subscription needs a billing action. Same + // AUTH_INVALID code as any 401 so callers that branch on it keep + // working; only the guidance changes, since re-entering the key won't help. + return new ToolkitError({ + code: "AUTH_INVALID", + message: "Zenrows refused the Batch API request because the subscription is not active.", + likely_cause: `${cause.replace(/\.?$/, ".")} The API key is valid, but the subscription is past due, unpaid, incomplete or paused.`, + next_action: `Fix the subscription in Billing (${BILLING_URL}), then retry.`, + }); + } if (status === 401) { return new ToolkitError({ code: "AUTH_INVALID", diff --git a/src/core/open-url.ts b/src/core/open-url.ts index c370a76..bae7269 100644 --- a/src/core/open-url.ts +++ b/src/core/open-url.ts @@ -13,6 +13,7 @@ export const DASHBOARD_URL = "https://app.zenrows.com/dashboard"; /** Lands on Billing with the credit-pack purchase modal already open (app PR #1972). */ export const BILLING_TOPUP_URL = "https://app.zenrows.com/billing?topup=open"; export const PLANS_URL = "https://app.zenrows.com/plans"; +export const BILLING_URL = "https://app.zenrows.com/billing"; export const DOCS_URL = "https://docs.zenrows.com"; /** Append query params to a URL (used to attach anonymous `utm_*` attribution). */ diff --git a/tests/batch-api.test.ts b/tests/batch-api.test.ts index a85c6e6..39977d5 100644 --- a/tests/batch-api.test.ts +++ b/tests/batch-api.test.ts @@ -149,6 +149,45 @@ test("problem+json 402 api_key_cap_reached maps to KEY_CREDIT_CAP_REACHED", asyn ); }); +test("problem+json 401 unauthenticated keeps AUTH_INVALID and the re-login guidance", async () => { + const { impl } = jsonFetch( + 401, + { title: "Unauthenticated (unauthenticated)", status: 401, code: "unauthenticated", detail: "Invalid API key." }, + "application/problem+json", + ); + await assert.rejects( + () => getJob("job_1", { apiKey: "k", fetchImpl: impl }), + (e: unknown) => + e instanceof ToolkitError && + e.code === "AUTH_INVALID" && + /Re-check your key/.test(e.next_action) && + (e.suggested_commands ?? []).some((c) => c.startsWith("zenrows login")), + ); +}); + +test("problem+json 401 subscription_inactive keeps AUTH_INVALID but points to Billing", async () => { + const { impl } = jsonFetch( + 401, + { + title: "Subscription not active (subscription_inactive)", + status: 401, + code: "subscription_inactive", + detail: "Subscription is not active.", + }, + "application/problem+json", + ); + await assert.rejects( + () => getJob("job_1", { apiKey: "k", fetchImpl: impl }), + (e: unknown) => + e instanceof ToolkitError && + e.code === "AUTH_INVALID" && + /subscription is not active/.test(e.message) && + /app\.zenrows\.com\/billing/.test(e.next_action) && + /Subscription is not active\./.test(e.likely_cause) && + !(e.suggested_commands ?? []).some((c) => c.startsWith("zenrows login")), + ); +}); + 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( From dcf8806cce52cc0e52c7ab39eb04b39904862f96 Mon Sep 17 00:00:00 2001 From: Emirlan Rasulov Date: Thu, 15 Oct 2026 11:37:13 +0600 Subject: [PATCH 2/2] fix(batch): mention Azure Marketplace for an inactive subscription --- src/core/batch-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/batch-api.ts b/src/core/batch-api.ts index f86d9aa..2b5cf28 100644 --- a/src/core/batch-api.ts +++ b/src/core/batch-api.ts @@ -185,7 +185,7 @@ function problemToError(status: number, body: string, method: string, path: stri code: "AUTH_INVALID", message: "Zenrows refused the Batch API request because the subscription is not active.", likely_cause: `${cause.replace(/\.?$/, ".")} The API key is valid, but the subscription is past due, unpaid, incomplete or paused.`, - next_action: `Fix the subscription in Billing (${BILLING_URL}), then retry.`, + next_action: `Fix the subscription in Billing (${BILLING_URL}), or in Azure Marketplace if you subscribed there, then retry.`, }); } if (status === 401) {