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
69 changes: 59 additions & 10 deletions crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,23 +867,30 @@ fn client_error(error: &LlmClientError) -> Response {
"upstream_error",
"upstream_error",
),
LlmClientError::Transport { source } | LlmClientError::InvalidResponse { source } => {
error_response(
StatusCode::BAD_GATEWAY,
source.to_string(),
"upstream_error",
"upstream_error",
)
}
// Transport sources may include the full request URL, including configured
// query credentials. Keep transport details out of client-visible errors.
LlmClientError::Transport { .. } => error_response(
StatusCode::BAD_GATEWAY,
"upstream transport error",
"upstream_error",
"upstream_error",
),
LlmClientError::InvalidResponse { source } => error_response(
StatusCode::BAD_GATEWAY,
source.to_string(),
"upstream_error",
"upstream_error",
),
LlmClientError::ResponseTranslation(message) => error_response(
StatusCode::BAD_GATEWAY,
message,
"upstream_error",
"upstream_error",
),
LlmClientError::Timeout { source } => error_response(
// Timeout sources can carry the same request context as transport errors.
LlmClientError::Timeout { .. } => error_response(
StatusCode::GATEWAY_TIMEOUT,
source.to_string(),
"upstream request timed out",
"upstream_error",
"upstream_timeout",
),
Expand Down Expand Up @@ -1459,6 +1466,48 @@ mod tests {
);
}

// Timeout sources may contain configured upstream URLs, so only a fixed message crosses the
// client-facing boundary.
#[test]
fn client_timeout_error_hides_source_details() {
const UPSTREAM_URL: &str =
"https://upstream.invalid/v1?key=CANARY_ADMIN_QUERY_KEY/responses";
let error = LlmClientError::Timeout {
source: Box::new(std::io::Error::other(format!(
"request timed out for {UPSTREAM_URL}"
))),
};

let response = client_error(&error);
let api_error = response
.extensions()
.get::<ApiError>()
.expect("client error metadata");
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
assert_eq!(api_error.message, "upstream request timed out");
assert_eq!(api_error.error_type, "upstream_error");
assert_eq!(api_error.code, "upstream_timeout");
assert!(!api_error.message.contains(UPSTREAM_URL));
}

// Response-decoding details remain client-visible.
#[test]
fn client_invalid_response_error_preserves_source_detail() {
let error = LlmClientError::InvalidResponse {
source: Box::new(std::io::Error::other("response body was truncated")),
};

let response = client_error(&error);
let api_error = response
.extensions()
.get::<ApiError>()
.expect("client error metadata");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(api_error.message, "response body was truncated");
assert_eq!(api_error.error_type, "upstream_error");
assert_eq!(api_error.code, "upstream_error");
}

// Canonical error text remains available without consuming the response body.
#[test]
fn error_response_carries_request_log_error() {
Expand Down
42 changes: 42 additions & 0 deletions crates/switchyard-server/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2021,6 +2021,48 @@ async fn anthropic_stream_error_does_not_emit_success_terminal_events() -> TestR
Ok(())
}

// A transport error must not expose credentials from the configured upstream URL.
#[tokio::test]
async fn transport_error_hides_credential_bearing_upstream_url() -> TestResult {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const CANARY: &str = "CANARY_ADMIN_QUERY_KEY";

let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
drop(listener);
let base_url = format!("http://{addr}/v1?key={CANARY}");
let upstream_request_url = format!("{base_url}/chat/completions");
let app = build_switchyard_router(random_state(&base_url, &[(ROUTE_MODEL, &["model/a"])])?);

let response = send(
&app,
"POST",
"/v1/chat/completions",
Some(json!({
"model": ROUTE_MODEL,
"messages": [{"role": "user", "content": "hello"}]
})),
)
.await?;

assert_eq!(response.status, StatusCode::BAD_GATEWAY);
let body = response.json()?;
assert_eq!(body["error"]["type"], "upstream_error");
assert_eq!(body["error"]["code"], "upstream_error");
let message = body["error"]["message"]
.as_str()
.ok_or("transport error message was not text")?;
assert_eq!(message, "upstream transport error");
assert!(
!message.contains(CANARY),
"credential leaked in {message:?}"
);
assert!(
!message.contains(&upstream_request_url),
"upstream URL leaked in {message:?}"
);
Ok(())
}

#[tokio::test]
async fn request_and_upstream_errors_use_the_inbound_wire_format() -> TestResult {
let (_upstream, app) = test_app(&[(ROUTE_MODEL, &["model/a"])]).await?;
Expand Down