Files
sub2api/backend/dev-docs/upstream-error-proxy-attribution.md
Wei-Shaw 4c1f920d58 fix(ops): close proxy attribution gaps and bound queued upstream events
Follow-up to the event-time proxy snapshot: every upstream attempt now
carries attribution, the direct/unknown contract is enforced everywhere,
and the queued upstream_errors array has hard bounds again.

Attribution coverage
- compact fallback retry helper and the streaming compact failover event
  now stamp proxy_id/proxy_name; the non-streaming SSE compact retry
  previously dropped its first attempt on `continue` and now records it
- raw chat-completions stream truncation events (new on main) attributed
- Anthropic/Bedrock transport errors get attribution inside the shared
  handleUpstreamTransportError helper, so all five callers inherit it
- OpenAI first-output timeout takes attribution from the caller: HTTP
  callers pass the HTTP rule, the WS passthrough relay passes the WS rule
  (no managed proxy => unknown, never direct, because http.DefaultClient
  may honor environment proxies)

Contract hardening
- single opsUpstreamProxyAttribution returns (id, name) from one decision;
  a hydrated Proxy without a durable ID is unknown, not id=null+name
- both normalizers (struct at append time, JSON on detail reads) enforce
  proxy_id==null <=> sentinel name; blank managed name => "proxy"
- detail-read normalization edits only the attribution keys via sjson, so
  keys from older struct versions and stored key order are preserved

Queue bounds
- keep at most 256 attempts and ~512 KiB serialized per entry, newest
  first; the oldest retained event records dropped_earlier_attempts
- attempts outside the 16-event body window trim url/message to 512 bytes
- restore the empty-event drop rule, judged before the body window clears
  detail so older real attempts survive

Tests cover each site above (managed proxy, direct, WS unknown), the
normalizer invariants, key/order preservation, and the count/byte bounds.
2026-09-03 15:11:36 +08:00

6.6 KiB

Upstream error proxy attribution

Scope

Gateway inference errors are stored in ops_error_logs.upstream_errors. Each array item represents one upstream attempt, including same-account retries and cross-account failover attempts. Proxy attribution is stored on that item; it must not be reconstructed later from accounts.proxy_id.

This repository has one gateway implementation under backend/internal/service. There is no separate gateway/backend source copy to synchronize.

Event contract

Every newly persisted event contains these fields. proxy_id is emitted even when its value is JSON null:

Field Meaning
proxy_id Managed proxy ID used by the attempt; null for direct or unknown routes.
proxy_name Snapshotted proxy name, direct/no_proxy, or unknown.

Only proxy ID and name are stored. Proxy URL, protocol endpoint, host, port, username, password, and authorization data are excluded.

The two sentinel names have strict meanings:

  • direct/no_proxy: the transport was explicitly given no proxy.
  • unknown: the route cannot be proven from event-time evidence. This includes legacy events, pre-transport credential failures, and OpenAI WebSocket use of the default HTTP client.

Invariant: proxy_id is null if and only if proxy_name is one of the two sentinels. A managed proxy with a blank name (defensive only; proxies.name is non-empty) is labeled proxy. Both the struct normalizer used at append time and the JSON normalizer used on detail reads enforce this, so an event can never carry proxy_id=null together with a real proxy name.

Why the account snapshot is event-time evidence

Account selection loads ProxyID and Proxy into one in-memory snapshot. HTTP, TLS-fingerprint, Bedrock, Gemini, Antigravity, OpenAI, and OpenAI WebSocket forwarding use that snapshot for managed proxy routing. Inference transports use a managed proxy only when both the binding ID and its hydrated proxy object are present; the attribution accessors use the same rule. OpenAI WebSocket additionally retains the existing default-client environment proxy behavior when that snapshot does not yield a usable proxy. The transport does not switch to a database backup proxy during a request.

Expired proxy fallback happens before scheduling by atomically rewriting the account binding:

  • backup proxy: proxy_id becomes the backup ID and proxy_fallback_origin_id stores the expired proxy ID;
  • direct fallback: proxy_id becomes null and proxy_fallback_origin_id stores the expired proxy ID.

Custom Anthropic relays receive the same snapshotted proxy as their encoded relay proxy parameter. OpenAI WebSocket requests keep the existing transport behavior: configured account proxies use the proxy client, while an empty account proxy leaves the HTTP client unset so coder/websocket uses http.DefaultClient, including its HTTP_PROXY, HTTPS_PROXY, and NO_PROXY handling.

For WebSocket errors without a usable account proxy, the event stores proxy_id=null and proxy_name=unknown. It does not infer whether the default client selected an environment proxy or a direct connection after the fact. This applies to every event produced while a request is on the WebSocket transport, including WS handshake/fallback errors and the first-output timeout raised by the WS passthrough relay; opsUpstreamWSProxyAttribution is the accessor for those sites and never returns direct/no_proxy.

Credential acquisition failures that occur before the inference transport is opened are also recorded as unknown. The account's proxy binding is not treated as proof that the credential operation or an inference request used that route.

Each failure site constructs a complete OpsUpstreamErrorEvent. Its literal sets proxy_id and proxy_name from the same route input used by the attempt. Credential-free accessors copy only the managed proxy ID and name. A fallback proxy needs no special event flag: its event-time ID and name are the historical route evidence. Shared helpers that assemble the event on behalf of a caller (handleUpstreamTransportError, handleOpenAIUpstreamTransportError, appendOpenAICompactFallbackRetryOps, recordOpenAIRawStreamTruncation, newOpenAIFirstOutputTimeoutError) stamp attribution themselves, so a caller cannot forget it. Every non-test OpsUpstreamErrorEvent{ literal in backend/internal/service must either set ProxyName or be passed to one of those helpers; grep for literals missing ProxyName before merging changes that add a failure site.

appendOpsUpstreamError accepts only the completed event. It does not receive an Account, query current account state, or infer proxy attribution while appending. Once appended, the event contains only scalar snapshot values, so later account mutation or failover cannot change an earlier event.

Queue sanitization keeps attempt events newest-first under hard bounds:

  • the newest 16 attempts retain detail and upstream_response_body (each capped at the queue body limit) plus upstream_url/message up to 2048 bytes;
  • older attempts keep timestamps, account, proxy, status, kind, stage, scope, reason and a upstream_url/message trimmed to 512 bytes; an attempt whose only payload was a cleared detail is still retained;
  • at most 256 attempts and roughly 512 KiB of serialized JSON are kept per request. When either bound drops attempts, the oldest retained event carries dropped_earlier_attempts with the number of discarded earlier attempts. The newest attempt is always retained because it decides the request outcome.

Legacy events and analytics

Legacy JSON is not rewritten in the database. Detail reads edit only the proxy_id/proxy_name keys of events that lack valid attribution, so keys written by older struct versions and the stored key order are preserved. The shared parser applies the same rule to the decoded structs. Both materialize missing attribution as:

{
  "proxy_id": null,
  "proxy_name": "unknown"
}

Proxy and region aggregation must use the event fields first. A safe grouping rule is:

  1. A non-null event proxy_id groups by that ID and uses the event proxy_name as the historical label. A join to proxies may enrich current metadata but must not replace the event label.
  2. proxy_id=null and proxy_name=direct/no_proxy groups as direct.
  3. Missing attribution, or proxy_id=null and proxy_name=unknown, groups as unknown. Do not join through the account's current proxy_id and present it as historical attribution.

If an operator deliberately produces a current-account cohort for legacy data, the report must be labeled as a current snapshot; it is not historical proxy attribution.