Skip to content

Commit f97b376

Browse files
Quota-aware tenant health and reframed credit-exhaustion errors (#62)
Two trial-fleet findings: status showed ok:true while every proxied call failed on quota, and Anthropic's raw 'out of extra usage' error pointed teammates at their own billing instead of the shared tenant account. Per-account quota state (lastQuotaErrorAt/code/consecutive) is recorded in DO storage off the response path on credit-class errors only (plain 429s are transient rate limiting and pass through untouched), cleared on the next 2xx. /tenant/accounts gains sanitized per-account health and the ready endpoint gains accountsDegraded. Credit-exhaustion responses keep provider status and JSON shape but the message now names the shared account label, points at cmux-subrouter status, and appends the original provider text. 2xx streaming is untouched (zero added awaits pre-tee). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 909b4c9 commit f97b376

4 files changed

Lines changed: 563 additions & 13 deletions

File tree

cloudflare/packages/worker/src/index.ts

Lines changed: 286 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,18 @@ interface TenantAccountOutput {
106106
readonly created_at: string
107107
readonly email?: string
108108
readonly validation?: "ok" | "failed"
109+
readonly health: AccountHealthOutput
109110
}
110111

111112
type UpstreamProvider = "claude" | "codex"
112113
type WaitUntil = (promise: Promise<unknown>) => void
113114

115+
interface AccountHealthOutput {
116+
readonly ok: boolean
117+
readonly lastQuotaErrorAt?: string
118+
readonly message?: string
119+
}
120+
114121
const anthropicBootstrapModelQuotas: AccountModelQuotas = {
115122
opus: { remainingPercent: 100 },
116123
sonnet: { remainingPercent: 100 },
@@ -136,6 +143,9 @@ interface StoredAccount extends Account {
136143
readonly createdAt: number
137144
readonly updatedAt: number
138145
readonly source?: string
146+
readonly lastQuotaErrorAt?: number
147+
readonly lastQuotaErrorCode?: string
148+
readonly consecutiveQuotaErrors?: number
139149
}
140150

141151
interface RouteOutput {
@@ -185,6 +195,14 @@ interface AccountRow {
185195
readonly totp_algorithm: string | null
186196
readonly created_at: number
187197
readonly updated_at: number
198+
readonly last_quota_error_at: number | null
199+
readonly last_quota_error_code: string | null
200+
readonly consecutive_quota_errors: number | null
201+
}
202+
203+
interface TableInfoRow {
204+
readonly [key: string]: SqlStorageValue
205+
readonly name: string
188206
}
189207

190208
interface OAuthRefreshStatus {
@@ -593,6 +611,16 @@ const safeTenantAccount = (
593611
created_at: new Date(account.createdAt).toISOString(),
594612
...(safe.email ? { email: safe.email } : {}),
595613
...(validation ? { validation } : {}),
614+
health: accountHealth(account),
615+
}
616+
}
617+
618+
const accountHealth = (account: StoredAccount): AccountHealthOutput => {
619+
if (!account.lastQuotaErrorAt) return { ok: true }
620+
return {
621+
ok: false,
622+
lastQuotaErrorAt: new Date(account.lastQuotaErrorAt).toISOString(),
623+
message: "Upstream reported quota exhaustion for this shared tenant account.",
596624
}
597625
}
598626

@@ -808,6 +836,15 @@ const rowToAccount = (row: AccountRow): StoredAccount => ({
808836
createdAt: row.created_at,
809837
updatedAt: row.updated_at,
810838
source: "durable-object",
839+
...(row.last_quota_error_at !== null
840+
? { lastQuotaErrorAt: row.last_quota_error_at }
841+
: {}),
842+
...(row.last_quota_error_code !== null
843+
? { lastQuotaErrorCode: row.last_quota_error_code }
844+
: {}),
845+
...(row.consecutive_quota_errors !== null
846+
? { consecutiveQuotaErrors: row.consecutive_quota_errors }
847+
: {}),
811848
})
812849

813850
const quotaRowsToRecord = (
@@ -1444,6 +1481,7 @@ export class SubrouterDurableObject extends DurableObject<Env> {
14441481
INSERT OR IGNORE INTO subrouter_status(id) VALUES (1);
14451482
INSERT OR IGNORE INTO lifecycle(id, started_at) VALUES (1, ${Date.now()});
14461483
`)
1484+
this.ensureAccountHealthColumns()
14471485

14481486
this.ctx.blockConcurrencyWhile(async () => {
14491487
await this.scheduleNextRefreshAlarm()
@@ -1678,9 +1716,13 @@ export class SubrouterDurableObject extends DurableObject<Env> {
16781716
}
16791717
}
16801718

1681-
async ready(): Promise<{ ok: boolean; draining: boolean }> {
1719+
async ready(): Promise<{ ok: boolean; draining: boolean; accountsDegraded: number }> {
16821720
const draining = this.isDraining()
1683-
return { ok: !draining, draining }
1721+
return {
1722+
ok: !draining,
1723+
draining,
1724+
accountsDegraded: this.accountsDegradedCount(this.ctx.storage.sql),
1725+
}
16841726
}
16851727

16861728
async drain(): Promise<{ ok: true; draining: true }> {
@@ -1917,6 +1959,52 @@ export class SubrouterDurableObject extends DurableObject<Env> {
19171959
}
19181960
}
19191961

1962+
async recordAccountQuotaError(input: {
1963+
readonly orgId?: string
1964+
readonly accountId: string
1965+
readonly code: string
1966+
}): Promise<{ ok: true }> {
1967+
const orgId = this.resolveOrgId(input.orgId)
1968+
const accountId = this.requireNonEmpty(input.accountId, "accountId")
1969+
const code = this.requireNonEmpty(input.code, "code")
1970+
const now = Date.now()
1971+
this.ctx.storage.sql.exec(
1972+
`UPDATE accounts
1973+
SET last_quota_error_at = ?,
1974+
last_quota_error_code = ?,
1975+
consecutive_quota_errors = COALESCE(consecutive_quota_errors, 0) + 1,
1976+
updated_at = ?
1977+
WHERE id = ? AND org_id = ?`,
1978+
now,
1979+
code,
1980+
now,
1981+
accountId,
1982+
orgId
1983+
)
1984+
return { ok: true }
1985+
}
1986+
1987+
async clearAccountQuotaError(input: {
1988+
readonly orgId?: string
1989+
readonly accountId: string
1990+
}): Promise<{ ok: true }> {
1991+
const orgId = this.resolveOrgId(input.orgId)
1992+
const accountId = this.requireNonEmpty(input.accountId, "accountId")
1993+
const now = Date.now()
1994+
this.ctx.storage.sql.exec(
1995+
`UPDATE accounts
1996+
SET last_quota_error_at = NULL,
1997+
last_quota_error_code = NULL,
1998+
consecutive_quota_errors = 0,
1999+
updated_at = ?
2000+
WHERE id = ? AND org_id = ? AND last_quota_error_at IS NOT NULL`,
2001+
now,
2002+
accountId,
2003+
orgId
2004+
)
2005+
return { ok: true }
2006+
}
2007+
19202008
async recordTranscriptMeta(input: TranscriptEventInput): Promise<{ ok: true }> {
19212009
const agentType = normalizeAgentType(input.agentType) || "codex"
19222010
const sessionId = this.requireNonEmpty(input.sessionId, "sessionId")
@@ -2058,6 +2146,34 @@ export class SubrouterDurableObject extends DurableObject<Env> {
20582146
return sql.exec<StatusRow>("SELECT * FROM subrouter_status WHERE id = 1").one()
20592147
}
20602148

2149+
private ensureAccountHealthColumns(): void {
2150+
const existing = new Set(
2151+
this.ctx.storage.sql
2152+
.exec<TableInfoRow>("PRAGMA table_info(accounts)")
2153+
.toArray()
2154+
.map((row) => row.name)
2155+
)
2156+
if (!existing.has("last_quota_error_at")) {
2157+
this.ctx.storage.sql.exec("ALTER TABLE accounts ADD COLUMN last_quota_error_at INTEGER")
2158+
}
2159+
if (!existing.has("last_quota_error_code")) {
2160+
this.ctx.storage.sql.exec("ALTER TABLE accounts ADD COLUMN last_quota_error_code TEXT")
2161+
}
2162+
if (!existing.has("consecutive_quota_errors")) {
2163+
this.ctx.storage.sql.exec(
2164+
"ALTER TABLE accounts ADD COLUMN consecutive_quota_errors INTEGER DEFAULT 0"
2165+
)
2166+
}
2167+
}
2168+
2169+
private accountsDegradedCount(sql: SqlStorage): number {
2170+
return sql
2171+
.exec<CountRow>(
2172+
"SELECT COUNT(*) AS count FROM accounts WHERE last_quota_error_at IS NOT NULL"
2173+
)
2174+
.one().count
2175+
}
2176+
20612177
private listEligibleAccounts(
20622178
sql: SqlStorage,
20632179
orgId: string,
@@ -2699,6 +2815,143 @@ const recordTelemetryAccount = (
26992815
telemetry.upstreamProvider = providerForAccount(account.kind)
27002816
}
27012817

2818+
interface QuotaObservation {
2819+
readonly code: string
2820+
readonly response?: Response
2821+
}
2822+
2823+
const quotaResponseForNon2xx = async (
2824+
response: Response,
2825+
account: StoredAccountContract
2826+
): Promise<QuotaObservation | null> => {
2827+
if (response.status < 400) return null
2828+
const bodyText = await response.clone().text()
2829+
const parsed = parseJsonOrText(bodyText)
2830+
const message = providerErrorMessage(parsed)
2831+
const creditExhausted = isAnthropicCreditExhaustion(parsed, bodyText)
2832+
// Only credit-class exhaustion counts: a plain 429 is transient rate
2833+
// limiting on an account that still has quota, and telling users to
2834+
// "top up" for it would be wrong advice.
2835+
if (!creditExhausted) return null
2836+
2837+
const code = providerErrorCode(parsed) ?? "anthropic_credit_exhausted"
2838+
const reframedBody = reframeQuotaJsonBody(parsed, account.label, message)
2839+
if (!reframedBody) return { code }
2840+
2841+
const headers = new Headers(response.headers)
2842+
headers.delete("content-length")
2843+
if (!headers.has("content-type")) {
2844+
headers.set("content-type", "application/json; charset=utf-8")
2845+
}
2846+
return {
2847+
code,
2848+
response: new Response(JSON.stringify(reframedBody), {
2849+
status: response.status,
2850+
statusText: response.statusText,
2851+
headers,
2852+
}),
2853+
}
2854+
}
2855+
2856+
const providerErrorMessage = (body: unknown): string | undefined => {
2857+
if (!body || typeof body !== "object") {
2858+
return typeof body === "string" && body.trim() ? body : undefined
2859+
}
2860+
const record = body as Record<string, unknown>
2861+
const error = record["error"]
2862+
if (error && typeof error === "object" && !Array.isArray(error)) {
2863+
const nestedMessage = (error as Record<string, unknown>)["message"]
2864+
if (typeof nestedMessage === "string" && nestedMessage.trim()) {
2865+
return nestedMessage
2866+
}
2867+
}
2868+
if (typeof error === "string" && error.trim()) return error
2869+
const message = record["message"]
2870+
return typeof message === "string" && message.trim() ? message : undefined
2871+
}
2872+
2873+
const providerErrorCode = (body: unknown): string | undefined => {
2874+
if (!body || typeof body !== "object") return undefined
2875+
const record = body as Record<string, unknown>
2876+
const error = record["error"]
2877+
if (error && typeof error === "object" && !Array.isArray(error)) {
2878+
const nested = error as Record<string, unknown>
2879+
for (const key of ["code", "type"]) {
2880+
const value = nested[key]
2881+
if (typeof value === "string" && value.trim()) return value
2882+
}
2883+
}
2884+
for (const key of ["code", "type"]) {
2885+
const value = record[key]
2886+
if (typeof value === "string" && value.trim()) return value
2887+
}
2888+
return undefined
2889+
}
2890+
2891+
const isAnthropicCreditExhaustion = (parsed: unknown, rawBody: string): boolean => {
2892+
const haystack = `${providerErrorCode(parsed) ?? ""}\n${providerErrorMessage(parsed) ?? ""}\n${rawBody}`.toLowerCase()
2893+
return (
2894+
haystack.includes("out of extra usage") ||
2895+
haystack.includes("claude.ai/settings/usage") ||
2896+
haystack.includes("credit_balance_too_low") ||
2897+
haystack.includes("out of credits")
2898+
)
2899+
}
2900+
2901+
const reframeQuotaJsonBody = (
2902+
body: unknown,
2903+
accountLabel: string,
2904+
providerMessage: string | undefined
2905+
): unknown | null => {
2906+
if (!providerMessage) return null
2907+
const message = quotaReframeMessage(accountLabel, providerMessage)
2908+
if (!body || typeof body !== "object" || Array.isArray(body)) return null
2909+
const record = body as Record<string, unknown>
2910+
const error = record["error"]
2911+
if (error && typeof error === "object" && !Array.isArray(error)) {
2912+
return {
2913+
...record,
2914+
error: {
2915+
...(error as Record<string, unknown>),
2916+
message,
2917+
},
2918+
}
2919+
}
2920+
if (typeof error === "string") {
2921+
return { ...record, error: message }
2922+
}
2923+
if (typeof record["message"] === "string") {
2924+
return { ...record, message }
2925+
}
2926+
return null
2927+
}
2928+
2929+
const quotaReframeMessage = (
2930+
accountLabel: string,
2931+
providerMessage: string
2932+
): string =>
2933+
`[cmux subrouter] shared tenant account '${sanitizeQuotaMessage(accountLabel)}' is out of quota - ask the tenant owner to top up or add another account (run: cmux-subrouter status). Provider says: ${sanitizeQuotaMessage(providerMessage)}`
2934+
2935+
const sanitizeQuotaMessage = (value: string): string =>
2936+
value
2937+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
2938+
.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]")
2939+
.replace(/srt_[0-9a-f]+/gi, "[redacted]")
2940+
.replace(/[\r\n\t]+/g, " ")
2941+
.slice(0, 500)
2942+
2943+
const scheduleAccountHealthWrite = (
2944+
waitUntil: WaitUntil | undefined,
2945+
promise: Promise<unknown>
2946+
): void => {
2947+
const guarded = promise.catch(() => {})
2948+
if (waitUntil) {
2949+
waitUntil(guarded)
2950+
return
2951+
}
2952+
void guarded
2953+
}
2954+
27022955
const proxyUpstream = async (
27032956
request: Request,
27042957
env: Env,
@@ -2752,8 +3005,33 @@ const proxyUpstream = async (
27523005
const upstreamRequest = new Request(upstreamURL, init)
27533006
const response = await fetch(upstreamRequest)
27543007
if (telemetry) telemetry.upstreamStatus = response.status
2755-
const [clientBody, transcriptBody] = response.body
2756-
? response.body.tee()
3008+
let clientResponse = response
3009+
if (response.status >= 200 && response.status < 300) {
3010+
if ((account as StoredAccountContract & { readonly lastQuotaErrorAt?: number }).lastQuotaErrorAt) {
3011+
scheduleAccountHealthWrite(
3012+
waitUntil,
3013+
actor.clearAccountQuotaError({
3014+
orgId: routeInput.orgId,
3015+
accountId: account.id,
3016+
})
3017+
)
3018+
}
3019+
} else {
3020+
const quota = await quotaResponseForNon2xx(response, account)
3021+
if (quota) {
3022+
scheduleAccountHealthWrite(
3023+
waitUntil,
3024+
actor.recordAccountQuotaError({
3025+
orgId: routeInput.orgId,
3026+
accountId: account.id,
3027+
code: quota.code,
3028+
})
3029+
)
3030+
clientResponse = quota.response ?? response
3031+
}
3032+
}
3033+
const [clientBody, transcriptBody] = clientResponse.body
3034+
? clientResponse.body.tee()
27573035
: [null, null]
27583036
const transcriptBodyBuffer = transcriptBody
27593037
? new Response(transcriptBody).arrayBuffer()
@@ -2778,7 +3056,7 @@ const proxyUpstream = async (
27783056
eventType: "http_body",
27793057
direction: "upstream_to_client",
27803058
body: await transcriptBodyBuffer,
2781-
payload: { status: response.status },
3059+
payload: { status: clientResponse.status },
27823060
})
27833061
}
27843062
if (waitUntil) {
@@ -2787,9 +3065,9 @@ const proxyUpstream = async (
27873065
await recordTranscripts()
27883066
}
27893067
return new Response(clientBody, {
2790-
status: response.status,
2791-
statusText: response.statusText,
2792-
headers: response.headers,
3068+
status: clientResponse.status,
3069+
statusText: clientResponse.statusText,
3070+
headers: clientResponse.headers,
27933071
})
27943072
}
27953073

0 commit comments

Comments
 (0)