Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions apis/src/openai/error_response_formatter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Praxis Contributors

//! OpenAI error response formatter for Praxis fatal proxy failures.
//!
//! Implements [`ErrorResponseFormatter`] to produce the standard
//! `{"error": {...}}` envelope that OpenAI SDKs expect. Installed
//! as a request extension after positive OpenAI classification so
//! that Praxis calls it from `fail_to_proxy` instead of emitting
//! RFC 9457 Problem Details.

use bytes::Bytes;
use http::HeaderValue;
use praxis_filter::{ErrorResponseContext, ErrorResponseFormatter, FormattedErrorResponse};

/// Formats Praxis fatal proxy failures as OpenAI error JSON.
///
/// Produces `{"error":{"message":"…","type":"…","param":null,"code":"…"}}`.
///
/// Mapping:
/// - `error.message` ← `context.message`
/// - `error.code` ← `context.code` (Praxis machine-readable code)
/// - `error.type` ← standardized OpenAI error type based on HTTP status code
/// - `error.param` ← always `null`
pub(crate) struct OpenAiErrorFormatter;

/// Maps an HTTP status code to a standardized OpenAI error type string.
fn map_error_type(status: u16) -> &'static str {
match status {
500..=599 => "server_error",
429 => "rate_limit_error",
401 => "authentication_error",
403 => "permission_error",
404 => "not_found_error",
400 | 422 => "invalid_request_error",
_ => "api_error",
}
}

impl ErrorResponseFormatter for OpenAiErrorFormatter {
fn format(&self, context: &ErrorResponseContext<'_>) -> FormattedErrorResponse {
let error_type = map_error_type(context.status);

let body = serde_json::json!({
"error": {
"message": context.message,
"type": error_type,
"param": null,
"code": context.code,
},
});

FormattedErrorResponse::new(
Bytes::from(body.to_string()),
HeaderValue::from_static("application/json"),
)
}
}

// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------

#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")]
mod tests {
use super::*;

#[test]
fn connection_refusal_produces_valid_openai_json() {
let ctx = ErrorResponseContext::new("upstream_connect_refused", "Connection refused", 502);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(parsed["error"]["message"], "Connection refused");
assert_eq!(parsed["error"]["type"], "server_error");
assert_eq!(parsed["error"]["code"], "upstream_connect_refused");
assert!(parsed["error"]["param"].is_null());
}

#[test]
fn timeout_produces_valid_openai_json() {
let ctx = ErrorResponseContext::new("upstream_connect_timeout", "Connection timed out", 504);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(parsed["error"]["message"], "Connection timed out");
assert_eq!(parsed["error"]["type"], "server_error");
assert_eq!(parsed["error"]["code"], "upstream_connect_timeout");
assert!(parsed["error"]["param"].is_null());
}

#[test]
fn content_type_is_application_json() {
let ctx = ErrorResponseContext::new("upstream_connect_error", "Connection refused", 502);
let response = OpenAiErrorFormatter.format(&ctx);

assert_eq!(response.content_type, HeaderValue::from_static("application/json"));
}

#[test]
fn json_escaping_handles_special_characters() {
let ctx = ErrorResponseContext::new("server_error", "line1\nline2\"quoted\"\tand\\backslash", 500);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
parsed["error"]["message"].as_str().unwrap(),
"line1\nline2\"quoted\"\tand\\backslash"
);
}

#[test]
fn json_escaping_handles_unicode() {
let ctx = ErrorResponseContext::new("server_error", "Connection to サーバー failed 🔥 (مرحبا)", 500);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
parsed["error"]["message"].as_str().unwrap(),
"Connection to サーバー failed 🔥 (مرحبا)"
);

assert!(std::str::from_utf8(&response.body).is_ok());
}

#[test]
fn fivex_status_uses_server_error_type() {
for status in [500, 502, 503, 504] {
let ctx = ErrorResponseContext::new("some_code", "some message", status);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
parsed["error"]["type"], "server_error",
"status {status} should use server_error type"
);
assert_eq!(
parsed["error"]["code"], "some_code",
"status {status} should preserve the Praxis code"
);
}
}

#[test]
fn fourx_status_maps_to_openai_error_types() {
let cases = [
(400, "invalid_request_error"),
(401, "authentication_error"),
(403, "permission_error"),
(404, "not_found_error"),
(422, "invalid_request_error"),
(429, "rate_limit_error"),
(418, "api_error"),
];

for (status, expected_type) in cases {
let ctx = ErrorResponseContext::new("custom_code", "test error", status);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
parsed["error"]["type"], expected_type,
"status {status} should map to error type '{expected_type}'"
);
assert_eq!(
parsed["error"]["code"], "custom_code",
"status {status} should preserve the Praxis code"
);
}
}

#[test]
fn param_is_always_null() {
for status in [400, 429, 500, 502, 504] {
let ctx = ErrorResponseContext::new("test_code", "test message", status);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert!(
parsed["error"]["param"].is_null(),
"param should be null for status {status}"
);
}
}

#[test]
fn output_is_valid_json() {
let ctx = ErrorResponseContext::new("upstream_connect_error", "failed", 502);
let response = OpenAiErrorFormatter.format(&ctx);

let parsed: Result<serde_json::Value, _> = serde_json::from_slice(&response.body);
assert!(parsed.is_ok(), "output must be valid JSON");

let parsed = parsed.unwrap();
assert!(parsed.get("error").is_some(), "must have top-level error key");
assert!(parsed["error"].get("message").is_some());
assert!(parsed["error"].get("type").is_some());
assert!(parsed["error"].get("param").is_some());
assert!(parsed["error"].get("code").is_some());
}
}
1 change: 1 addition & 0 deletions apis/src/openai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)]
pub(crate) mod api_client;
pub(crate) mod conversations;
pub(crate) mod error_response_formatter;
pub(crate) mod include;
mod operation;
pub(crate) mod responses;
Expand Down
24 changes: 23 additions & 1 deletion apis/src/openai/responses/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ use std::{borrow::Cow, io};
use async_trait::async_trait;
use bytes::Bytes;
use praxis_filter::{
BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext,
BodyAccess, BodyMode, ErrorResponseFormatterHandle, FilterAction, FilterError, HttpFilter, HttpFilterContext,
builtins::http::payload_processing::OnInvalidBehavior, parse_filter_config,
};
use tracing::{debug, trace};
Expand Down Expand Up @@ -273,6 +273,8 @@ impl HttpFilter for ResponsesFormatFilter {
compute_mode(&classified)
};

install_error_formatter(ctx, classified.format);

write_metadata(ctx, &classified, mode);
promote_headers(ctx, &classified, &self.config, mode);
promote_filter_results(ctx, &classified, mode)?;
Expand All @@ -285,6 +287,26 @@ impl HttpFilter for ResponsesFormatFilter {
// Helpers
// -----------------------------------------------------------------------------

/// Install the OpenAI error response formatter for positively classified
/// OpenAI requests (Responses and Chat Completions).
///
/// When installed, Praxis invokes the formatter from `fail_to_proxy`
/// instead of emitting RFC 9457 Problem Details. Non-OpenAI formats
/// (Anthropic, unknown, invalid, non-JSON) are left untouched.
fn install_error_formatter(ctx: &mut HttpFilterContext<'_>, format: AiRequestFormat) {
match format {
AiRequestFormat::Responses | AiRequestFormat::ChatCompletions => {
ctx.extensions.insert(ErrorResponseFormatterHandle::new(
crate::openai::error_response_formatter::OpenAiErrorFormatter,
));
},
AiRequestFormat::AnthropicMessages
| AiRequestFormat::UnknownJson
| AiRequestFormat::InvalidJson
| AiRequestFormat::NonJson => {},
}
}

/// Classify a request from a recognized path/handshake or its body.
fn classify_request(ctx: &HttpFilterContext<'_>, bytes: &[u8]) -> (ClassifiedRequest, bool) {
let method = &ctx.request.method;
Expand Down
15 changes: 15 additions & 0 deletions apis/src/openai/responses/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,11 @@ async fn get_v1_responses_with_id_classifies_as_responses() {
Some("openai_responses"),
"GET /v1/responses/{{id}} should classify as responses"
);

assert!(
ctx.extensions.get::<ErrorResponseFormatterHandle>().is_some(),
"openai error response formatter should be installed for responses path"
);
}

#[tokio::test]
Expand Down Expand Up @@ -872,6 +877,11 @@ async fn get_responses_without_websocket_headers_classifies_body_normally() {
Some("non_json"),
"an ordinary GET list request must not be promoted as a WebSocket handshake"
);

assert!(
ctx.extensions.get::<ErrorResponseFormatterHandle>().is_none(),
"openai error response formatter should not be installed for non-json"
);
}

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -976,6 +986,11 @@ async fn mode_not_set_for_chat_completions() {
!headers.contains_key("x-praxis-responses-mode"),
"mode header absent for chat_completions"
);

assert!(
ctx.extensions.get::<ErrorResponseFormatterHandle>().is_some(),
"openai error response formatter should be installed for chat completions"
);
}

#[tokio::test]
Expand Down
Loading
Loading