From e4a96937c189ae326eb8b7585bf13ce7fee06885 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:31:53 +0000 Subject: [PATCH] feat: turn the console's refusals into cards the user can act on A 429 and a 402 from the console arrived as prose. The 429 rendered as a message with a countdown and no way to see the ceiling it hit; the 402 rendered as a sentence in a red box, telling the user their budget was gone and leaving them to find the page that raises it. Split by what actually helps, because the two are not the same kind of problem: - 429 goes through `retryable()` and gets the existing retry card, linking to the console's limits page. Retrying IS the remedy here, and `Retry-After` already paces it. - 402 does NOT. The console answers 402 rather than 429 specifically so that clients stop -- its own comment says retrying an out-of-credit workspace "just turns one refusal into six" -- and a card that reads "Retrying in 4s" over something that will never succeed is worse than a plain message. These go through a new `blocking()` instead. So `SessionStatus` gains a `blocked` variant: the turn ended, no retry will fix it, but there is something to click. It is distinct from `retry`, which means a retry is IN FLIGHT and is drawn with a spinner and a countdown, and distinct from a plain session error, which travels as message text and therefore cannot carry a link at all -- which is why the user was told "budget exhausted" and left to go looking. The two 402s are told apart by `code`, and they need different pages: a key over its cap is fixed on the key, an empty balance by topping up. `code` is the only thing that distinguishes them on the OpenAI-compatible path, because the console's fuller refusal body does not survive that envelope (fixed console-side in the same change set). Everything is gated on the provider being `redrob`. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's generic codes, so any vendor may send one, and offering a link to our console for somebody else's rate limit would send the user to a page that cannot help them. --- packages/redrob/src/session/processor.ts | 15 ++- packages/redrob/src/session/retry.ts | 124 +++++++++++++++++++- packages/redrob/test/session/retry.test.ts | 69 +++++++++++ packages/schema/src/session-status-event.ts | 26 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 12 ++ 5 files changed, 244 insertions(+), 2 deletions(-) diff --git a/packages/redrob/src/session/processor.ts b/packages/redrob/src/session/processor.ts index ab583f0685..7a43dfaf17 100644 --- a/packages/redrob/src/session/processor.ts +++ b/packages/redrob/src/session/processor.ts @@ -640,7 +640,20 @@ const layer = Layer.effect( sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) - yield* status.set(ctx.sessionID, { type: "idle" }) + /* + A refusal the user can act on ends as `blocked` rather than `idle`, so the client can offer the + page that fixes it instead of printing a sentence and leaving them to find it. `idle` otherwise, + which is every failure with nothing to click. + + Published BEFORE the error event would be wrong: a client that renders the error first and then + sees `idle` has already drawn the plain box. The order here -- error, then the terminal status -- + is the existing one, and `blocked` simply replaces `idle` in it. + */ + const block = SessionRetry.blocking(error, input.model.providerID) + yield* status.set( + ctx.sessionID, + block ? { type: "blocked", message: block.message, action: block.action } : { type: "idle" }, + ) }) const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { diff --git a/packages/redrob/src/session/retry.ts b/packages/redrob/src/session/retry.ts index b687d2cc64..95da7b46a8 100644 --- a/packages/redrob/src/session/retry.ts +++ b/packages/redrob/src/session/retry.ts @@ -9,7 +9,22 @@ export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" export const GO_UPSELL_URL = "https://code.redrob.ai/go" -export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {}) +/** + * Where a console refusal sends the user. `/limits` shows the tier and its per-minute ceiling; + * `/api-keys` is where a key's monthly cap is raised; `/billing` is where the account is topped up. + * Each card links to the page that fixes ITS refusal, because a card that lands on the wrong page is + * barely better than no card. + */ +export const CONSOLE_LIMITS_URL = "https://console.redrob.ai/limits" +export const CONSOLE_KEYS_URL = "https://console.redrob.ai/api-keys" +export const CONSOLE_BILLING_URL = "https://console.redrob.ai/billing" +export type RetryReason = + | "free_tier_limit" + | "account_rate_limit" + | "console_rate_limit" + | "console_key_budget" + | "console_out_of_credit" + | (string & {}) export type Retryable = { message: string @@ -82,6 +97,107 @@ function exponential(attempt: number, random: number) { return Math.ceil(base + base * RETRY_JITTER_FACTOR * random) } +/** + * A console refusal the user can act on, as the card cowork already draws. + * + * Only for our own provider. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's generic codes, + * so any vendor may send them -- offering a link to the Redrob console for somebody else's rate limit + * would send the user to a page that cannot fix their problem. + * + * Only the RATE refusal is here. A 402 is deliberately not retryable: the console chose that status over + * 429 precisely so clients would stop rather than turn one refusal into six, and a spinner reading + * "Retrying in 4s" over something that will never succeed is worse than a plain message. Those are + * handled by `blocking()` instead. + */ +function consoleRateLimit(error: SessionV1.APIError, provider: string): Retryable | undefined { + if (provider !== "redrob") return undefined + if (consoleErrorCode(error) !== "rate_limit_exceeded") return undefined + + /* + The console's own message already names the tier's per-minute ceiling and how long to wait, so it is + used as-is rather than paraphrased into something less specific. + */ + const message = error.data.message || "Rate limit reached" + return { + message, + action: { + reason: "console_rate_limit", + provider, + title: "Rate limit reached", + message: + "This is your workspace's requests-per-minute ceiling, which rises with your lifetime top-ups. It clears on its own; the console shows the current tier and limit.", + label: "open limits", + link: CONSOLE_LIMITS_URL, + }, + } +} + +/** The machine-readable code from the console's OpenAI-shaped error envelope, if there is one. */ +function consoleErrorCode(error: SessionV1.APIError): string | undefined { + const body = parseJSON(error.data.responseBody) + if (!isRecord(body)) return undefined + const envelope = body["error"] + if (!isRecord(envelope)) return undefined + const code = envelope["code"] + return typeof code === "string" && code.length > 0 ? code : undefined +} + +export type Blocking = { + message: string + action?: Retryable["action"] +} + +/** + * A console refusal that retrying cannot fix, but a person can. + * + * Both of the console's 402s land here. They are NOT routed through `retryable()` on purpose: the console + * answers 402 rather than 429 specifically so that clients stop, and a retry card would both retry and + * claim to be retrying something that will never succeed. + * + * The two are told apart by `code`, which is the only thing that distinguishes them on the + * OpenAI-compatible path -- the console's fuller refusal body, with `reason` and the figures, does not + * survive that envelope. They need different pages: a key over its cap is fixed by raising that key's + * cap, an empty balance by topping up, and sending the user to the wrong one wastes the card. + */ +export function blocking(error: Err, provider: string): Blocking | undefined { + if (provider !== "redrob") return undefined + if (!SessionV1.APIError.isInstance(error)) return undefined + const code = consoleErrorCode(error) + /* The console's own message carries the figures, so it is shown rather than paraphrased. */ + const message = error.data.message || "The request was refused" + + if (code === "api_key_budget_exhausted") { + return { + message, + action: { + reason: "console_key_budget", + provider, + title: "This key is over its monthly budget", + message: + "The cap is per API key and resets at the start of the month. Raise it on the key, or use a key without a cap.", + label: "open api keys", + link: CONSOLE_KEYS_URL, + }, + } + } + + if (code === "insufficient_quota") { + return { + message, + action: { + reason: "console_out_of_credit", + provider, + title: "The workspace is out of credit", + message: "Top up to continue. A balance covers every key on the workspace.", + label: "open billing", + link: CONSOLE_BILLING_URL, + }, + } + } + + return undefined +} + export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (SessionV1.ContextOverflowError.isInstance(error)) return undefined @@ -96,6 +212,12 @@ export function retryable(error: Err, provider: string) { !matchesRetryableMessage(error.data.responseBody) ) return undefined + /* + Checked before the upstream markers below, because those match on a body substring while this + matches on the console's own error code -- the more specific signal should win. + */ + const rate = consoleRateLimit(error, provider) + if (rate) return rate if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, diff --git a/packages/redrob/test/session/retry.test.ts b/packages/redrob/test/session/retry.test.ts index 702d5956f2..01f7bfd1ac 100644 --- a/packages/redrob/test/session/retry.test.ts +++ b/packages/redrob/test/session/retry.test.ts @@ -519,3 +519,72 @@ describe("session.message-v2.fromError", () => { }) }) }) + +/** + * The console's refusals, and the line between the two kinds. + * + * A 429 is retryable and gets the retry card. A 402 is NOT: the console answers 402 rather than 429 + * specifically so clients stop, so it must never appear in `retryable()` -- a retry card would both retry + * and claim to be retrying something that will never succeed. + * + * Both are gated on the provider being ours. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's + * generic codes, so another vendor sending one must not be handed a link to our console. + */ +describe("session.retry console refusals", () => { + function consoleError(status: number, code: string, message = "refused"): SessionV1.APIError { + return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message, + isRetryable: status === 429, + statusCode: status, + responseBody: JSON.stringify({ error: { message, type: "x", param: null, code } }), + }).toObject(), + ) + } + + test("a rate refusal becomes a retry card pointing at the limits page", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded", "slow down"), "redrob") + expect(result?.message).toBe("slow down") + expect(result?.action?.reason).toBe("console_rate_limit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_LIMITS_URL) + }) + + test("another vendor's rate limit gets no console link", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded"), "openai") + expect(result?.action).toBeUndefined() + }) + + test("a budget refusal is NOT retryable", () => { + expect(SessionRetry.retryable(consoleError(402, "api_key_budget_exhausted"), "redrob")).toBeUndefined() + }) + + test("a budget refusal blocks, pointing at the keys page where the cap lives", () => { + const result = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted", "over cap"), "redrob") + expect(result?.message).toBe("over cap") + expect(result?.action?.reason).toBe("console_key_budget") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_KEYS_URL) + }) + + test("an empty balance blocks, pointing at billing instead", () => { + const result = SessionRetry.blocking(consoleError(402, "insufficient_quota", "no credit"), "redrob") + expect(result?.action?.reason).toBe("console_out_of_credit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_BILLING_URL) + }) + + test("the two 402s are told apart, which is the whole point of carrying the code", () => { + const budget = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted"), "redrob") + const balance = SessionRetry.blocking(consoleError(402, "insufficient_quota"), "redrob") + expect(budget?.action?.link).not.toBe(balance?.action?.link) + }) + + test("another vendor's 402 does not block with our pages", () => { + expect(SessionRetry.blocking(consoleError(402, "insufficient_quota"), "openai")).toBeUndefined() + }) + + test("a 402 with no code is left alone rather than guessed at", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ message: "refused", isRetryable: false, statusCode: 402 }).toObject(), + ) + expect(SessionRetry.blocking(error, "redrob")).toBeUndefined() + }) +}) diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts index f6a3022bcb..47cdec58e1 100644 --- a/packages/schema/src/session-status-event.ts +++ b/packages/schema/src/session-status-event.ts @@ -29,6 +29,32 @@ export const Info = Schema.Union([ Schema.Struct({ type: Schema.Literal("busy"), }), + /** + * The turn stopped and no retry will fix it, but there IS something the user can do. + * + * Distinct from `retry` because that variant means a retry is in flight: a client renders it with a + * spinner and a countdown, which would be a lie here. Distinct from a plain session error because + * those travel as message text and so can carry no link -- the user was told "budget exhausted" and + * left to find the page themselves. + * + * The console's 402s are the case this exists for. Both a key over its monthly cap and an account with + * an empty balance are refusals a person fixes in the console, and the console deliberately answers 402 + * rather than 429 so that clients STOP instead of turning one refusal into six. + */ + Schema.Struct({ + type: Schema.Literal("blocked"), + message: Schema.String, + action: optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: optional(Schema.String), + }), + ), + }), ]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a609f30ae7..ac9249b06d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -693,6 +693,18 @@ export type SessionStatus = | { type: "busy" } + | { + type: "blocked" + message: string + action?: { + reason: string + provider: string + title: string + message: string + label: string + link?: string + } + } export type QuestionOption = { /**