use http::StatusCode;
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
use openrouter_rs::{
api::models,
error::{ApiErrorKind, HttpRequestError, OpenRouterError},
};
fn spawn_error_server(
status_line: &str,
body: &str,
request_id: Option<&str>,
) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener should have local addr");
let status_line = status_line.to_string();
let body = body.to_string();
let request_id = request_id.map(ToOwned::to_owned);
let server = thread::spawn(move || {
let (mut stream, _) = listener
.accept()
.expect("server should accept one connection");
let mut request_bytes = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("server should read request");
if read == 0 {
break;
}
request_bytes.extend_from_slice(&chunk[..read]);
if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let mut headers = format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
);
if let Some(request_id) = request_id {
headers.push_str(&format!("x-request-id: {request_id}\r\n"));
}
headers.push_str("\r\n");
let response = format!("{headers}{body}");
stream
.write_all(response.as_bytes())
.expect("server should write response");
});
(format!("http://{addr}/api/v1"), server)
}
#[tokio::test]
async fn test_normalized_generic_api_error_shape() {
let (base_url, server) = spawn_error_server(
"429 Too Many Requests",
r#"{"error":{"code":429,"message":"Rate limit exceeded"}}"#,
Some("req_123"),
);
let result = models::list_models(&base_url, "test-key", None, None).await;
let error = result.expect_err("request should fail");
match error {
OpenRouterError::Api(api_error) => {
assert_eq!(api_error.status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(api_error.api_code, Some(429));
assert_eq!(api_error.message, "Rate limit exceeded");
assert_eq!(api_error.request_id.as_deref(), Some("req_123"));
assert!(matches!(api_error.kind, ApiErrorKind::Generic));
assert!(api_error.is_retryable());
}
other => panic!("expected Api error, got {other:?}"),
}
server
.join()
.expect("server thread should join in reasonable time");
}
#[tokio::test]
async fn test_api_error_preserves_top_level_openrouter_metadata() {
let (base_url, server) = spawn_error_server(
"403 Forbidden",
r#"{
"error": {
"code": 403,
"message": "Guardrail blocked request"
},
"openrouter_metadata": {
"attempt": 1,
"pipeline": [{
"type": "guardrail",
"guardrail_id": "gr_123",
"data": {
"action": "block",
"patterns": ["secret"]
}
}]
},
"user_id": "user_123"
}"#,
Some("req_guardrail"),
);
let result = models::list_models(&base_url, "test-key", None, None).await;
let error = result.expect_err("request should fail");
match error {
OpenRouterError::Api(api_error) => {
assert_eq!(api_error.status, StatusCode::FORBIDDEN);
assert_eq!(api_error.api_code, Some(403));
assert_eq!(api_error.message, "Guardrail blocked request");
assert_eq!(api_error.request_id.as_deref(), Some("req_guardrail"));
assert_eq!(
api_error
.metadata
.as_ref()
.and_then(|metadata| metadata.get("openrouter_metadata"))
.and_then(|metadata| metadata.get("pipeline"))
.and_then(|pipeline| pipeline.get(0))
.and_then(|stage| stage.get("guardrail_id"))
.and_then(|value| value.as_str()),
Some("gr_123")
);
assert_eq!(
api_error
.metadata
.as_ref()
.and_then(|metadata| metadata.get("user_id"))
.and_then(|value| value.as_str()),
Some("user_123")
);
}
other => panic!("expected Api error, got {other:?}"),
}
server
.join()
.expect("server thread should join in reasonable time");
}
#[tokio::test]
async fn test_unreadable_error_body_preserves_read_failure_context() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener should have local addr");
let server = thread::spawn(move || {
let (mut stream, _) = listener
.accept()
.expect("server should accept one connection");
let mut request_bytes = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("server should read request");
if read == 0 {
break;
}
request_bytes.extend_from_slice(&chunk[..read]);
if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let partial_body = "{\"error\":";
let response = format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nx-request-id: req_truncated\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
partial_body.len() + 32,
partial_body
);
stream
.write_all(response.as_bytes())
.expect("server should write partial response");
});
let base_url = format!("http://{addr}/api/v1");
let error = models::list_models(&base_url, "test-key", None, None)
.await
.expect_err("request should fail");
match error {
OpenRouterError::Api(api_error) => {
assert_eq!(api_error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(api_error.request_id.as_deref(), Some("req_truncated"));
assert!(
api_error
.message
.contains("Failed to read error response body")
);
assert_eq!(
api_error
.metadata
.as_ref()
.and_then(|metadata| metadata.get("body_read_error"))
.and_then(|value| value.as_str())
.map(|value| !value.is_empty()),
Some(true)
);
}
other => panic!("expected Api error, got {other:?}"),
}
server
.join()
.expect("server thread should join in reasonable time");
}
#[tokio::test]
async fn test_normalized_provider_error_shape() {
let (base_url, server) = spawn_error_server(
"502 Bad Gateway",
r#"{
"error": {
"code": 502,
"message": "Provider overloaded",
"metadata": {
"provider_name": "openai",
"raw": {"upstream_code":"E_OVERLOAD"}
}
}
}"#,
Some("req_provider"),
);
let result = models::list_models(&base_url, "test-key", None, None).await;
let error = result.expect_err("request should fail");
match error {
OpenRouterError::Api(api_error) => {
assert_eq!(api_error.status, StatusCode::BAD_GATEWAY);
assert_eq!(api_error.api_code, Some(502));
assert_eq!(api_error.request_id.as_deref(), Some("req_provider"));
assert!(api_error.is_retryable());
match api_error.kind {
ApiErrorKind::Provider { provider_name, raw } => {
assert_eq!(provider_name, "openai");
assert_eq!(
raw.get("upstream_code").and_then(|value| value.as_str()),
Some("E_OVERLOAD")
);
}
other => panic!("expected provider kind, got {other:?}"),
}
}
other => panic!("expected Api error, got {other:?}"),
}
server
.join()
.expect("server thread should join in reasonable time");
}
#[tokio::test]
async fn test_normalized_moderation_error_shape() {
let (base_url, server) = spawn_error_server(
"400 Bad Request",
r#"{
"error": {
"code": 400,
"message": "Moderation blocked",
"metadata": {
"reasons": ["hate"],
"flagged_input": "bad text",
"provider_name": "openai",
"model_slug": "gpt-4.1"
}
}
}"#,
Some("req_mod"),
);
let result = models::list_models(&base_url, "test-key", None, None).await;
let error = result.expect_err("request should fail");
match error {
OpenRouterError::Api(api_error) => {
assert_eq!(api_error.status, StatusCode::BAD_REQUEST);
assert_eq!(api_error.api_code, Some(400));
assert_eq!(api_error.request_id.as_deref(), Some("req_mod"));
assert!(!api_error.is_retryable());
assert_eq!(
api_error
.metadata
.as_ref()
.and_then(|metadata| metadata.get("provider_name"))
.and_then(|value| value.as_str()),
Some("openai")
);
assert_eq!(
api_error
.metadata
.as_ref()
.and_then(|metadata| metadata.get("model_slug"))
.and_then(|value| value.as_str()),
Some("gpt-4.1")
);
match api_error.kind {
ApiErrorKind::Moderation {
reasons,
flagged_input,
provider_name,
model_slug,
} => {
assert_eq!(reasons, vec!["hate"]);
assert_eq!(flagged_input, "bad text");
assert_eq!(provider_name, "openai");
assert_eq!(model_slug, "gpt-4.1");
}
other => panic!("expected moderation kind, got {other:?}"),
}
}
other => panic!("expected Api error, got {other:?}"),
}
server
.join()
.expect("server thread should join in reasonable time");
}
#[tokio::test]
async fn test_plain_text_error_is_still_normalized() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener should have local addr");
let server = thread::spawn(move || {
let (mut stream, _) = listener
.accept()
.expect("server should accept one connection");
let mut request_bytes = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("server should read request");
if read == 0 {
break;
}
request_bytes.extend_from_slice(&chunk[..read]);
if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let body = "upstream timeout";
let response = format!(
"HTTP/1.1 504 Gateway Timeout\r\nContent-Type: text/plain\r\nx-request-id: req_plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("server should write response");
});
let base_url = format!("http://{addr}/api/v1");
let result = models::list_models(&base_url, "test-key", None, None).await;
let error = result.expect_err("request should fail");
match error {
OpenRouterError::Api(api_error) => {
assert_eq!(api_error.status, StatusCode::GATEWAY_TIMEOUT);
assert_eq!(api_error.api_code, Some(504));
assert_eq!(api_error.request_id.as_deref(), Some("req_plain"));
assert_eq!(api_error.message, "upstream timeout");
assert!(matches!(api_error.kind, ApiErrorKind::Generic));
assert!(api_error.is_retryable());
}
other => panic!("expected Api error, got {other:?}"),
}
server
.join()
.expect("server thread should join in reasonable time");
}
#[test]
fn test_http_request_error_is_backend_neutral() {
let error = HttpRequestError::new("connection refused");
assert_eq!(error.message(), "connection refused");
assert_eq!(error.to_string(), "connection refused");
}