Skip to content

feat(proxy): warn Claude Code when a subscription turn fails over to the billable Weave key - #852

Open
steventohme wants to merge 2 commits into
mainfrom
subscription-failover-warning-marker
Open

feat(proxy): warn Claude Code when a subscription turn fails over to the billable Weave key#852
steventohme wants to merge 2 commits into
mainfrom
subscription-failover-warning-marker

Conversation

@steventohme

Copy link
Copy Markdown
Collaborator

Why

Investigating an anomalous router-usage session ($836 on one org's Weave API key), we traced the proximate cause to: a Claude subscription hit its 5-hour unified rate limit mid-session (anthropic-ratelimit-unified-5h-status: rejected), and the router correctly failed the turn over to the billable Weave/BYOK Anthropic key — both credential-suppression paths already handle this — but did so silently. The customer had no signal that subsequent turns would be billed instead of served on their own plan.

What

Adds subscriptionFailoverWarningMarker, a new routing-marker constant distinct from subscriptionOnlyWarningMarker:

  • subscriptionOnlyWarningMarker — permanent, org-level depleted-credits state, with a top-up CTA.
  • subscriptionFailoverWarningMarker (new) — per-turn, self-healing condition ("this turn", not "until you add credits"), no CTA, not gated by the routing-marker opt-out (same billing-state-change rule as the existing marker).

Wired into both places a subscription-served Anthropic turn can fail over:

  1. Pre-emptive (service.go ~2457) — a prior observer snapshot already reads exhausted before dispatch.
  2. Reactive (service.go ~2886) — a live 429/OAuth-rejection triggers the subscription-credit retry onto the Weave key. Swap is safe because this branch only runs pre-commit (preludeBuf.Committed() == false).

Out of scope

Codex/OpenAI: there's no codexSubscriptionExhausted equivalent gating that path today, so a Codex subscription failover stays silent until that symmetric guard is built (separate follow-up).

Tests

Two new full-flow ProxyMessages integration tests in failover_integration_test.go — one per path — asserting the marker text appears and the retry authenticates via the deployment key, not the spent subscription bearer. Full suite: wv mr tc && wv mr t pass.

🤖 Generated with Claude Code

…the billable Weave key

Munir's Claude subscription hit its 5h unified rate limit mid-session
(anthropic-ratelimit-unified-5h-status: rejected) while a long, cache-heavy
session was running under a native-web-search Anthropic-only constraint. The
router correctly failed the turn over to the billable Weave/BYOK Anthropic
key -- both credential-suppression paths already do this -- but did so
silently, so the customer had no signal that ~$800 of subsequent turns would
be billed instead of served on their own plan.

Add subscriptionFailoverWarningMarker, a new routing-marker constant distinct
from subscriptionOnlyWarningMarker: that one signals a permanent, org-level
depleted-credits state with a top-up CTA; this one is a per-turn,
self-healing condition ("this turn", not "until you add credits"), so it
carries no CTA and no opt-out gate -- same billing-state-change rule as the
existing subscription-only warning.

Wire it into both places a subscription-served Anthropic turn can fail over:

- Pre-emptive (service.go ~2457): a prior observer snapshot already reads
  exhausted before dispatch. Thread subscriptionFailingOver through to the
  marker assembly so the turn's single marker choice reflects it.
- Reactive (service.go ~2886): a live 429/OAuth-rejection triggers the
  subscription-credit retry onto the Weave key. Since this branch only runs
  pre-commit (preludeBuf.Committed() == false), swapping the retry attempt's
  marker is safe -- nothing under the original marker has hit the wire yet.

Codex/OpenAI is out of scope: there is no codexSubscriptionExhausted
equivalent gating that path today, so a Codex subscription failover stays
silent until that symmetric guard exists.

Tests: two new full-flow ProxyMessages integration tests in
failover_integration_test.go covering both paths end-to-end (including,
for the reactive path, that the router's own same-binding retry runs first
and the failover marker only appears on the eventual Weave-key attempt).
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


  • Run git diff to get added comment lines
  • Analyze added comment blocks (3+ consecutive lines)
  • Post review if any are flagged

Posted 6 comment-length suggestions (advisory, won't block merge):

File Lines Issue
failover_integration_test.go 531–534 4-line func docstring restates name+assertions; fits in 3
failover_integration_test.go 578–583 6-line func docstring; same content fits in 3
failover_integration_test.go 595–598 4-line inline comment; non-obvious WHY fits in 3
service.go 2541–2544 4-line branch comment; opt-out exemption note fits in 2
service.go 2886–2888 3-line safety note; preludeBuf invariant fits on 1 line
usage_bypass.go 216–221 6-line godoc; permanent-vs-per-turn contrast fits in 4

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nits. Won't block merge.

Comment on lines +531 to +534
// TestProxyMessages_PreemptiveSubscriptionFailoverWarnsUser guards the
// pre-emptive path (an observer snapshot already reads exhausted before
// dispatch): the turn must serve on the deployment key AND the client must see
// the billable-failover warning marker instead of the normal routing marker.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// TestProxyMessages_PreemptiveSubscriptionFailoverWarnsUser guards the
// pre-emptive path (an observer snapshot already reads exhausted before
// dispatch): the turn must serve on the deployment key AND the client must see
// the billable-failover warning marker instead of the normal routing marker.
// TestProxyMessages_PreemptiveSubscriptionFailoverWarnsUser: observer already
// reads exhausted — turn must serve on the deployment key and emit the
// billable-failover warning instead of the normal routing marker.

Was 4 lines narrating what the function name + assertions already convey; the why fits in 3.

Comment on lines +578 to +583
// TestProxyMessages_ReactiveSubscriptionFailoverWarnsUser guards the reactive
// path: a subscription-served Anthropic turn hits a live retryable error
// (429), so the router retries the SAME model on the Weave key. The retry
// must carry the billable-failover warning marker, not the original routing
// marker, and the retry must authenticate via x-api-key (not the spent OAuth
// bearer).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// TestProxyMessages_ReactiveSubscriptionFailoverWarnsUser guards the reactive
// path: a subscription-served Anthropic turn hits a live retryable error
// (429), so the router retries the SAME model on the Weave key. The retry
// must carry the billable-failover warning marker, not the original routing
// marker, and the retry must authenticate via x-api-key (not the spent OAuth
// bearer).
// TestProxyMessages_ReactiveSubscriptionFailoverWarnsUser: live 429 on the
// subscription triggers a retry on the Weave key — retry must carry the
// billable-failover marker and authenticate via x-api-key, not OAuth bearer.

Was 6 lines; same content fits in 3.

Comment on lines +595 to +598
// Attempts 1-3: subscription OAuth bearer. dispatchWithFallback
// retries a sole binding in place up to maxSameBindingRetries (2)
// before giving up, so the 429 must persist through all three
// same-binding attempts to reach the subscription-failover retry.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Attempts 1-3: subscription OAuth bearer. dispatchWithFallback
// retries a sole binding in place up to maxSameBindingRetries (2)
// before giving up, so the 429 must persist through all three
// same-binding attempts to reach the subscription-failover retry.
// Attempts 1-3: dispatchWithFallback retries the same binding up to
// maxSameBindingRetries (2), so 429 must persist all three attempts
// before the subscription-failover retry fires.

Was 4 lines; same non-obvious WHY fits in 3.

Comment thread internal/proxy/service.go
Comment on lines +2541 to +2544
// Subscription hit its 5h/7d limit pre-dispatch: the turn is about to
// serve on the billable Weave/BYOK key instead. Not gated by the
// routing-marker opt-out — same billing-state-change rule as the
// subscription-only warning above.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Subscription hit its 5h/7d limit pre-dispatch: the turn is about to
// serve on the billable Weave/BYOK key instead. Not gated by the
// routing-marker opt-out — same billing-state-change rule as the
// subscription-only warning above.
// Not gated by the routing-marker opt-out — billing state change,
// same rule as subscriptionOnlyWarningMarker above.

Was 4 lines; the one non-obvious point (opt-out exemption) fits in 2.

Comment thread internal/proxy/service.go
Comment on lines +2886 to +2888
// Swap in the billable-failover warning for this retry attempt — safe
// because reaching here already required preludeBuf.Committed() ==
// false, so nothing under the original marker has hit the wire yet.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Swap in the billable-failover warning for this retry attempt — safe
// because reaching here already required preludeBuf.Committed() ==
// false, so nothing under the original marker has hit the wire yet.
// Safe: preludeBuf.Committed() == false here, so no bytes are committed under the original marker.

Was 3 lines; the invariant fits on one.

Comment on lines +216 to +221
// subscriptionFailoverWarningMarker is prepended when a subscription-served
// Anthropic turn fails over to the billable Weave/BYOK key mid-session (5h/7d
// unified rate-limit rejection, or an OAuth credential rejection). Distinct
// from subscriptionOnlyWarningMarker: that one signals a permanent, org-level
// state (credits depleted, until top-up); this one is a per-turn, self-healing
// condition, so the copy says "this turn" rather than pointing at a CTA.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// subscriptionFailoverWarningMarker is prepended when a subscription-served
// Anthropic turn fails over to the billable Weave/BYOK key mid-session (5h/7d
// unified rate-limit rejection, or an OAuth credential rejection). Distinct
// from subscriptionOnlyWarningMarker: that one signals a permanent, org-level
// state (credits depleted, until top-up); this one is a per-turn, self-healing
// condition, so the copy says "this turn" rather than pointing at a CTA.
// subscriptionFailoverWarningMarker is prepended when a subscription turn fails
// over to the billable Weave/BYOK key (5h/7d rate-limit or OAuth rejection).
// Unlike subscriptionOnlyWarningMarker (permanent org-level state), this is
// per-turn and self-healing — no CTA.

Was 6 lines; the permanent-vs-per-turn contrast fits in 4.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 78a62f4. Configure here.

Comment thread internal/proxy/service.go
// serve on the billable Weave/BYOK key instead. Not gated by the
// routing-marker opt-out — same billing-state-change rule as the
// subscription-only warning above.
marker = subscriptionFailoverWarningMarker

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failover warning ignores routed provider

High Severity

The preemptive path sets subscriptionFailoverWarningMarker whenever claudeSubscriptionExhausted is true, without checking that decision.Provider is Anthropic. After a limit hit, subsidy drops and routing often picks OSS/Gemini, so those unrelated turns still get the billable Claude-failover warning even though no subscription→Weave Anthropic failover occurred.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78a62f4. Configure here.

Comment thread internal/proxy/service.go
// Swap in the billable-failover warning for this retry attempt — safe
// because reaching here already required preludeBuf.Committed() ==
// false, so nothing under the original marker has hit the wire yet.
subAttempt := s.anthropicNativeAttempt(env, r, subPrep, sink, preludeBuf, subscriptionFailoverWarningMarker, setExtractor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning misstates non-limit failovers

Medium Severity

The reactive retry always swaps in subscriptionFailoverWarningMarker, whose copy claims a Claude usage-limit hit and automatic window reset. That path also runs for any IsRetryable fault (5xx, 408, stalls, transport) and for OAuth authentication_error/permission_error, so users can be told they burned plan quota when the cause was a transient outage or rejected token.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78a62f4. Configure here.

…over bit on router.call

Downstream consumers of the OTLP router.call log record (WorkWeave's backend
ingest) have no way to detect a subscription->billable-key failover today:
credential_source only reaches the router's OWN Postgres telemetry insert
(InsertTelemetryParams), never the OTel span/log attrs that recordCallLog
reuses as its metadata base.

Add credential.source to both ProxyMessages' and
ProxyOpenAIChatCompletion's upstream span builders, computed once via the
existing credentialKeyParts(ctx) (moved earlier so it's in scope for the
builder, not just the later telemetry-insert call site).

Also add dispatch.subscription_failover_billable, the OR of the pre-emptive
(subscriptionFailingOver, an already-exhausted observer snapshot skipping
dispatch entirely) and reactive (subscriptionFailoverUsed, a live
429/OAuth-rejection retry) subscription-failover paths on the Anthropic
side. The existing dispatch.subscription_failover only covers the reactive
branch -- on its own it would under-report, since the pre-emptive path is
the more common trigger in practice.

Extends the two failover_integration_test.go tests added in the prior
commit to also assert CredentialSource on the captured telemetry row.
@steventohme

Copy link
Copy Markdown
Collaborator Author

Pushed a second commit adding credential.source and dispatch.subscription_failover_billable to both Anthropic and OpenAI upstream span builders. This closes a gap needed for a follow-up WorkWeave-side admin alert: credential_source previously only reached this router's own Postgres telemetry insert, never the OTel span/log attrs that recordCallLog reuses as the router.call metadata base -- so WorkWeave's backend ingest (which consumes that OTLP log record) had no way to see it. dispatch.subscription_failover_billable combines both the pre-emptive and reactive failover paths since the existing dispatch.subscription_failover only covers the reactive one.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


Claude finished @steventohme's taskView job


  • Run git diff to get added comment lines
  • Analyze added comment blocks (3+ consecutive lines)
  • Post review if any are flagged

Posted 2 comment-length suggestions (advisory, won't block merge) for the new commit:

File Lines Issue
service.go 2958–2961 4-line "computed here" note; the OTLP-vs-Postgres why fits in 3
service.go 2998–3004 7-line inline attr comment; the both-paths distinction fits in 3

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nits. Won’t block merge.

Comment thread internal/proxy/service.go
Comment on lines +2958 to +2961
// Computed here (rather than only at the later Postgres-telemetry site)
// so credential.source and the combined subscription-failover flag reach
// the router.call OTLP log record — WorkWeave's ingest reads that record,
// not this router's own Postgres telemetry table.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Computed here (rather than only at the later Postgres-telemetry site)
// so credential.source and the combined subscription-failover flag reach
// the router.call OTLP log record — WorkWeave's ingest reads that record,
// not this router's own Postgres telemetry table.
// Computed here so credential.source and the subscription-failover flag
// reach the router.call OTLP log record — WorkWeave’s ingest reads that,
// not this router’s Postgres telemetry table.

Was 4 lines; the non-obvious why (OTLP vs. Postgres telemetry site) fits in 3.

Comment thread internal/proxy/service.go
Comment on lines +2998 to +3004
// Combined pre-emptive (subscriptionFailingOver, an exhausted observer
// snapshot skipping dispatch entirely) OR reactive
// (subscriptionFailoverUsed, a live 429/OAuth-rejection retry) signal —
// either means this turn is billed on the Weave/BYOK key instead of the
// caller's own subscription. dispatch.subscription_failover above only
// covers the reactive branch; this one is the authoritative bit for
// admin alerting downstream.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Combined pre-emptive (subscriptionFailingOver, an exhausted observer
// snapshot skipping dispatch entirely) OR reactive
// (subscriptionFailoverUsed, a live 429/OAuth-rejection retry) signal —
// either means this turn is billed on the Weave/BYOK key instead of the
// caller's own subscription. dispatch.subscription_failover above only
// covers the reactive branch; this one is the authoritative bit for
// admin alerting downstream.
// Combined pre-emptive (subscriptionFailingOver) OR reactive
// (subscriptionFailoverUsed) signal — dispatch.subscription_failover above
// covers only the reactive branch; this is the authoritative bit for alerting.

Was 7 lines; the one non-obvious point (covers both paths, unlike the field above) fits in 3.

Comment thread internal/proxy/service.go
// Swap in the billable-failover warning for this retry attempt — safe
// because reaching here already required preludeBuf.Committed() ==
// false, so nothing under the original marker has hit the wire yet.
subAttempt := s.anthropicNativeAttempt(env, r, subPrep, sink, preludeBuf, subscriptionFailoverWarningMarker, setExtractor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Usage-limit warning misclassifies failures

This retry also runs for generic retryable errors and OAuth credential rejections, but it always injects text saying that the Claude subscription “hit its usage limit.” A recovered 503, timeout, transport failure, or OAuth 401 can therefore be shown to the customer as quota exhaustion even when no usage limit was involved. Reserve quota-specific copy for positively identified subscription exhaustion and use neutral failover wording for other recovery paths.

Artifacts

Focused hermetic ProxyMessages test source for 503 and OAuth rejection

  • The executed test configures a real ProxyMessages request and Anthropic HTTP client with local upstream responses for non-quota failures, then checks the returned SSE marker; it documents the reproducible coverage.

Baseline quota-exhaustion subscription failover test passed

  • The existing 429 subscription failover test was executed from `/home/user/repo` and passed after retrying with suppressed OAuth credentials; it confirms the baseline quota flow.

503 and OAuth rejection retries emit quota-exhaustion wording

  • The focused hermetic test was executed from `/home/user/repo` and failed as expected: both a successful 503 recovery and a successful OAuth-401 recovery sent the client the quota-exhaustion marker, confirming the bug.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +222 to +223
const subscriptionFailoverWarningMarker = routingMarkerPrefix +
"your Claude subscription hit its usage limit, so this turn is running on the Weave router key and will be billed. Full routing resumes automatically once your subscription window resets.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 BYOK fallback misidentified as Weave

After suppressing the subscription credential, credential resolution can select the customer’s Anthropic BYOK key. The retry then authenticates with that customer key, but this fixed message says that it is running on the Weave router key and that the turn will be billed. Select the warning based on the resolved fallback credential source so BYOK retries accurately describe credential ownership and billing.

Artifacts

Focused hermetic ProxyMessages failover test source

  • Authored test drives subscription 429 retries followed by a customer Anthropic BYOK fallback and asserts the customer-visible marker, establishing the executable scenario.

Subscription failover runtime output with customer BYOK fallback

  • Executed `go test` output shows three subscription attempts, a successful fourth BYOK-authenticated retry, and the retained Weave-billing marker; the mismatch is confirmed.

Diff whitespace validation output

  • Executed `git diff --check` completed with exit code 0 after adding only the focused test, confirming no patch formatting errors.

View artifacts

T-Rex Ran code and verified through T-Rex

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex produced a P1 finding-proof and validated it against the focused hermetic ProxyMessages test for 503 and OAuth rejection, using the associated test source and logs.
  • T-Rex produced a P1 finding-proof and validated it against the focused hermetic ProxyMessages failover test source, the BYOK fallback runtime output, and the diff whitespace validation results.
  • T-Rex produced a P1 finding-proof with no artifacts attached, as part of the broader validation effort.
  • General contract validation confirms that the baseline 429 quota-exhaustion flow passes and the focused non-quota test exits after observing the specific retry message.
  • General contract validation ties the observed behavior to internal code paths that suppress and re-resolve credentials, inject a subscriptionFailoverWarningMarker, notes a misleading customer-visible copy, and records before/after credential behavior in a runtime log.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (2)

  1. General comment

    P1 Non-quota subscription failovers claim the Claude usage limit was reached

    • Bug
      • When an Anthropic request authenticated with a Claude subscription receives a retryable 503 or an OAuth 401 authentication rejection, and the deployment-key retry succeeds, the client SSE stream is prepended with “your Claude subscription hit its usage limit.” Neither condition proves quota exhaustion.
    • Cause
      • The eligibility condition at internal/proxy/service.go:2861 includes all providers.IsRetryable failures and anthropicOAuthCredentialRejected, while the retry at line 2889 always uses the quota-specific subscriptionFailoverWarningMarker.
    • Fix
      • Use a neutral billable-failover marker for timeout, transport, 5xx, and OAuth credential failures, reserving subscriptionFailoverWarningMarker for positively identified subscription quota/rate-limit exhaustion; add the focused 503 and OAuth regression cases.

    T-Rex Ran code and verified through T-Rex

  2. General comment

    P1 Subscription failover can use customer Anthropic BYOK while claiming Weave billing

    • Bug
      • When a Claude subscription request exhausts its retries, the failover suppresses the subscription credential and re-resolves credentials. With an Anthropic external key present, the retry authenticates with the customer's BYOK key (x-api-key). The client-visible marker nevertheless says that the turn is running on the Weave router key and will be billed.
    • Cause
      • resolveAndInjectCredentials falls through from a suppressed subscription to Anthropic BYOK. The failover path always constructs its retry with subscriptionFailoverWarningMarker, whose fixed wording attributes the request to the Weave router key.
    • Fix
      • Derive the failover marker from the resolved fallback credential source. Preserve the billed/Weave-key wording only for a deployment-key fallback; use accurate customer-BYOK wording (and billing semantics) when CredentialsFromContext(subCtx).Source == "byok". Add this focused regression test to the suite.

    T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "feat(proxy): warn Claude Code when a sub..." | Re-trigger Greptile

@steventohme

Copy link
Copy Markdown
Collaborator Author

Depended on by workweave/WorkWeave#11247 (subscription-failover admin alert) — that PR reads credential.source / dispatch.subscription_failover_billable from the OTLP log record this PR adds them to.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants