Merge pull request #6179 from wwcchh0123/codex/upstream-error-proxy-attribution

fix(ops): preserve event-time proxy attribution for upstream errors
This commit is contained in:
Wesley Liddick
2026-09-03 15:34:59 +08:00
committed by GitHub
55 changed files with 1472 additions and 44 deletions
@@ -0,0 +1,136 @@
# 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:
```json
{
"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.
@@ -146,6 +146,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
logBody, maxBytes := s.getLogConfig()
upstreamDetail := s.getUpstreamErrorDetail(respBody)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -203,6 +205,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
})
if retryErr != nil {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -242,6 +246,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
retryUpstreamDetail = truncateString(string(retryBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -278,6 +284,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
errMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -359,6 +367,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
logger.LegacyPrintf("service.antigravity_gateway", "%s status=400 prompt_too_long=true upstream_message=%q request_id=%s body=%s", prefix, upstreamMsg, resp.Header.Get("x-request-id"), truncateForLog(respBody, maxBytes))
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -385,6 +395,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
upstreamDetail := s.getUpstreamErrorDetail(respBody)
log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -403,6 +415,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
upstreamDetail := s.getUpstreamErrorDetail(respBody)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -424,6 +424,8 @@ func (s *AntigravityGatewayService) handleAntigravityCompatHTTPError(
if s.shouldFailoverUpstreamError(resp.StatusCode) {
message := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(body)))
event := OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -493,6 +495,8 @@ func (s *AntigravityGatewayService) writeMappedAntigravityCompatError(
message := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(body)))
setOpsUpstreamError(c, upstreamStatus, message, s.getUpstreamErrorDetail(body))
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -227,6 +227,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(signatureCheckBody)))
upstreamDetail := s.getUpstreamErrorDetail(signatureCheckBody)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -272,6 +274,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
retryOpsBody = retryUnwrapped
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -292,6 +296,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
} else {
if switchErr, ok := IsAntigravityAccountSwitchError(retryErr); ok {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -305,6 +311,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
}
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -346,6 +354,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
if resp.StatusCode == http.StatusBadRequest && isGoogleProjectConfigError(strings.ToLower(upstreamMsg)) {
log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -360,6 +370,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
if s.shouldFailoverUpstreamError(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -375,6 +387,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
contentType = "application/json"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -548,6 +548,8 @@ urlFallbackLoop:
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(p.account),
ProxyName: opsUpstreamProxyName(p.account),
Platform: p.account.Platform,
AccountID: p.account.ID,
AccountName: p.account.Name,
@@ -625,6 +627,8 @@ urlFallbackLoop:
upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(p.account),
ProxyName: opsUpstreamProxyName(p.account),
Platform: p.account.Platform,
AccountID: p.account.ID,
AccountName: p.account.Name,
@@ -660,6 +664,8 @@ urlFallbackLoop:
upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
appendOpsUpstreamError(p.c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(p.account),
ProxyName: opsUpstreamProxyName(p.account),
Platform: p.account.Platform,
AccountID: p.account.ID,
AccountName: p.account.Name,
@@ -695,6 +695,8 @@ func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, accou
upstreamDetail := s.getUpstreamErrorDetail(body)
setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -140,6 +140,8 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
respBody, _ := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -184,6 +186,8 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
s.handleRetryExhaustedSideEffects(ctx, resp, account)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -218,6 +222,8 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
s.handleFailoverSideEffects(ctx, resp, account, input.RequestModel)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -225,6 +225,8 @@ func (s *GatewayService) executeBedrockUpstream(
respBody, _ := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -276,6 +278,8 @@ func (s *GatewayService) handleBedrockUpstreamErrors(
s.handleRetryExhaustedSideEffects(ctx, resp, account)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -300,6 +304,8 @@ func (s *GatewayService) handleBedrockUpstreamErrors(
s.handleFailoverSideEffects(ctx, resp, account)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -270,6 +270,8 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
if err != nil {
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -324,6 +326,8 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -405,6 +405,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if s.shouldRectifySignatureError(ctx, account, respBody, reqModel) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -466,6 +468,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
_ = retryResp.Body.Close()
if retryReadErr == nil && retryResp.StatusCode == 400 && s.isSignatureErrorPattern(ctx, account, retryRespBody) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -506,6 +510,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
_ = retryResp2.Body.Close()
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -545,6 +551,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
errMsg := extractUpstreamErrorMessage(respBody)
if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -615,6 +623,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
respBody, _ := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -669,6 +679,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
s.handleRetryExhaustedSideEffects(ctx, resp, account)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -704,6 +716,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
s.handleFailoverSideEffects(ctx, resp, account, reqModel)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
UpstreamStatusCode: resp.StatusCode,
@@ -746,6 +760,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -822,6 +838,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -148,6 +148,8 @@ func (s *GatewayService) ForwardAsChatCompletions(
if s.shouldFailoverUpstreamError(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -161,6 +161,8 @@ func (s *GatewayService) ForwardAsResponses(
if s.shouldFailoverUpstreamError(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -398,6 +398,8 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
UpstreamStatusCode: resp.StatusCode,
@@ -578,6 +580,8 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
UpstreamStatusCode: resp.StatusCode,
@@ -27,7 +27,8 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst
// proxy / DNS / TCP / TLS). It:
// 1. records the failure in Ops error logs (status 0, kind=request_error) —
// the caller passes path-specific fields (UpstreamURL, Passthrough) via
// event; identity and classification fields are filled here;
// event; identity, proxy attribution and classification fields are
// filled here from the same account snapshot that built the transport;
// 2. for durable faults (expired/rejected proxy creds, dead proxy,
// DNS/routing) temporarily unschedules the account and logs a stable warn
// event that alert rules can key on;
@@ -40,6 +41,7 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst
func (s *GatewayService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, event OpsUpstreamErrorEvent) error {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account)
event.Platform = account.Platform
event.AccountID = account.ID
event.AccountName = account.Name
@@ -72,6 +72,61 @@ func TestHandleUpstreamTransportError_TransientFailsOverWithoutEviction(t *testi
}
}
// TestHandleUpstreamTransportError_AttributesEventTimeProxy pins that the
// shared helper stamps proxy attribution from the same account snapshot the
// transport used, so every Anthropic/Bedrock caller inherits it.
func TestHandleUpstreamTransportError_AttributesEventTimeProxy(t *testing.T) {
proxyID := int64(10060)
tests := []struct {
name string
account *Account
wantID *int64
wantName string
}{
{
name: "managed proxy",
account: &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "wldsg82-ipv6-10060"}},
wantID: &proxyID,
wantName: "wldsg82-ipv6-10060",
},
{
name: "direct",
account: &Account{ID: 149, Name: "acc", Platform: PlatformAnthropic},
wantName: opsProxyNameDirect,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &GatewayService{accountRepo: &transportTempUnschedRepoStub{}}
c := newTransportErrorTestGin(t)
_ = s.handleUpstreamTransportError(context.Background(), c, tt.account,
errors.New("EOF"), OpsUpstreamErrorEvent{UpstreamURL: "https://api.anthropic.com/v1/messages", Passthrough: true})
raw, ok := c.Get(OpsUpstreamErrorsKey)
if !ok {
t.Fatal("no upstream events recorded")
}
events := raw.([]*OpsUpstreamErrorEvent)
if len(events) != 1 {
t.Fatalf("events = %d, want 1", len(events))
}
ev := events[0]
if tt.wantID == nil {
if ev.ProxyID != nil {
t.Fatalf("proxy_id = %d, want null", *ev.ProxyID)
}
} else if ev.ProxyID == nil || *ev.ProxyID != *tt.wantID {
t.Fatalf("proxy_id = %v, want %d", ev.ProxyID, *tt.wantID)
}
if ev.ProxyName != tt.wantName {
t.Fatalf("proxy_name = %q, want %q", ev.ProxyName, tt.wantName)
}
if !ev.Passthrough || ev.UpstreamURL == "" || ev.Kind != "request_error" {
t.Fatalf("caller-supplied fields lost: %+v", ev)
}
})
}
}
// TestHandleUpstreamTransportError_PersistentEvictsAccount pins the contract
// for durable faults (dead endpoint / DNS / proxy credentials): fail over AND
// temporarily unschedule the account for the transport cooldown.
@@ -127,6 +127,8 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -172,6 +174,8 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -225,6 +229,8 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(evBody)))
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -816,6 +822,8 @@ func (s *GeminiMessagesCompatService) writeGeminiChatCompletionsMappedError(
setOpsUpstreamError(c, upstreamStatus, upstreamMsg, "")
if account != nil {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -785,6 +785,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -823,6 +825,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -909,6 +913,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -976,6 +982,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1010,6 +1018,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
log.Printf("[Gemini] status=400 google_config_error failover=true upstream_message=%q account=%d", upstreamMsg, account.ID)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1038,6 +1048,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1324,6 +1336,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1392,6 +1406,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1492,6 +1508,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
upstreamDetail = truncateString(string(evBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1523,6 +1541,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
log.Printf("[Gemini] status=400 google_config_error failover=true upstream_message=%q account=%d", upstreamMsg, account.ID)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1548,6 +1568,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
upstreamDetail = truncateString(string(evBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1678,6 +1700,8 @@ func (s *GeminiMessagesCompatService) skippedErrorPolicyFailoverError(c *gin.Con
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
upstreamDetail := s.upstreamErrorDetail(respBody)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1719,6 +1743,8 @@ func (s *GeminiMessagesCompatService) writeGeminiCustomCodeSkippedError(c *gin.C
upstreamDetail := s.upstreamErrorDetail(body)
setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1747,6 +1773,8 @@ func (s *GeminiMessagesCompatService) writeGeminiNativeUpstreamError(c *gin.Cont
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1811,6 +1839,8 @@ func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, acc
}
setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -647,6 +647,10 @@ func (s *OpenAIGatewayService) newGrokCredentialFailover(c *gin.Context, account
class.message = "Grok OAuth credentials are unavailable"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
// Credential acquisition happens before the inference transport opens,
// so the account binding is not evidence of an inference proxy route.
ProxyID: nil,
ProxyName: opsProxyNameUnknown,
Platform: PlatformGrok,
AccountID: account.ID,
Stage: string(GatewayFailureStageAccountAuth),
@@ -294,6 +294,34 @@ func TestGetRequestCredentialMapsPermanentGrokOAuthFailureAndRedactsSecrets(t *t
require.NotContains(t, events[0].Message, "leaked-refresh")
}
func TestNewGrokCredentialFailoverDoesNotAttributeInferenceProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
proxyID := int64(43)
account := &Account{
ID: 701,
Platform: PlatformGrok,
ProxyID: &proxyID,
Proxy: &Proxy{ID: proxyID, Name: "bound-proxy"},
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
err := (&OpenAIGatewayService{}).newGrokCredentialFailover(c, account, grokCredentialFailureClass{
scope: GatewayFailureScopeAccount,
reason: GrokCredentialReasonAccountChanged,
action: NextAccountRetry,
message: "credential unavailable",
})
require.Error(t, err)
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 1)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
}
func TestGetRequestCredentialPermanentMappingsPersistAndInvalidate(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
+6
View File
@@ -1239,6 +1239,8 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse(
if isGrokContentPolicyRejection(resp.StatusCode, body) {
clientMsg := grokContentPolicyClientMessage(body)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1269,6 +1271,8 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse(
if !account.ShouldHandleErrorCode(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1288,6 +1292,8 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -215,6 +215,8 @@ func (s *OpenAIGatewayService) appendOpenAICompactFallbackRetryOps(
detail = truncateString(string(payload), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -225,6 +225,120 @@ func TestOpenAIGatewayForwardRetriesExplicitNativeCompactHTTPFailureOnce(t *test
require.Equal(t, "retry", events[0].Kind)
require.Equal(t, "compact_model_fallback", events[0].Reason)
require.Equal(t, http.StatusBadRequest, events[0].UpstreamStatusCode)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameDirect, events[0].ProxyName)
}
func compactFallbackManagedProxyAccount() (*Account, *Proxy) {
proxy := &Proxy{ID: 10060, Name: "wldsg82-ipv6-10060", Protocol: "http", Host: "proxy.example", Port: 8080}
return &Account{
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
Status: StatusActive, Schedulable: true,
ProxyID: &proxy.ID, Proxy: proxy,
}, proxy
}
func requireCompactEventsAttributedTo(t *testing.T, events []*OpsUpstreamErrorEvent, proxy *Proxy) {
t.Helper()
for i, ev := range events {
require.NotNil(t, ev.ProxyID, "event %d proxy_id", i)
require.Equal(t, proxy.ID, *ev.ProxyID, "event %d proxy_id", i)
require.Equal(t, proxy.Name, ev.ProxyName, "event %d proxy_name", i)
}
}
// Non-streaming SSE compact retry: the first attempt's failure must be recorded
// as its own attempt (it previously vanished on `continue`), carrying the
// managed proxy the transport actually used.
func TestOpenAIGatewayForwardNonStreamCompactRetryRecordsAttemptWithManagedProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
MarkOpenAINativeCompactionV2(c)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid_compact_1"}},
Body: io.NopCloser(strings.NewReader("event: response.failed\n" +
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
},
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
httpUpstream: upstream,
}
account, proxy := compactFallbackManagedProxyAccount()
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, proxy.URL(), upstream.lastProxyURL)
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 1)
require.Equal(t, "retry", events[0].Kind)
require.Equal(t, "compact_model_fallback", events[0].Reason)
require.Equal(t, "rid_compact_1", events[0].UpstreamRequestID)
requireCompactEventsAttributedTo(t, events, proxy)
}
// Streaming compact fallback whose second attempt fails with a failover-class
// error: both the retry event and the failover event must carry the proxy.
func TestOpenAIGatewayForwardCompactFailoverEventCarriesManagedProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
MarkOpenAINativeCompactionV2(c)
contextFailed := "event: response.failed\n" +
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
// The fallback model's failure is an account-state error, which
// shouldFailoverOpenAIUpstreamResponse classifies as failover-worthy.
workspaceFailed := "event: response.failed\n" +
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"deactivated_workspace","message":"workspace deactivated"}}}` + "\n\n"
upstream := &httpUpstreamRecorder{responses: []*http.Response{
{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(contextFailed))},
{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(workspaceFailed))},
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
httpUpstream: upstream,
}
account, proxy := compactFallbackManagedProxyAccount()
result, err := svc.Forward(context.Background(), c, account, body)
require.Error(t, err)
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Len(t, upstream.bodies, 2)
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 2)
require.Equal(t, "retry", events[0].Kind)
require.Equal(t, "compact_model_fallback", events[0].Reason)
require.Equal(t, "failover", events[1].Kind)
requireCompactEventsAttributedTo(t, events, proxy)
}
func TestOpenAIGatewayForwardRetriesExplicitNativeCompactSSEFailureBeforeOutput(t *testing.T) {
@@ -355,6 +469,10 @@ func TestOpenAIGatewayForwardDoesNotRecurseWhenCompactFallbackAlsoFails(t *testi
require.Equal(t, "retry", events[0].Kind)
require.Equal(t, "compact_model_fallback", events[0].Reason)
require.Equal(t, "http_error", events[1].Kind)
for _, ev := range events {
require.Nil(t, ev.ProxyID)
require.Equal(t, opsProxyNameDirect, ev.ProxyName)
}
}
func TestOpenAIPassthroughCompactFallbackSecondStreamFailureUsesStandardErrorPath(t *testing.T) {
@@ -376,11 +494,7 @@ func TestOpenAIPassthroughCompactFallbackSecondStreamFailureUsesStandardErrorPat
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
httpUpstream: upstream,
}
account := &Account{
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
Status: StatusActive, Schedulable: true,
}
account, proxy := compactFallbackManagedProxyAccount()
result, err := svc.forwardOpenAIPassthrough(
context.Background(), c, account, body, body, "gpt-5.5", false, nil, true, time.Now(),
@@ -402,4 +516,6 @@ func TestOpenAIPassthroughCompactFallbackSecondStreamFailureUsesStandardErrorPat
require.Equal(t, "compact_model_fallback", events[0].Reason)
require.Equal(t, "http_error", events[1].Kind)
require.True(t, events[1].Passthrough)
require.Equal(t, proxy.URL(), upstream.lastProxyURL)
requireCompactEventsAttributedTo(t, events, proxy)
}
@@ -89,7 +89,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
account.ApplyHeaderOverrides(upstreamReq.Header)
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
@@ -97,6 +97,8 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -126,6 +128,8 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -243,10 +243,16 @@ func (s *OpenAIGatewayService) openAIFirstOutputTimeout(reasoningEffort string)
return time.Duration(seconds) * time.Second
}
// newOpenAIFirstOutputTimeoutError records the timeout as an upstream attempt
// and returns the failover error. proxyID/proxyName are supplied by the caller
// because the same deadline is enforced over HTTP and WebSocket transports,
// whose direct-route semantics differ (see opsUpstreamWSProxyAttribution).
func (s *OpenAIGatewayService) newOpenAIFirstOutputTimeoutError(
ctx context.Context,
c *gin.Context,
account *Account,
proxyID *int64,
proxyName string,
startTime time.Time,
originalModel string,
reasoningEffort string,
@@ -262,7 +268,9 @@ func (s *OpenAIGatewayService) newOpenAIFirstOutputTimeoutError(
)
requestID := strings.TrimSpace(responseHeaders.Get("x-request-id"))
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform, AccountID: account.ID, AccountName: account.Name,
ProxyID: proxyID,
ProxyName: proxyName,
Platform: account.Platform, AccountID: account.ID, AccountName: account.Name,
UpstreamStatusCode: http.StatusGatewayTimeout, UpstreamRequestID: requestID,
Kind: "first_output_timeout", Message: "OpenAI upstream produced no semantic output before the deadline",
Detail: fmt.Sprintf("phase=%s elapsed_ms=%d timeout_ms=%d", phase, elapsed.Milliseconds(), timeout.Milliseconds()),
@@ -152,6 +152,33 @@ func TestOpenAINativeFirstOutputTimeoutIgnoresPreambleAndCleansReader(t *testing
}
}
func TestNewOpenAIFirstOutputTimeoutErrorRecordsCallerProxyAttribution(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
svc := &OpenAIGatewayService{}
proxyID := int64(10060)
account := &Account{ID: 1, Name: "acc", Platform: PlatformOpenAI, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "ws-proxy"}}
// WebSocket callers pass the WS attribution; HTTP callers pass the HTTP one.
wsID, wsName := opsUpstreamWSProxyAttribution(&Account{ID: 2, Platform: PlatformOpenAI})
_ = svc.newOpenAIFirstOutputTimeoutError(context.Background(), c, &Account{ID: 2, Name: "ws", Platform: PlatformOpenAI},
wsID, wsName, time.Now(), "gpt-5.5", "", time.Second, "websocket_first_semantic_output", nil)
_ = svc.newOpenAIFirstOutputTimeoutError(context.Background(), c, account,
opsUpstreamProxyID(account), opsUpstreamProxyName(account), time.Now(), "gpt-5.5", "", time.Second, "semantic_output", nil)
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 2)
require.Equal(t, "first_output_timeout", events[0].Kind)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
require.NotNil(t, events[1].ProxyID)
require.Equal(t, proxyID, *events[1].ProxyID)
require.Equal(t, "ws-proxy", events[1].ProxyName)
}
func TestOpenAIFirstOutputTimeoutForReasoningEffort(t *testing.T) {
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
OpenAIFirstOutputTimeoutSeconds: 120,
@@ -112,6 +112,8 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError(
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -223,7 +225,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
account.ApplyHeaderOverrides(upstreamReq.Header)
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
@@ -348,7 +348,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
// 7. Send request
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
@@ -106,7 +106,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
}
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
@@ -193,6 +193,8 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -727,6 +727,50 @@ func TestForwardAsRawChatCompletions_TruncatedStreamAfterOutputFailsRequest(t *t
// 已写出的内容保持原样透传,客户端拿到的仍是它已经收到的那部分。
require.Contains(t, rec.Body.String(), `"content":"half an ans"`)
require.NotContains(t, rec.Body.String(), "data: [DONE]")
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events := rawEvents.([]*OpsUpstreamErrorEvent)
require.Len(t, events, 1)
require.Equal(t, "http_error", events[0].Kind)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameDirect, events[0].ProxyName)
}
// 截断 failover 事件必须带上本次传输真实使用的托管代理快照。
func TestForwardAsRawChatCompletions_TruncationFailoverAttributesManagedProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_empty_proxy"}},
Body: io.NopCloser(strings.NewReader("")),
}}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
proxy := &Proxy{ID: 8001, Name: "oxylabs-uk-8001", Protocol: "http", Host: "proxy.example", Port: 8080}
account := rawChatCompletionsTestAccount()
account.ProxyID = &proxy.ID
account.Proxy = proxy
_, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, proxy.URL(), upstream.lastProxyURL)
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events := rawEvents.([]*OpsUpstreamErrorEvent)
require.Len(t, events, 1)
require.Equal(t, "failover", events[0].Kind)
require.NotNil(t, events[0].ProxyID)
require.Equal(t, proxy.ID, *events[0].ProxyID)
require.Equal(t, proxy.Name, events[0].ProxyName)
}
// 上游 200 但一个 SSE 字节都没发:响应头尚未提交,应换号重试而不是回 200 空流。
@@ -80,7 +80,7 @@ func (s *OpenAIGatewayService) ForwardResponsesInputTokens(
}
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
@@ -320,7 +320,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
}
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
@@ -1015,7 +1015,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
headerGuard.close()
return nil, s.newOpenAIFirstOutputTimeoutError(
ctx, c, account, startTime, originalModel, reasoningEffortValue,
ctx, c, account, opsUpstreamProxyID(account), opsUpstreamProxyName(account),
startTime, originalModel, reasoningEffortValue,
firstOutputTimeout, "response_headers", nil,
)
}
@@ -1108,6 +1109,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
upstreamDetail = truncateString(string(respBody), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1173,6 +1176,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal)
if s.shouldFailoverOpenAIUpstreamResponse(compactResp.StatusCode, signal.message, compactBody) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1204,6 +1209,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
); retry {
s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, false)
body = retryBody
requestView = newOpenAIRequestView(body)
upstreamModel = fallbackModel
@@ -170,6 +170,8 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1412,6 +1414,8 @@ func (s *OpenAIGatewayService) describeGrokComposerImage(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -623,6 +623,8 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -374,7 +374,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
// 7. Send request
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
// Grok may reject encrypted reasoning replayed under a different OAuth
@@ -689,6 +689,8 @@ func (s *OpenAIGatewayService) recordOpenAIMessagesStreamUpstreamError(c *gin.Co
message = sanitizeUpstreamErrorMessage(message)
setOpsUpstreamError(c, http.StatusBadGateway, message, "")
event := OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: PlatformOpenAI,
UpstreamStatusCode: http.StatusBadGateway,
UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
@@ -86,7 +86,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint(
}
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
@@ -894,6 +894,8 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -960,6 +962,8 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
_ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1676,6 +1680,8 @@ func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
if c != nil {
setOpsUpstreamError(c, statusCode, message, detail)
event := OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: PlatformOpenAI,
UpstreamStatusCode: statusCode,
UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
@@ -925,7 +925,8 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
markEventProcessed(ev)
}
return resultWithUsage(), s.newOpenAIFirstOutputTimeoutError(
ctx, c, account, startTime, originalModel, reasoningEffort,
ctx, c, account, opsUpstreamProxyID(account), opsUpstreamProxyName(account),
startTime, originalModel, reasoningEffort,
firstOutputTimeout, "semantic_output", resp.Header,
)
@@ -111,7 +111,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
}
proxyURL := ""
if account.Proxy != nil {
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
@@ -892,7 +892,10 @@ func (s *OpenAIGatewayService) writeOpenAIWSFallbackErrorResponse(c *gin.Context
setOpsUpstreamError(c, statusCode, upstreamMessage, "")
if account != nil {
proxyID, proxyName := opsUpstreamWSProxyAttribution(account)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: proxyID,
ProxyName: proxyName,
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -549,6 +549,8 @@ func (s *OpenAIGatewayService) handleErrorResponse(
if isOpenAIRequestBodyTooLargeError(resp.StatusCode, upstreamMsg, body) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -596,6 +598,8 @@ func (s *OpenAIGatewayService) handleErrorResponse(
// Check custom error codes
if !account.ShouldHandleErrorCode(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -633,6 +637,8 @@ func (s *OpenAIGatewayService) handleErrorResponse(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -798,6 +804,8 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
// return a generic error without exposing upstream details.
if !account.ShouldHandleErrorCode(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -828,6 +836,8 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -53,6 +53,8 @@ func newGrokMissingUsageFailoverError(c *gin.Context, account *Account, upstream
setOpsUpstreamError(c, http.StatusBadGateway, grokMissingUsageMessage, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: PlatformGrok,
AccountID: accountID,
AccountName: accountName,
@@ -631,6 +631,8 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -650,6 +652,8 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -957,6 +957,8 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
// handleCompatErrorResponse).
if !account.ShouldHandleErrorCode(resp.StatusCode) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -997,6 +999,8 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
kind = "failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1826,6 +1830,8 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -1852,6 +1858,8 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -2024,7 +2032,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
kind = "retry_exhausted_failover"
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform, AccountID: account.ID, AccountName: account.Name,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform, AccountID: account.ID, AccountName: account.Name,
UpstreamStatusCode: statusCode, UpstreamRequestID: requestID, UpstreamURL: upstreamURL,
Kind: kind, Message: message,
})
@@ -2066,6 +2076,8 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
}
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -123,6 +123,8 @@ func recordOpenAIRawStreamTruncation(
setOpsUpstreamError(c, http.StatusBadGateway, message, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: platform,
AccountID: accountID,
AccountName: accountName,
@@ -247,6 +247,8 @@ func newOpenAISilentRefusalFailoverError(c *gin.Context, account *Account, upstr
setOpsUpstreamError(c, http.StatusBadGateway, openAISilentRefusalUpstreamMessage, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: platform,
AccountID: accountID,
AccountName: accountName,
@@ -284,6 +286,8 @@ func newOpenAIResponsesEmptyCompletedFailoverError(c *gin.Context, account *Acco
setOpsUpstreamError(c, http.StatusBadGateway, openAIResponsesEmptyCompletedMessage, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: platform,
AccountID: accountID,
AccountName: accountName,
@@ -109,6 +109,8 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
@@ -40,17 +40,23 @@ func newOpenAITransportErrTestContext() (*gin.Context, *httptest.ResponseRecorde
}
type failingOpenAIHTTPUpstream struct {
err error
calls int
err error
calls int
proxyURL string
proxyURLs []string
}
func (u *failingOpenAIHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
func (u *failingOpenAIHTTPUpstream) Do(_ *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) {
u.calls++
u.proxyURL = proxyURL
u.proxyURLs = append(u.proxyURLs, proxyURL)
return nil, u.err
}
func (u *failingOpenAIHTTPUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
func (u *failingOpenAIHTTPUpstream) DoWithTLS(_ *http.Request, proxyURL string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
u.calls++
u.proxyURL = proxyURL
u.proxyURLs = append(u.proxyURLs, proxyURL)
return nil, u.err
}
@@ -60,7 +66,14 @@ func (u *failingOpenAIHTTPUpstream) DoWithTLS(_ *http.Request, _ string, _ int64
func TestHandleOpenAIUpstreamTransportError_PersistentEvictsAndFailsOver(t *testing.T) {
repo := &openaiTransportAccountRepoStub{}
svc := &OpenAIGatewayService{accountRepo: repo}
account := &Account{ID: 4627, Name: "proxy-expired", Platform: PlatformOpenAI}
proxyID := int64(10060)
account := &Account{
ID: 4627,
Name: "proxy-expired",
Platform: PlatformOpenAI,
ProxyID: &proxyID,
Proxy: &Proxy{ID: proxyID, Name: "wldsg82-ipv6-10060"},
}
c, rec := newOpenAITransportErrTestContext()
before := time.Now()
@@ -85,6 +98,15 @@ func TestHandleOpenAIUpstreamTransportError_PersistentEvictsAndFailsOver(t *test
// Must NOT write a response body — the handler owns the (failover) response.
require.Equal(t, 0, rec.Body.Len())
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 1)
require.NotNil(t, events[0].ProxyID)
require.Equal(t, proxyID, *events[0].ProxyID)
require.Equal(t, "wldsg82-ipv6-10060", events[0].ProxyName)
}
// A transient blip should fail over but must NOT evict the account.
@@ -191,12 +213,22 @@ func TestForwardAsRawChatCompletions_TransportErrorFailsOver(t *testing.T) {
},
},
}
proxyID := int64(8001)
proxy := &Proxy{
ID: proxyID,
Name: "oxylabs-uk-8001",
Protocol: "http",
Host: "proxy.example",
Port: 8080,
}
account := &Account{
ID: 81,
Name: "oc-20053",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://opencode.ai/zen/v1"},
ProxyID: &proxyID,
Proxy: proxy,
}
c, rec := newOpenAITransportErrTestContext()
body := []byte(`{"model":"deepseek-v4-flash-free","messages":[{"role":"user","content":"hello"}]}`)
@@ -204,11 +236,62 @@ func TestForwardAsRawChatCompletions_TransportErrorFailsOver(t *testing.T) {
_, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
require.Equal(t, 1, upstream.calls)
require.Equal(t, proxy.URL(), upstream.proxyURL)
var fo *UpstreamFailoverError
require.True(t, errors.As(err, &fo), "transport error must trigger account failover")
require.Equal(t, http.StatusBadGateway, fo.StatusCode)
require.Empty(t, repo.tempUnschedCalls, "plain EOF is transient: fail over but do not evict")
require.Equal(t, 0, rec.Body.Len(), "service must not write a hard 502 before handler can fail over")
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 1)
require.NotNil(t, events[0].ProxyID)
require.Equal(t, proxyID, *events[0].ProxyID)
require.Equal(t, proxy.Name, events[0].ProxyName)
}
func TestForwardAsRawChatCompletions_RecordsProxyPerAccountAttempt(t *testing.T) {
upstream := &failingOpenAIHTTPUpstream{err: errors.New("EOF")}
svc := &OpenAIGatewayService{
accountRepo: &openaiTransportAccountRepoStub{},
httpUpstream: upstream,
cfg: &config.Config{Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
}},
}
proxyA := &Proxy{ID: 10060, Name: "proxy-a", Protocol: "http", Host: "proxy-a.example", Port: 8080}
proxyB := &Proxy{ID: 8001, Name: "proxy-b", Protocol: "http", Host: "proxy-b.example", Port: 8080}
fallbackOriginID := int64(10150)
accounts := []*Account{
{ID: 81, Name: "account-a", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "a", "base_url": "https://example.com/v1"}, ProxyID: &proxyA.ID, Proxy: proxyA},
{ID: 82, Name: "account-b", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "b", "base_url": "https://example.com/v1"}, ProxyID: &proxyB.ID, Proxy: proxyB, ProxyFallbackOriginID: &fallbackOriginID},
{ID: 83, Name: "account-direct", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "c", "base_url": "https://example.com/v1"}},
}
c, _ := newOpenAITransportErrTestContext()
body := []byte(`{"model":"test","messages":[{"role":"user","content":"hello"}]}`)
for _, account := range accounts {
_, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
}
require.Equal(t, []string{proxyA.URL(), proxyB.URL(), ""}, upstream.proxyURLs)
rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 3)
for i, proxy := range []*Proxy{proxyA, proxyB} {
require.NotNil(t, events[i].ProxyID)
require.Equal(t, proxy.ID, *events[i].ProxyID)
require.Equal(t, proxy.Name, events[i].ProxyName)
}
require.Nil(t, events[2].ProxyID)
require.Equal(t, opsProxyNameDirect, events[2].ProxyName)
}
func TestHandleOpenAIUpstreamTransportError_RecordsOllamaActivityOnly(t *testing.T) {
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -160,6 +162,63 @@ func TestResolveOpenAIWSFallbackErrorResponse(t *testing.T) {
})
}
func TestWriteOpenAIWSFallbackErrorResponseMarksDefaultClientRouteUnknown(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
svc := &OpenAIGatewayService{}
account := &Account{ID: 42, Name: "default-client", Platform: PlatformOpenAI}
written := svc.writeOpenAIWSFallbackErrorResponse(
c,
account,
wrapOpenAIWSFallback("auth_failed", errors.New("unauthorized")),
)
require.True(t, written)
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 1)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
}
func TestWriteOpenAIWSFallbackErrorResponseKeepsManagedProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
svc := &OpenAIGatewayService{}
proxyID := int64(10060)
account := &Account{ID: 42, Name: "proxied", Platform: PlatformOpenAI, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "ws-proxy"}}
require.True(t, svc.writeOpenAIWSFallbackErrorResponse(c, account, wrapOpenAIWSFallback("auth_failed", errors.New("unauthorized"))))
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 1)
require.NotNil(t, events[0].ProxyID)
require.Equal(t, proxyID, *events[0].ProxyID)
require.Equal(t, "ws-proxy", events[0].ProxyName)
}
func TestOpsUpstreamWSProxyAttributionNeverReportsDirect(t *testing.T) {
proxyID := int64(7)
id, name := opsUpstreamWSProxyAttribution(nil)
require.Nil(t, id)
require.Equal(t, opsProxyNameUnknown, name)
id, name = opsUpstreamWSProxyAttribution(&Account{})
require.Nil(t, id)
require.Equal(t, opsProxyNameUnknown, name, "no managed proxy => http.DefaultClient => unknown, never direct")
id, name = opsUpstreamWSProxyAttribution(&Account{ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "ws-proxy"}})
require.NotNil(t, id)
require.Equal(t, proxyID, *id)
require.Equal(t, "ws-proxy", name)
}
func TestOpenAIWSFallbackCooling(t *testing.T) {
svc := &OpenAIGatewayService{cfg: &config.Config{}}
svc.cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
@@ -1396,10 +1396,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
var firstOutputTimeoutErr *openAIWSPassthroughFirstOutputTimeoutError
if errors.As(relayErr, &firstOutputTimeoutErr) {
deadline := firstOutputTimeoutErr.deadline
// The relay ran over the WebSocket transport, so a missing managed
// proxy is an unknown route (http.DefaultClient), not a direct one.
wsProxyID, wsProxyName := opsUpstreamWSProxyAttribution(account)
failoverErr := s.newOpenAIFirstOutputTimeoutError(
ctx,
c,
account,
wsProxyID,
wsProxyName,
deadline.startedAt,
deadline.requestModel,
deadline.reasoningEffort,
@@ -8,7 +8,10 @@ import (
func TestSanitizeOpsUpstreamErrorsForQueueBoundsAndRedacts(t *testing.T) {
entry := &OpsInsertErrorLogInput{}
for i := 0; i < 20; i++ {
proxyID := int64(i + 1)
entry.UpstreamErrors = append(entry.UpstreamErrors, &OpsUpstreamErrorEvent{
ProxyID: &proxyID,
ProxyName: "proxy-" + string(rune('a'+i)),
Platform: strings.Repeat("p", 100),
AccountName: strings.Repeat("a", 300),
UpstreamStatusCode: 500,
@@ -32,18 +35,167 @@ func TestSanitizeOpsUpstreamErrorsForQueueBoundsAndRedacts(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(events) != 16 {
t.Fatalf("event count = %d, want 16", len(events))
if len(events) != 20 {
t.Fatalf("event count = %d, want 20", len(events))
}
for _, event := range events {
for i, event := range events {
if event.ProxyID == nil || *event.ProxyID != int64(i+1) {
t.Fatalf("event %d proxy id = %v, want %d", i, event.ProxyID, i+1)
}
if len(event.Platform) > 32 || len(event.AccountName) > 128 || len(event.UpstreamURL) > 2048 || len(event.Message) > 2048 {
t.Fatalf("event fields were not bounded: %+v", event)
}
if i < 4 && (len(event.UpstreamURL) > opsUpstreamErrorsOlderURLMaxLen || len(event.Message) > opsUpstreamErrorsOlderMessageMaxLen) {
t.Fatalf("older event %d kept a full-size url/message: url=%d message=%d", i, len(event.UpstreamURL), len(event.Message))
}
if event.DroppedEarlierAttempts != 0 {
t.Fatalf("event %d reported dropped attempts without any drop: %d", i, event.DroppedEarlierAttempts)
}
if len(event.UpstreamResponseBody) > OpsErrorLogQueueBodyMaxBytes || len(event.Detail) > OpsErrorLogQueueBodyMaxBytes {
t.Fatal("event body/detail exceeded queue limit")
}
if strings.Contains(event.UpstreamResponseBody, "Bearer secret") || strings.Contains(event.Detail, `"secret"`) {
t.Fatal("credential material was not redacted")
}
if i < 4 && (event.UpstreamResponseBody != "" || event.Detail != "") {
t.Fatal("older event retained a large body outside the queue body window")
}
}
}
func TestSanitizeOpsUpstreamErrorsForQueueHardBoundsEventCount(t *testing.T) {
entry := &OpsInsertErrorLogInput{}
total := opsUpstreamErrorsMaxEvents + 40
for i := 0; i < total; i++ {
entry.UpstreamErrors = append(entry.UpstreamErrors, &OpsUpstreamErrorEvent{
AtUnixMs: int64(i + 1),
ProxyName: opsProxyNameDirect,
UpstreamStatusCode: 500,
Message: "attempt",
})
}
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
t.Fatal(err)
}
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
if err != nil {
t.Fatal(err)
}
if len(events) != opsUpstreamErrorsMaxEvents {
t.Fatalf("event count = %d, want %d", len(events), opsUpstreamErrorsMaxEvents)
}
// Newest attempts win; the oldest retained one carries the drop count.
if events[len(events)-1].AtUnixMs != int64(total) {
t.Fatalf("newest event lost: at=%d want %d", events[len(events)-1].AtUnixMs, total)
}
if events[0].AtUnixMs != int64(total-opsUpstreamErrorsMaxEvents+1) {
t.Fatalf("oldest retained at=%d", events[0].AtUnixMs)
}
if events[0].DroppedEarlierAttempts != 40 {
t.Fatalf("dropped_earlier_attempts = %d, want 40", events[0].DroppedEarlierAttempts)
}
if events[1].DroppedEarlierAttempts != 0 {
t.Fatal("drop count must only be stamped on the oldest retained event")
}
}
func TestSanitizeOpsUpstreamErrorsForQueueHardBoundsSerializedBytes(t *testing.T) {
entry := &OpsInsertErrorLogInput{}
// Every bounded field at its cap: the 16 newest attempts weigh ~20KB each
// (8KB body + 8KB detail + 2KB url + 2KB message), older ones ~2KB. At the
// count cap that is well past the byte budget, so bytes must win here.
const total = opsUpstreamErrorsMaxEvents
for i := 0; i < total; i++ {
entry.UpstreamErrors = append(entry.UpstreamErrors, &OpsUpstreamErrorEvent{
AtUnixMs: int64(i + 1),
Platform: strings.Repeat("p", 40),
AccountName: strings.Repeat("a", 200),
ProxyName: strings.Repeat("n", 200),
UpstreamRequestID: strings.Repeat("r", 200),
UpstreamStatusCode: 502,
UpstreamURL: "https://example.com/" + strings.Repeat("u", 3000),
// Plain-text payloads are truncated, not JSON-trimmed, so they keep
// their full queue-limit weight.
UpstreamResponseBody: strings.Repeat("b", 10_000),
Kind: strings.Repeat("k", 80),
Stage: strings.Repeat("s", 80),
Scope: strings.Repeat("c", 80),
Reason: strings.Repeat("e", 200),
Message: strings.Repeat("m", 3000),
Detail: strings.Repeat("d", 10_000),
})
}
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
t.Fatal(err)
}
// The newest event is always kept even when it alone exceeds the remaining
// budget, so allow one max-size event of slack.
if len(*entry.UpstreamErrorsJSON) > opsUpstreamErrorsQueueMaxBytes+24*1024 {
t.Fatalf("serialized upstream_errors = %d bytes, budget %d", len(*entry.UpstreamErrorsJSON), opsUpstreamErrorsQueueMaxBytes)
}
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
if err != nil {
t.Fatal(err)
}
if len(events) == 0 || len(events) >= total {
t.Fatalf("expected the byte budget to drop some events, kept %d of %d", len(events), total)
}
if events[len(events)-1].AtUnixMs != int64(total) {
t.Fatal("newest attempt must always be retained")
}
if events[0].DroppedEarlierAttempts != total-len(events) {
t.Fatalf("dropped_earlier_attempts = %d, want %d", events[0].DroppedEarlierAttempts, total-len(events))
}
}
func TestSanitizeOpsUpstreamErrorsForQueueDropsEmptyButKeepsOlderTrimmedEvents(t *testing.T) {
entry := &OpsInsertErrorLogInput{}
// 20 events: the first 4 fall outside the body window. Event 0 is a real
// attempt whose only payload is a detail (status 0, no message); it must
// survive the window even though its detail is cleared. Event 1 is truly
// empty and must be dropped.
for i := 0; i < 20; i++ {
ev := &OpsUpstreamErrorEvent{AtUnixMs: int64(i + 1), ProxyName: opsProxyNameDirect}
switch i {
case 0:
ev.Detail = "proxy handshake failed"
case 1:
// fully empty
default:
ev.UpstreamStatusCode = 500
}
entry.UpstreamErrors = append(entry.UpstreamErrors, ev)
}
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
t.Fatal(err)
}
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
if err != nil {
t.Fatal(err)
}
if len(events) != 19 {
t.Fatalf("event count = %d, want 19", len(events))
}
if events[0].AtUnixMs != 1 || events[0].Detail != "" {
t.Fatalf("detail-only older attempt must be kept with detail cleared: %+v", events[0])
}
if events[1].AtUnixMs != 3 {
t.Fatalf("fully-empty attempt must be dropped, got at=%d", events[1].AtUnixMs)
}
}
func TestSanitizeOpsUpstreamErrorsForQueueIgnoresCallerSuppliedDropCount(t *testing.T) {
entry := &OpsInsertErrorLogInput{UpstreamErrors: []*OpsUpstreamErrorEvent{
{UpstreamStatusCode: 500, ProxyName: opsProxyNameDirect, DroppedEarlierAttempts: 99},
}}
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
t.Fatal(err)
}
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].DroppedEarlierAttempts != 0 {
t.Fatalf("caller-supplied drop count must be reset: %+v", events[0])
}
}
@@ -9,6 +9,7 @@ import (
type opsRepoMock struct {
InsertErrorLogFn func(ctx context.Context, input *OpsInsertErrorLogInput) (int64, error)
BatchInsertErrorLogsFn func(ctx context.Context, inputs []*OpsInsertErrorLogInput) (int64, error)
GetErrorLogByIDFn func(ctx context.Context, id int64) (*OpsErrorLogDetail, error)
BatchInsertSystemLogsFn func(ctx context.Context, inputs []*OpsInsertSystemLogInput) (int64, error)
ListSystemLogsFn func(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error)
DeleteSystemLogsFn func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error)
@@ -34,6 +35,9 @@ func (m *opsRepoMock) ListErrorLogs(ctx context.Context, filter *OpsErrorLogFilt
}
func (m *opsRepoMock) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
if m.GetErrorLogByIDFn != nil {
return m.GetErrorLogByIDFn(ctx, id)
}
return &OpsErrorLogDetail{}, nil
}
+84 -17
View File
@@ -539,29 +539,54 @@ func (s *OpsService) prepareErrorLogInput(ctx context.Context, entry *OpsInsertE
return entry, true, nil
}
const (
// opsUpstreamErrorsBodyWindow is how many of the newest attempts keep their
// larger detail / upstream_response_body payloads while queued.
opsUpstreamErrorsBodyWindow = 16
// Attempts older than the body window keep scalar metadata only, with URL
// and message trimmed harder than the newest attempts (2048).
opsUpstreamErrorsOlderURLMaxLen = 512
opsUpstreamErrorsOlderMessageMaxLen = 512
// Hard bounds for the upstream_errors array of one queued entry. Newest
// attempts win; the oldest retained attempt records the dropped count.
opsUpstreamErrorsMaxEvents = 256
opsUpstreamErrorsQueueMaxBytes = 512 * 1024
)
func sanitizeOpsUpstreamErrors(entry *OpsInsertErrorLogInput) error {
if entry == nil || len(entry.UpstreamErrors) == 0 {
return nil
}
const maxEvents = 16
events := entry.UpstreamErrors
if len(events) > maxEvents {
events = events[len(events)-maxEvents:]
events := make([]*OpsUpstreamErrorEvent, 0, len(entry.UpstreamErrors))
for _, ev := range entry.UpstreamErrors {
if ev != nil {
events = append(events, ev)
}
}
firstEventWithBody := len(events) - opsUpstreamErrorsBodyWindow
if firstEventWithBody < 0 {
firstEventWithBody = 0
}
sanitized := make([]*OpsUpstreamErrorEvent, 0, len(events))
for _, ev := range events {
if ev == nil {
continue
}
for i, ev := range events {
out := *ev
normalizeOpsUpstreamProxyAttribution(&out)
// Only boundOpsUpstreamErrors may stamp this; never trust caller input.
out.DroppedEarlierAttempts = 0
keepBody := i >= firstEventWithBody
urlMaxLen, messageMaxLen := 2048, 2048
if !keepBody {
urlMaxLen, messageMaxLen = opsUpstreamErrorsOlderURLMaxLen, opsUpstreamErrorsOlderMessageMaxLen
}
out.Platform = truncateString(strings.TrimSpace(out.Platform), 32)
out.AccountName = truncateString(strings.TrimSpace(out.AccountName), 128)
out.ProxyName = truncateString(strings.TrimSpace(out.ProxyName), 128)
out.UpstreamRequestID = truncateString(strings.TrimSpace(out.UpstreamRequestID), 128)
out.UpstreamURL = truncateString(strings.TrimSpace(out.UpstreamURL), 2048)
if body := strings.TrimSpace(out.UpstreamResponseBody); body != "" {
out.UpstreamURL = truncateString(strings.TrimSpace(out.UpstreamURL), urlMaxLen)
if body := strings.TrimSpace(out.UpstreamResponseBody); keepBody && body != "" {
out.UpstreamResponseBody, _ = sanitizeErrorBodyForStorage(body, OpsErrorLogQueueBodyMaxBytes)
} else {
out.UpstreamResponseBody = ""
@@ -582,11 +607,17 @@ func sanitizeOpsUpstreamErrors(entry *OpsInsertErrorLogInput) error {
}
msg := sanitizeUpstreamErrorMessage(strings.TrimSpace(out.Message))
msg = truncateString(msg, 2048)
msg = truncateString(msg, messageMaxLen)
out.Message = msg
detail := strings.TrimSpace(out.Detail)
if detail != "" {
// Drop fully-empty events (can happen if only status code was known).
// Judged on the original detail so an older attempt whose detail is
// cleared by the body window below is still retained.
if out.UpstreamStatusCode == 0 && out.Message == "" && detail == "" {
continue
}
if keepBody && detail != "" {
// Keep upstream detail small while the event waits in the queue.
sanitizedDetail, _ := sanitizeErrorBodyForStorage(detail, OpsErrorLogQueueBodyMaxBytes)
out.Detail = sanitizedDetail
@@ -594,20 +625,51 @@ func sanitizeOpsUpstreamErrors(entry *OpsInsertErrorLogInput) error {
out.Detail = ""
}
// Drop fully-empty events (can happen if only status code was known).
if out.UpstreamStatusCode == 0 && out.Message == "" && out.Detail == "" {
continue
}
evCopy := out
sanitized = append(sanitized, &evCopy)
}
sanitized, _ = boundOpsUpstreamErrors(sanitized)
entry.UpstreamErrorsJSON = marshalOpsUpstreamErrors(sanitized)
entry.UpstreamErrors = nil
return nil
}
// boundOpsUpstreamErrors enforces the per-entry event count and serialized
// byte budget. It walks from the newest attempt backwards so the final,
// outcome-deciding attempts are always retained (the newest event is kept even
// if it alone exceeds the budget), and stamps the oldest retained event with
// the number of dropped earlier attempts. Events must already be sanitized
// copies owned by the caller.
func boundOpsUpstreamErrors(events []*OpsUpstreamErrorEvent) ([]*OpsUpstreamErrorEvent, int) {
if len(events) == 0 {
return events, 0
}
keepFrom := len(events)
budget := opsUpstreamErrorsQueueMaxBytes
for i := len(events) - 1; i >= 0; i-- {
if len(events)-i > opsUpstreamErrorsMaxEvents {
break
}
raw, err := json.Marshal(events[i])
size := 0
if err == nil {
size = len(raw) + 1 // trailing comma / bracket share
}
if i != len(events)-1 && size > budget {
break
}
budget -= size
keepFrom = i
}
kept := events[keepFrom:]
dropped := keepFrom
if dropped > 0 {
kept[0].DroppedEarlierAttempts = dropped
}
return kept, dropped
}
func (s *OpsService) GetErrorLogs(ctx context.Context, filter *OpsErrorLogFilter) (*OpsErrorLogList, error) {
if err := s.RequireMonitoringEnabled(ctx); err != nil {
return nil, err
@@ -682,6 +744,11 @@ func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLo
}
return nil, infraerrors.InternalServer("OPS_ERROR_LOAD_FAILED", "Failed to load ops error log").WithCause(err)
}
if detail != nil && strings.TrimSpace(detail.UpstreamErrors) != "" {
if normalized, normalizeErr := normalizeOpsUpstreamErrorsJSON(detail.UpstreamErrors); normalizeErr == nil {
detail.UpstreamErrors = normalized
}
}
return detail, nil
}
@@ -2,11 +2,15 @@ package service
import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// Gin context keys used by Ops error logger for capturing upstream error details.
@@ -365,6 +369,17 @@ type OpsUpstreamErrorEvent struct {
AccountID int64 `json:"account_id,omitempty"`
AccountName string `json:"account_name,omitempty"`
// Proxy attribution is an immutable, credential-free snapshot of the route
// used by this attempt. ProxyID is null for direct and unknown routes;
// ProxyName distinguishes direct/no_proxy from unknown.
// Never add proxy URLs or credentials to this event.
ProxyID *int64 `json:"proxy_id"`
ProxyName string `json:"proxy_name"`
// DroppedEarlierAttempts is set on the oldest retained event when queue
// bounds forced earlier attempts of the same request to be discarded.
DroppedEarlierAttempts int `json:"dropped_earlier_attempts,omitempty"`
// Outcome
UpstreamStatusCode int `json:"upstream_status_code,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
@@ -394,6 +409,14 @@ type OpsUpstreamErrorEvent struct {
SkipMonitoring bool `json:"-"`
}
const (
opsProxyNameDirect = "direct/no_proxy"
opsProxyNameUnknown = "unknown"
// opsProxyNameUnnamed labels a managed proxy whose name is blank. The
// proxies.name column is NOT NULL/non-empty, so this is a defensive value.
opsProxyNameUnnamed = "proxy"
)
func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) {
if c == nil {
return
@@ -402,6 +425,7 @@ func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) {
ev.AtUnixMs = time.Now().UnixMilli()
}
ev.Platform = strings.TrimSpace(ev.Platform)
normalizeOpsUpstreamProxyAttribution(&ev)
ev.UpstreamRequestID = strings.TrimSpace(ev.UpstreamRequestID)
ev.UpstreamResponseBody = strings.TrimSpace(ev.UpstreamResponseBody)
ev.Kind = strings.TrimSpace(ev.Kind)
@@ -429,6 +453,91 @@ func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) {
checkSkipMonitoringForUpstreamEvent(c, &evCopy)
}
// opsUpstreamProxyAttribution derives both attribution fields from one
// decision so they can never disagree. The event is assembled at the failure
// site; forwarding code builds managed proxy routes from Proxy only when the
// binding ID is also set, so the same rule decides the label here:
//
// - nil account -> (nil, unknown)
// - no binding or no hydrated Proxy -> (nil, direct/no_proxy)
// - hydrated Proxy without durable ID -> (nil, unknown): the transport did
// use that proxy, but nothing durable identifies it
// - otherwise -> (Proxy.ID, Proxy.Name or "proxy")
//
// Invariant: proxy_id == null implies proxy_name is one of the two sentinels.
func opsUpstreamProxyAttribution(account *Account) (*int64, string) {
if account == nil {
return nil, opsProxyNameUnknown
}
if account.ProxyID == nil || account.Proxy == nil {
return nil, opsProxyNameDirect
}
if account.Proxy.ID <= 0 {
return nil, opsProxyNameUnknown
}
proxyID := account.Proxy.ID
name := strings.TrimSpace(account.Proxy.Name)
if name == "" {
name = opsProxyNameUnnamed
}
return &proxyID, name
}
func opsUpstreamProxyID(account *Account) *int64 {
proxyID, _ := opsUpstreamProxyAttribution(account)
return proxyID
}
func opsUpstreamProxyName(account *Account) string {
_, name := opsUpstreamProxyAttribution(account)
return name
}
// opsUpstreamWSProxyAttribution is the OpenAI WebSocket variant. The WS dialer
// sets an explicit proxy client only when the account has a usable managed
// proxy; otherwise coder/websocket falls back to http.DefaultClient, which
// honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY. A missing managed proxy is therefore
// unknown, not direct.
func opsUpstreamWSProxyAttribution(account *Account) (*int64, string) {
proxyID, name := opsUpstreamProxyAttribution(account)
if proxyID == nil {
return nil, opsProxyNameUnknown
}
return proxyID, name
}
func setUnknownOpsUpstreamProxy(ev *OpsUpstreamErrorEvent) {
if ev == nil {
return
}
ev.ProxyID = nil
ev.ProxyName = opsProxyNameUnknown
}
// normalizeOpsUpstreamProxyAttribution makes legacy events explicit without
// pretending that the account's current proxy is historical evidence.
func normalizeOpsUpstreamProxyAttribution(ev *OpsUpstreamErrorEvent) {
if ev == nil {
return
}
if ev.ProxyID != nil && *ev.ProxyID <= 0 {
// A non-positive ID never identifies a managed proxy.
ev.ProxyID = nil
}
ev.ProxyName = strings.TrimSpace(ev.ProxyName)
if ev.ProxyID != nil {
if ev.ProxyName == "" {
ev.ProxyName = opsProxyNameUnnamed
}
return
}
// Invariant: proxy_id == null implies a sentinel name. Any other name
// without a durable ID is not historical evidence of a managed route.
if ev.ProxyName != opsProxyNameDirect {
setUnknownOpsUpstreamProxy(ev)
}
}
// checkSkipMonitoringForUpstreamEvent snapshots whether this attempt matches a
// skip_monitoring passthrough rule. The final failure decides whether the
// request error is hidden; an intermediate recovered attempt cannot suppress a
@@ -479,6 +588,56 @@ func ParseOpsUpstreamErrors(raw string) ([]*OpsUpstreamErrorEvent, error) {
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, err
}
for _, ev := range out {
normalizeOpsUpstreamProxyAttribution(ev)
}
return out, nil
}
// normalizeOpsUpstreamErrorsJSON materializes missing proxy attribution on
// stored JSON for detail reads. It edits only the attribution keys of events
// that lack them, so keys written by older struct versions and the original
// key order survive; nothing is re-marshaled through the current struct.
func normalizeOpsUpstreamErrorsJSON(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw, nil
}
if !gjson.Valid(raw) {
return "", errors.New("upstream_errors is not valid JSON")
}
parsed := gjson.Parse(raw)
if !parsed.IsArray() {
return "", errors.New("upstream_errors is not a JSON array")
}
out := raw
for i, ev := range parsed.Array() {
if !ev.IsObject() {
continue
}
prefix := strconv.Itoa(i) + "."
proxyID := ev.Get("proxy_id")
proxyName := strings.TrimSpace(ev.Get("proxy_name").String())
hasValidID := proxyID.Exists() && proxyID.Type == gjson.Number && proxyID.Int() > 0
var err error
switch {
case hasValidID:
if proxyName == "" {
out, err = sjson.Set(out, prefix+"proxy_name", opsProxyNameUnnamed)
}
case proxyName == opsProxyNameDirect:
if !proxyID.Exists() || proxyID.Type != gjson.Null {
out, err = sjson.Set(out, prefix+"proxy_id", nil)
}
default:
if out, err = sjson.Set(out, prefix+"proxy_id", nil); err == nil {
out, err = sjson.Set(out, prefix+"proxy_name", opsProxyNameUnknown)
}
}
if err != nil {
return "", err
}
}
return out, nil
}
@@ -1,8 +1,12 @@
package service
import (
"context"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -26,3 +30,282 @@ func TestSafeUpstreamURL(t *testing.T) {
})
}
}
func TestOpsUpstreamErrorEventKeepsExplicitProxySnapshotPerAttempt(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
oldProxyID := int64(10060)
oldProxy := &Proxy{
ID: oldProxyID,
Name: "wldsg82-ipv6-10060",
Protocol: "socks5",
Host: "old-proxy.example",
Username: "proxy-user",
Password: "proxy-secret",
}
oldAccount := &Account{
ID: 11,
Name: "old-account",
Platform: PlatformOpenAI,
ProxyID: &oldProxyID,
Proxy: oldProxy,
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
AccountID: oldAccount.ID,
ProxyID: opsUpstreamProxyID(oldAccount),
ProxyName: opsUpstreamProxyName(oldAccount),
Kind: "retry",
})
newProxyID := int64(8001)
newAccount := &Account{
ID: 12,
Name: "new-account",
Platform: PlatformOpenAI,
ProxyID: &newProxyID,
Proxy: &Proxy{ID: newProxyID, Name: "oxylabs-uk-8001"},
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
AccountID: newAccount.ID,
ProxyID: opsUpstreamProxyID(newAccount),
ProxyName: opsUpstreamProxyName(newAccount),
Kind: "failover",
})
// Mutating the selected account after both attempts must not rewrite history.
oldProxy.Name = "mutated-current-proxy"
oldAccount.ProxyID = &newProxyID
oldAccount.Proxy = newAccount.Proxy
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 2)
require.Equal(t, int64(10060), *events[0].ProxyID)
require.Equal(t, "wldsg82-ipv6-10060", events[0].ProxyName)
require.Equal(t, int64(8001), *events[1].ProxyID)
require.Equal(t, "oxylabs-uk-8001", events[1].ProxyName)
encoded := marshalOpsUpstreamErrors(events)
require.NotNil(t, encoded)
require.NotContains(t, *encoded, "old-proxy.example")
require.NotContains(t, *encoded, "proxy-user")
require.NotContains(t, *encoded, "proxy-secret")
}
func TestOpsUpstreamProxyFieldAccessors(t *testing.T) {
gin.SetMode(gin.TestMode)
boundID := int64(7)
backupID := int64(8)
tests := []struct {
name string
account *Account
wantID *int64
wantName string
}{
{
name: "actual proxy object is authoritative",
account: &Account{ProxyID: &boundID, Proxy: &Proxy{ID: backupID, Name: "backup-uk"}},
wantID: &backupID,
wantName: "backup-uk",
},
{
name: "proxy object without binding id is not a configured route",
account: &Account{Proxy: &Proxy{ID: backupID, Name: "hydrated-proxy"}},
wantName: opsProxyNameDirect,
},
{
name: "hydrated proxy without durable id is unknown, never id=null plus a real name",
account: &Account{ProxyID: &boundID, Proxy: &Proxy{Name: "transient-proxy"}},
wantName: opsProxyNameUnknown,
},
{
name: "blank proxy name gets the unnamed placeholder",
account: &Account{ProxyID: &boundID, Proxy: &Proxy{ID: boundID, Name: " "}},
wantID: &boundID,
wantName: opsProxyNameUnnamed,
},
{
name: "account has no proxy",
account: &Account{},
wantName: opsProxyNameDirect,
},
{
name: "configured proxy was not hydrated and transport is direct",
account: &Account{ProxyID: &backupID},
wantName: opsProxyNameDirect,
},
{
name: "missing attempt account",
account: nil,
wantName: opsProxyNameUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(tt.account),
ProxyName: opsUpstreamProxyName(tt.account),
Kind: "request_error",
})
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 1)
event := events[0]
if tt.wantID == nil {
require.Nil(t, event.ProxyID)
} else {
require.NotNil(t, event.ProxyID)
require.Equal(t, *tt.wantID, *event.ProxyID)
}
require.Equal(t, tt.wantName, event.ProxyName)
})
}
}
func TestOpsUpstreamErrorEventRetryKeepsExplicitAttemptProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
proxyID := int64(10060)
account := &Account{ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "retry-proxy"}}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Kind: "retry",
})
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Kind: "retry_exhausted",
})
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 2)
for _, event := range events {
require.NotNil(t, event.ProxyID)
require.Equal(t, proxyID, *event.ProxyID)
require.Equal(t, "retry-proxy", event.ProxyName)
}
}
func TestNormalizeOpsUpstreamProxyAttributionEnforcesSentinelInvariant(t *testing.T) {
positive := int64(7)
zero := int64(0)
tests := []struct {
name string
in OpsUpstreamErrorEvent
wantID *int64
wantName string
}{
{name: "id with name kept", in: OpsUpstreamErrorEvent{ProxyID: &positive, ProxyName: "eu-1"}, wantID: &positive, wantName: "eu-1"},
{name: "id without name gets placeholder", in: OpsUpstreamErrorEvent{ProxyID: &positive}, wantID: &positive, wantName: opsProxyNameUnnamed},
{name: "null id with real name collapses to unknown", in: OpsUpstreamErrorEvent{ProxyName: "eu-1"}, wantName: opsProxyNameUnknown},
{name: "zero id with real name collapses to unknown", in: OpsUpstreamErrorEvent{ProxyID: &zero, ProxyName: "eu-1"}, wantName: opsProxyNameUnknown},
{name: "explicit direct preserved", in: OpsUpstreamErrorEvent{ProxyName: opsProxyNameDirect}, wantName: opsProxyNameDirect},
{name: "empty is unknown", in: OpsUpstreamErrorEvent{}, wantName: opsProxyNameUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := tt.in
normalizeOpsUpstreamProxyAttribution(&ev)
if tt.wantID == nil {
require.Nil(t, ev.ProxyID)
} else {
require.NotNil(t, ev.ProxyID)
require.Equal(t, *tt.wantID, *ev.ProxyID)
}
require.Equal(t, tt.wantName, ev.ProxyName)
})
}
}
func TestNormalizeOpsUpstreamErrorsJSONPreservesUnknownKeysAndOrder(t *testing.T) {
// A legacy row written by an older struct version: it carries a key the
// current struct no longer has, and mixes explicit/legacy attribution.
raw := `[` +
`{"at_unix_ms":1,"account_id":42,"upstream_request_body":"legacy-only-key","kind":"http_error"},` +
`{"at_unix_ms":2,"proxy_id":null,"proxy_name":"direct/no_proxy","kind":"request_error"},` +
`{"at_unix_ms":3,"proxy_id":9,"proxy_name":"eu-9","kind":"failover"},` +
`{"at_unix_ms":4,"proxy_id":0,"proxy_name":"stale-name","kind":"retry"},` +
`{"at_unix_ms":5,"proxy_id":11,"proxy_name":"","kind":"retry"}` +
`]`
out, err := normalizeOpsUpstreamErrorsJSON(raw)
require.NoError(t, err)
// Legacy-only key and original key order survive.
require.Contains(t, out, `"upstream_request_body":"legacy-only-key"`)
require.True(t, strings.Index(out, `"at_unix_ms":1`) < strings.Index(out, `"account_id":42`))
events, err := ParseOpsUpstreamErrors(out)
require.NoError(t, err)
require.Len(t, events, 5)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
require.Nil(t, events[1].ProxyID)
require.Equal(t, opsProxyNameDirect, events[1].ProxyName)
require.NotNil(t, events[2].ProxyID)
require.Equal(t, int64(9), *events[2].ProxyID)
require.Equal(t, "eu-9", events[2].ProxyName)
require.Nil(t, events[3].ProxyID)
require.Equal(t, opsProxyNameUnknown, events[3].ProxyName)
require.NotNil(t, events[4].ProxyID)
require.Equal(t, opsProxyNameUnnamed, events[4].ProxyName)
// Already-normalized input is returned byte-for-byte.
again, err := normalizeOpsUpstreamErrorsJSON(out)
require.NoError(t, err)
require.Equal(t, out, again)
_, err = normalizeOpsUpstreamErrorsJSON(`{"not":"an array"}`)
require.Error(t, err)
_, err = normalizeOpsUpstreamErrorsJSON(`[not json`)
require.Error(t, err)
}
func TestParseOpsUpstreamErrorsMarksLegacyProxyAttributionUnknown(t *testing.T) {
events, err := ParseOpsUpstreamErrors(`[{"account_id":42,"account_name":"legacy","kind":"http_error"}]`)
require.NoError(t, err)
require.Len(t, events, 1)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
}
func TestParseOpsUpstreamErrorsPreservesExplicitDirectAttribution(t *testing.T) {
events, err := ParseOpsUpstreamErrors(`[{"proxy_id":null,"proxy_name":"direct/no_proxy","kind":"request_error"}]`)
require.NoError(t, err)
require.Len(t, events, 1)
require.Nil(t, events[0].ProxyID)
require.Equal(t, opsProxyNameDirect, events[0].ProxyName)
}
func TestOpsServiceGetErrorLogByIDNormalizesLegacyProxyAttribution(t *testing.T) {
repo := &opsRepoMock{
GetErrorLogByIDFn: func(context.Context, int64) (*OpsErrorLogDetail, error) {
return &OpsErrorLogDetail{UpstreamErrors: `[{"account_id":42,"kind":"http_error"}]`}, nil
},
}
svc := NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
detail, err := svc.GetErrorLogByID(context.Background(), 1)
require.NoError(t, err)
require.NotNil(t, detail)
require.Contains(t, detail.UpstreamErrors, `"proxy_name":"unknown"`)
require.Contains(t, detail.UpstreamErrors, `"proxy_id":null`)
require.NotContains(t, detail.UpstreamErrors, `"proxy_mode"`)
require.NotContains(t, detail.UpstreamErrors, `"proxy_source"`)
require.NotContains(t, detail.UpstreamErrors, `"proxy_fallback"`)
}