diff --git a/crates/aisix-proxy/src/error_translate.rs b/crates/aisix-proxy/src/error_translate.rs index 4146b582..2baace7f 100644 --- a/crates/aisix-proxy/src/error_translate.rs +++ b/crates/aisix-proxy/src/error_translate.rs @@ -1,24 +1,32 @@ -//! Cross-wire error-envelope translation. +//! Cross-wire upstream-error `code` derivation. //! -//! Each upstream provider speaks a different error-envelope taxonomy: +//! Each upstream provider emits a different error taxonomy: //! -//! | Wire | `error.type` examples | Has `code`/`param`? | -//! |-------------|----------------------------------------|---------------------| -//! | OpenAI | `rate_limit_exceeded`, `invalid_api_key` | yes | -//! | Anthropic | `rate_limit_error`, `overloaded_error` | no | -//! | Bedrock | `ThrottlingException`, `ValidationException` | no | -//! | Vertex | `RESOURCE_EXHAUSTED`, `PERMISSION_DENIED` (gRPC) | no | -//! | AzureOpenAI | mostly OpenAI-shape; quirks for content-policy | partial | +//! | Wire | Has structured `code`? | Native `type` examples | +//! |-------------|------------------------|-------------------------------------------| +//! | OpenAI | yes | `rate_limit_exceeded`, `invalid_api_key` | +//! | Anthropic | no | `rate_limit_error`, `overloaded_error` | +//! | Bedrock | no | `ThrottlingException`, `ValidationException` | +//! | Vertex | no | `RESOURCE_EXHAUSTED`, `PERMISSION_DENIED` (gRPC) | +//! | AzureOpenAI | partial | mostly OpenAI-shape; quirks for content-policy | //! -//! OpenAI SDKs that drive the customer's retry strategy switch on -//! `error.code` (e.g. `rate_limit_exceeded` vs `insufficient_quota`) -//! and `error.type`. If we forward an Anthropic `rate_limit_error` -//! verbatim to a downstream OpenAI SDK, that retry logic doesn't fire. -//! This module maps each non-OpenAI upstream taxonomy to the OpenAI -//! taxonomy so the client-side SDK keeps working regardless of which -//! upstream the gateway routed to. +//! Customer SDKs that drive retry strategy switch on `error.code` +//! (e.g. `rate_limit_exceeded` vs `insufficient_quota`). When the +//! upstream doesn't expose a stable string `code` (Anthropic, Bedrock, +//! Vertex), this module derives one from the upstream `type` so the +//! client-side SDK keeps working regardless of which upstream the +//! gateway routed to. //! -//! Authoritative sources for the taxonomies: +//! Per issue #327, `error.type` itself is **not** derived per-upstream +//! — the DP renders the stable token `"upstream_error"` for any +//! upstream-originated error. The DP acts as a normalising gateway: +//! `error.type` is its closed taxonomy; the upstream's private +//! taxonomy (`upstream_test_fixture`, `ValidationException`, etc.) +//! never reaches the customer. SDKs branch on `error.type == +//! "upstream_error"` for upstream-class detection and on +//! `error.code` for granular retry routing. +//! +//! Authoritative sources for the input taxonomies: //! - OpenAI: //! - Anthropic: //! - Bedrock: @@ -47,34 +55,27 @@ pub(crate) fn render_openai_envelope( .clone() .unwrap_or_else(|| fallback_message.to_string()); let upstream_kind = view.kind.as_deref(); - let (kind, derived_code) = match wire { - UpstreamWire::OpenAI => ( - upstream_kind - .map(str::to_string) - .unwrap_or_else(|| "upstream_error".to_string()), - view.code.clone(), - ), - UpstreamWire::AzureOpenAI => translate_azure(upstream_kind), - UpstreamWire::Anthropic => translate_anthropic(upstream_kind), - UpstreamWire::Bedrock => translate_bedrock(upstream_kind), - UpstreamWire::Vertex => translate_vertex(upstream_kind), - UpstreamWire::Unknown => ( - upstream_kind - .map(str::to_string) - .unwrap_or_else(|| "upstream_error".to_string()), - view.code.clone(), - ), + let derived_code = match wire { + UpstreamWire::OpenAI | UpstreamWire::Unknown => view.code.clone(), + UpstreamWire::AzureOpenAI => derive_azure_code(upstream_kind), + UpstreamWire::Anthropic => derive_anthropic_code(upstream_kind), + UpstreamWire::Bedrock => derive_bedrock_code(upstream_kind), + UpstreamWire::Vertex => derive_vertex_code(upstream_kind), }; ErrorBody { message, - kind, + // Issue #327: `error.type` is the DP's stable taxonomy, NOT + // the upstream's. Customers branch on + // `error.type == "upstream_error"` for upstream-class + // detection; SDK retry granularity comes from `error.code`. + kind: UPSTREAM_ERROR_TYPE.to_string(), param: view.param.clone(), // - OpenAI same-wire: pass through the upstream's `code` verbatim. - // - AzureOpenAI: prefer the derived code when the translation - // table has an explicit mapping (e.g. `DeploymentNotFound` + // - AzureOpenAI: prefer the derived code when the table has an + // explicit Azure-specific mapping (e.g. `DeploymentNotFound` // → `model_not_found`), otherwise pass through the upstream - // `code` (Azure shares OpenAI's taxonomy for the bulk of - // codes, so `rate_limit_exceeded` etc. should flow through). + // `code` (Azure shares OpenAI's taxonomy for most codes, so + // `rate_limit_exceeded` etc. flow through). // - Anthropic / Bedrock / Vertex: the upstream `code` field is // either absent or operator-leaky (Vertex numeric codes // embed internal taxonomy) — only the derived code reaches @@ -87,146 +88,97 @@ pub(crate) fn render_openai_envelope( } } +/// DP-stable `error.type` token surfaced for any upstream-originated +/// error. See module docstring + issue #327. +const UPSTREAM_ERROR_TYPE: &str = "upstream_error"; + fn generic(message: &str) -> ErrorBody { ErrorBody { message: message.to_string(), - kind: "upstream_error".to_string(), + kind: UPSTREAM_ERROR_TYPE.to_string(), param: None, code: None, } } -/// Anthropic `error.type` → OpenAI `(type, code)`. Reference: -/// . `permission_error` and -/// `request_too_large` are deliberately exhaustive — the upstream -/// reference impl this gateway is benchmarked against falls through to -/// a generic error on those two cases. -fn translate_anthropic(kind: Option<&str>) -> (String, Option) { - match kind { - Some("invalid_request_error") => ("invalid_request_error".into(), None), - Some("authentication_error") => ( - "invalid_request_error".into(), - Some("invalid_api_key".into()), - ), - Some("permission_error") => ( - "invalid_request_error".into(), - Some("permission_denied".into()), - ), - Some("not_found_error") => ( - "invalid_request_error".into(), - Some("model_not_found".into()), - ), - Some("request_too_large") => ( - "invalid_request_error".into(), - Some("request_too_large".into()), - ), - Some("rate_limit_error") => ( - "rate_limit_exceeded".into(), - Some("rate_limit_exceeded".into()), - ), - Some("overloaded_error") => ("api_error".into(), Some("overloaded".into())), - Some("api_error") => ("api_error".into(), None), - _ => ("upstream_error".into(), None), +/// Anthropic `error.type` → OpenAI string `code`. Reference: +/// . The upstream `type` +/// itself is not propagated (see issue #327) — only a derived +/// OpenAI-shape `code` reaches the customer, so SDK retry logic that +/// switches on `error.code` works regardless of which upstream the +/// gateway routed to. +fn derive_anthropic_code(kind: Option<&str>) -> Option { + match kind? { + "authentication_error" => Some("invalid_api_key".into()), + "permission_error" => Some("permission_denied".into()), + "not_found_error" => Some("model_not_found".into()), + "request_too_large" => Some("request_too_large".into()), + "rate_limit_error" => Some("rate_limit_exceeded".into()), + "overloaded_error" => Some("overloaded".into()), + // `invalid_request_error`, `api_error`, and unknown values + // have no clean OpenAI string-code counterpart. + _ => None, } } -/// AWS Bedrock `InvokeModelError` variant name → OpenAI `(type, code)`. +/// AWS Bedrock `InvokeModelError` variant name → OpenAI string `code`. /// Reference: AWS SDK for Rust, `aws-sdk-bedrockruntime`'s generated /// `InvokeModelError` enum, and /// . -fn translate_bedrock(kind: Option<&str>) -> (String, Option) { - match kind { - Some("ThrottlingException") => ( - "rate_limit_exceeded".into(), - Some("rate_limit_exceeded".into()), - ), - Some("ServiceQuotaExceededException") => ( - "rate_limit_exceeded".into(), - Some("insufficient_quota".into()), - ), - Some("ValidationException") => ("invalid_request_error".into(), None), - Some("AccessDeniedException") => ( - "invalid_request_error".into(), - Some("permission_denied".into()), - ), - Some("ResourceNotFoundException") => ( - "invalid_request_error".into(), - Some("model_not_found".into()), - ), - Some("ModelNotReadyException") => ("api_error".into(), Some("model_not_ready".into())), - Some("ModelTimeoutException") => ("api_error".into(), Some("timeout".into())), - Some("ModelStreamErrorException") => ("api_error".into(), Some("stream_error".into())), - Some("ModelErrorException") => ("api_error".into(), Some("model_error".into())), - Some("InternalServerException") => ("api_error".into(), None), - Some("ServiceUnavailableException") => ("api_error".into(), Some("overloaded".into())), - _ => ("api_error".into(), None), +fn derive_bedrock_code(kind: Option<&str>) -> Option { + match kind? { + "ThrottlingException" => Some("rate_limit_exceeded".into()), + "ServiceQuotaExceededException" => Some("insufficient_quota".into()), + "AccessDeniedException" => Some("permission_denied".into()), + "ResourceNotFoundException" => Some("model_not_found".into()), + "ModelNotReadyException" => Some("model_not_ready".into()), + "ModelTimeoutException" => Some("timeout".into()), + "ModelStreamErrorException" => Some("stream_error".into()), + "ModelErrorException" => Some("model_error".into()), + "ServiceUnavailableException" => Some("overloaded".into()), + // `ValidationException`, `InternalServerException`, and + // unhandled variants have no clean OpenAI string-code + // counterpart. + _ => None, } } -/// Google canonical gRPC status code → OpenAI `(type, code)`. The +/// Google canonical gRPC status code → OpenAI string `code`. The /// upstream `error.status` field carries the gRPC code as a string /// (e.g. `"RESOURCE_EXHAUSTED"`). Reference: /// and the protobuf /// `google.rpc.Code` enum. -fn translate_vertex(kind: Option<&str>) -> (String, Option) { - match kind { - Some("RESOURCE_EXHAUSTED") => ( - "rate_limit_exceeded".into(), - Some("rate_limit_exceeded".into()), - ), - Some("PERMISSION_DENIED") => ( - "invalid_request_error".into(), - Some("permission_denied".into()), - ), - Some("UNAUTHENTICATED") => ( - "invalid_request_error".into(), - Some("invalid_api_key".into()), - ), - Some("INVALID_ARGUMENT") => ("invalid_request_error".into(), None), - Some("NOT_FOUND") => ( - "invalid_request_error".into(), - Some("model_not_found".into()), - ), - Some("FAILED_PRECONDITION") | Some("OUT_OF_RANGE") | Some("ALREADY_EXISTS") => { - ("invalid_request_error".into(), None) - } - Some("UNAVAILABLE") => ("api_error".into(), Some("overloaded".into())), - Some("DEADLINE_EXCEEDED") => ("api_error".into(), Some("timeout".into())), - Some("INTERNAL") | Some("ABORTED") | Some("CANCELLED") | Some("UNKNOWN") => { - ("api_error".into(), None) - } - _ => ("api_error".into(), None), +fn derive_vertex_code(kind: Option<&str>) -> Option { + match kind? { + "RESOURCE_EXHAUSTED" => Some("rate_limit_exceeded".into()), + "PERMISSION_DENIED" => Some("permission_denied".into()), + "UNAUTHENTICATED" => Some("invalid_api_key".into()), + "NOT_FOUND" => Some("model_not_found".into()), + "UNAVAILABLE" => Some("overloaded".into()), + "DEADLINE_EXCEEDED" => Some("timeout".into()), + // `INVALID_ARGUMENT`, `FAILED_PRECONDITION`, `INTERNAL`, + // `ABORTED`, `CANCELLED`, `UNKNOWN` have no clean OpenAI + // string-code counterpart. + _ => None, } } -/// Azure OpenAI `error.code` → OpenAI `(type, code)`. Azure error +/// Azure OpenAI `error.code` → OpenAI string `code`. Azure error /// codes are mostly identical to OpenAI's, with a handful of -/// Azure-specific tokens. Reference: Azure OpenAI REST docs, error -/// codes section. -fn translate_azure(kind: Option<&str>) -> (String, Option) { - match kind { - // Azure-specific deployment / content-policy codes. - Some("DeploymentNotFound") => ( - "invalid_request_error".into(), - Some("model_not_found".into()), - ), - Some("ResponsibleAIPolicyViolation") => ( - "invalid_request_error".into(), - Some("content_policy_violation".into()), - ), - Some("content_filter") => ( - "invalid_request_error".into(), - Some("content_policy_violation".into()), - ), - Some("invalid_encrypted_content") => ( - "invalid_request_error".into(), - Some("invalid_encrypted_content".into()), - ), - // Everything else: Azure aligns with OpenAI taxonomy — pass - // through the upstream kind as the OpenAI type, no derived - // code (the caller will prefer any upstream-supplied `code`). - Some(k) => (k.to_string(), None), - None => ("upstream_error".into(), None), +/// Azure-specific tokens that get rewritten to the OpenAI equivalent. +/// Reference: Azure OpenAI REST docs, error codes section. +fn derive_azure_code(kind: Option<&str>) -> Option { + match kind? { + "DeploymentNotFound" => Some("model_not_found".into()), + "ResponsibleAIPolicyViolation" | "content_filter" => { + Some("content_policy_violation".into()) + } + "invalid_encrypted_content" => Some("invalid_encrypted_content".into()), + // For OpenAI-compat codes (e.g. `rate_limit_exceeded`), the + // renderer falls back to `view.code`, which already carries + // the upstream code; emitting `None` here lets that fallback + // win. + _ => None, } } @@ -244,48 +196,52 @@ mod tests { } #[test] - fn anthropic_rate_limit_translates_to_openai_rate_limit_exceeded() { + fn anthropic_rate_limit_derives_rate_limit_exceeded_code() { let v = view("rate_limit_error"); let body = render_openai_envelope(Some(&v), UpstreamWire::Anthropic, "fallback"); - assert_eq!(body.kind, "rate_limit_exceeded"); + // Issue #327: `kind` is the DP-stable taxonomy + // (`upstream_error`); SDK retry routing uses `code`. + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("rate_limit_exceeded")); assert_eq!(body.message, "upstream said hi"); } #[test] - fn anthropic_overloaded_maps_to_api_error_with_overloaded_code() { + fn anthropic_overloaded_derives_overloaded_code() { let v = view("overloaded_error"); let body = render_openai_envelope(Some(&v), UpstreamWire::Anthropic, "fb"); - assert_eq!(body.kind, "api_error"); + // Issue #327: `kind` is the DP-stable taxonomy regardless of + // which upstream produced the error. + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("overloaded")); } #[test] - fn anthropic_authentication_carries_invalid_api_key_code() { + fn anthropic_authentication_derives_invalid_api_key_code() { let v = view("authentication_error"); let body = render_openai_envelope(Some(&v), UpstreamWire::Anthropic, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("invalid_api_key")); } #[test] - fn anthropic_permission_error_maps_to_permission_denied_code() { + fn anthropic_permission_error_derives_permission_denied_code() { let v = view("permission_error"); let body = render_openai_envelope(Some(&v), UpstreamWire::Anthropic, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("permission_denied")); } #[test] - fn anthropic_not_found_maps_to_model_not_found() { + fn anthropic_not_found_derives_model_not_found_code() { let v = view("not_found_error"); let body = render_openai_envelope(Some(&v), UpstreamWire::Anthropic, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("model_not_found")); } #[test] - fn anthropic_unknown_falls_back_to_upstream_error() { + fn anthropic_unknown_kind_yields_null_code_under_upstream_error_type() { let v = view("brand_new_anthropic_error_type"); let body = render_openai_envelope(Some(&v), UpstreamWire::Anthropic, "fb"); assert_eq!(body.kind, "upstream_error"); @@ -293,10 +249,10 @@ mod tests { } #[test] - fn bedrock_throttling_translates_to_openai_rate_limit_exceeded() { + fn bedrock_throttling_derives_rate_limit_exceeded_code() { let v = view("ThrottlingException"); let body = render_openai_envelope(Some(&v), UpstreamWire::Bedrock, "fb"); - assert_eq!(body.kind, "rate_limit_exceeded"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("rate_limit_exceeded")); } @@ -307,79 +263,79 @@ mod tests { // (quota lift vs backoff). let v = view("ServiceQuotaExceededException"); let body = render_openai_envelope(Some(&v), UpstreamWire::Bedrock, "fb"); - assert_eq!(body.kind, "rate_limit_exceeded"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("insufficient_quota")); } #[test] - fn bedrock_validation_maps_to_invalid_request_with_no_code() { + fn bedrock_validation_yields_null_code() { let v = view("ValidationException"); let body = render_openai_envelope(Some(&v), UpstreamWire::Bedrock, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert!(body.code.is_none()); } #[test] - fn bedrock_access_denied_carries_permission_denied_code() { + fn bedrock_access_denied_derives_permission_denied_code() { let v = view("AccessDeniedException"); let body = render_openai_envelope(Some(&v), UpstreamWire::Bedrock, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("permission_denied")); } #[test] - fn bedrock_unhandled_falls_back_to_api_error() { + fn bedrock_unhandled_yields_null_code() { let v = view("BrandNewBedrockException"); let body = render_openai_envelope(Some(&v), UpstreamWire::Bedrock, "fb"); - assert_eq!(body.kind, "api_error"); + assert_eq!(body.kind, "upstream_error"); assert!(body.code.is_none()); } #[test] - fn vertex_resource_exhausted_translates_to_openai_rate_limit_exceeded() { + fn vertex_resource_exhausted_derives_rate_limit_exceeded_code() { let v = view("RESOURCE_EXHAUSTED"); let body = render_openai_envelope(Some(&v), UpstreamWire::Vertex, "fb"); - assert_eq!(body.kind, "rate_limit_exceeded"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("rate_limit_exceeded")); } #[test] - fn vertex_permission_denied_maps_to_permission_denied_code() { + fn vertex_permission_denied_derives_permission_denied_code() { let v = view("PERMISSION_DENIED"); let body = render_openai_envelope(Some(&v), UpstreamWire::Vertex, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("permission_denied")); } #[test] - fn vertex_unauthenticated_maps_to_invalid_api_key_code() { + fn vertex_unauthenticated_derives_invalid_api_key_code() { let v = view("UNAUTHENTICATED"); let body = render_openai_envelope(Some(&v), UpstreamWire::Vertex, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("invalid_api_key")); } #[test] - fn vertex_unavailable_maps_to_api_error_with_overloaded_code() { + fn vertex_unavailable_derives_overloaded_code() { let v = view("UNAVAILABLE"); let body = render_openai_envelope(Some(&v), UpstreamWire::Vertex, "fb"); - assert_eq!(body.kind, "api_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("overloaded")); } #[test] - fn vertex_deadline_exceeded_maps_to_timeout_code() { + fn vertex_deadline_exceeded_derives_timeout_code() { let v = view("DEADLINE_EXCEEDED"); let body = render_openai_envelope(Some(&v), UpstreamWire::Vertex, "fb"); - assert_eq!(body.kind, "api_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("timeout")); } #[test] - fn azure_deployment_not_found_maps_to_model_not_found() { + fn azure_deployment_not_found_derives_model_not_found_code() { let v = view("DeploymentNotFound"); let body = render_openai_envelope(Some(&v), UpstreamWire::AzureOpenAI, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("model_not_found")); } @@ -391,18 +347,25 @@ mod tests { // code that SDKs recognise. let v = view("ResponsibleAIPolicyViolation"); let body = render_openai_envelope(Some(&v), UpstreamWire::AzureOpenAI, "fb"); - assert_eq!(body.kind, "invalid_request_error"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("content_policy_violation")); } #[test] - fn azure_unknown_kind_passes_through_when_openai_compatible() { + fn azure_openai_compatible_code_falls_through_to_upstream_value() { // Azure shares OpenAI's taxonomy for the vast majority of error - // codes; new ones should pass through rather than collapse to - // a generic `upstream_error`. - let v = view("some_future_openai_compat_code"); + // codes; for unknown codes the derived value is None, so the + // renderer falls back to view.code — which the Azure bridge + // populates from the upstream `error.code` field. + let v = UpstreamErrorView { + kind: Some("rate_limit_exceeded".into()), + message: Some("hi".into()), + code: Some("rate_limit_exceeded".into()), + param: None, + }; let body = render_openai_envelope(Some(&v), UpstreamWire::AzureOpenAI, "fb"); - assert_eq!(body.kind, "some_future_openai_compat_code"); + assert_eq!(body.kind, "upstream_error"); + assert_eq!(body.code.as_deref(), Some("rate_limit_exceeded")); } #[test] @@ -410,7 +373,8 @@ mod tests { // The same-wire path treats the upstream as authoritative — // `code` and `param` flow through unchanged, including codes // that aren't in any table (forward-compat for OpenAI taxonomy - // additions). + // additions). `kind` is the DP-stable token, not the upstream's + // `error.type`. let v = UpstreamErrorView { kind: Some("rate_limit_exceeded".into()), message: Some("hi".into()), @@ -418,13 +382,13 @@ mod tests { param: Some("model".into()), }; let body = render_openai_envelope(Some(&v), UpstreamWire::OpenAI, "fb"); - assert_eq!(body.kind, "rate_limit_exceeded"); + assert_eq!(body.kind, "upstream_error"); assert_eq!(body.code.as_deref(), Some("custom_code_added_yesterday")); assert_eq!(body.param.as_deref(), Some("model")); } #[test] - fn missing_view_uses_fallback_message_and_generic_kind() { + fn missing_view_uses_fallback_message_and_upstream_error_kind() { let body = render_openai_envelope(None, UpstreamWire::Anthropic, "raw upstream text"); assert_eq!(body.kind, "upstream_error"); assert_eq!(body.message, "raw upstream text"); @@ -440,7 +404,8 @@ mod tests { param: None, }; let body = render_openai_envelope(Some(&v), UpstreamWire::Anthropic, "raw fallback"); - assert_eq!(body.kind, "rate_limit_exceeded"); + assert_eq!(body.kind, "upstream_error"); + assert_eq!(body.code.as_deref(), Some("rate_limit_exceeded")); assert_eq!(body.message, "raw fallback"); } } diff --git a/crates/aisix-proxy/src/lib.rs b/crates/aisix-proxy/src/lib.rs index 0191ae7a..f654b302 100644 --- a/crates/aisix-proxy/src/lib.rs +++ b/crates/aisix-proxy/src/lib.rs @@ -724,14 +724,18 @@ mod tests { assert_eq!(v["error"]["type"], "upstream_error"); } - /// Issue #322: when an OpenAI upstream returns a coded 4xx with the - /// standard `{error:{message,type,code,param}}` envelope, every - /// field reaches the customer verbatim. SDKs that switch on - /// `error.code` to decide retry strategy depend on this — flattening - /// to a generic `upstream_error` envelope silently downgrades their - /// retry intelligence. + /// Issues #322 + #327: when an OpenAI upstream returns a coded + /// 4xx with the standard `{error:{message,type,code,param}}` + /// envelope, the gateway: + /// - preserves `message`, `code`, and `param` verbatim so SDK + /// retry logic that branches on `error.code` keeps working; + /// - normalises `error.type` to the DP-stable token + /// `"upstream_error"`, hiding the upstream's private taxonomy + /// from the customer (the upstream `type` here — + /// `"upstream_test_fixture"` — is mock-llm's internal label + /// and must not bleed through). #[tokio::test] - async fn upstream_openai_4xx_forwards_full_envelope_per_issue_322() { + async fn upstream_openai_4xx_forwards_code_and_param_but_normalises_type_per_issue_327() { let upstream = MockServer::start().await; Mock::given(method("POST")) .and(path("/chat/completions")) @@ -761,7 +765,12 @@ mod tests { let bytes = to_bytes(resp.into_body(), 2048).await.unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(v["error"]["message"], "upstream forced 429"); - assert_eq!(v["error"]["type"], "upstream_test_fixture"); + // Per #327: `error.type` is the DP-stable taxonomy, NOT the + // upstream's `type`. The upstream's `upstream_test_fixture` + // token must NOT leak to the customer envelope. + assert_eq!(v["error"]["type"], "upstream_error"); + // Per #322: `error.code` and `error.param` ARE preserved so + // SDK retry logic can branch on the granular code. assert_eq!(v["error"]["code"], "forced_429"); assert_eq!(v["error"]["param"], "model"); } @@ -905,15 +914,13 @@ mod tests { assert!(v["error"]["message"].is_string()); } - /// Cross-provider 4xx translation: Anthropic upstream 400 reaches - /// the OpenAI-client side with the OpenAI-shape `error.type` / - /// `error.code` derived from Anthropic's `error.type` via the - /// translation table in [`crate::error_translate`]. Anthropic - /// `invalid_request_error` maps to OpenAI `invalid_request_error` - /// (taxonomy overlap — same token); other Anthropic types like - /// `rate_limit_error` map to distinct OpenAI tokens. + /// Cross-provider 4xx forwarding (issue #327): Anthropic upstream + /// 400 reaches the OpenAI-client side with `error.type` normalised + /// to the DP-stable `"upstream_error"` token — Anthropic's private + /// taxonomy (`invalid_request_error`, `authentication_error`, etc.) + /// must not bleed through. #[tokio::test] - async fn upstream_anthropic_400_passes_through_with_openai_envelope() { + async fn upstream_anthropic_400_normalises_type_to_upstream_error() { use aisix_provider_anthropic::AnthropicBridge; let upstream = MockServer::start().await; @@ -951,20 +958,22 @@ mod tests { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v: serde_json::Value = serde_json::from_slice(&to_bytes(resp.into_body(), 1024).await.unwrap()).unwrap(); - assert_eq!(v["error"]["type"], "invalid_request_error"); + assert_eq!(v["error"]["type"], "upstream_error"); assert_eq!(v["error"]["message"], "bad input"); // Anthropic `invalid_request_error` doesn't derive an OpenAI // string code — translation table emits `code: null`. assert!(v["error"].get("code").is_none() || v["error"]["code"].is_null()); } - /// Issue #322 cross-wire contract: Anthropic upstream `rate_limit_error` - /// must translate to OpenAI `rate_limit_exceeded` (both as - /// `error.type` and `error.code`) so OpenAI SDK retry logic that - /// switches on `error.code` recognises the rate-limit failure - /// regardless of which upstream the gateway routed to. + /// Issue #322 + #327 cross-wire contract: Anthropic upstream + /// `rate_limit_error` must derive OpenAI `error.code = + /// rate_limit_exceeded` (so SDK retry logic that switches on + /// `error.code` recognises the rate-limit failure regardless of + /// upstream), while `error.type` stays as the DP-stable + /// `"upstream_error"` (per #327, Anthropic's `rate_limit_error` + /// token must not bleed through). #[tokio::test] - async fn upstream_anthropic_rate_limit_translates_to_openai_rate_limit_exceeded() { + async fn upstream_anthropic_rate_limit_derives_openai_rate_limit_exceeded_code() { use aisix_provider_anthropic::AnthropicBridge; let upstream = MockServer::start().await; @@ -1002,7 +1011,10 @@ mod tests { assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); let v: serde_json::Value = serde_json::from_slice(&to_bytes(resp.into_body(), 1024).await.unwrap()).unwrap(); - assert_eq!(v["error"]["type"], "rate_limit_exceeded"); + // Per #327: `error.type` is the DP-stable token, never the + // upstream's. Per #322: `error.code` is the derived OpenAI + // string code so SDK retry logic fires correctly. + assert_eq!(v["error"]["type"], "upstream_error"); assert_eq!(v["error"]["code"], "rate_limit_exceeded"); assert_eq!(v["error"]["message"], "slow down"); }