mod common;
use axum::http::HeaderMap;
use axum::Json;
use std::time::Duration;
use common::*;
use openkind_client::{ApiErrorKind, Error};
use serde_json::json;
#[tokio::test]
async fn error_mapping_matches_python_exception_taxonomy() {
let cases: &[(u16, ApiErrorKind)] = &[
(400, ApiErrorKind::BadRequest),
(401, ApiErrorKind::Authentication),
(403, ApiErrorKind::PermissionDenied),
(404, ApiErrorKind::NotFound),
(422, ApiErrorKind::UnprocessableEntity),
(429, ApiErrorKind::RateLimit),
(500, ApiErrorKind::InternalServer),
(503, ApiErrorKind::InternalServer),
(408, ApiErrorKind::Other),
(409, ApiErrorKind::Other),
(302, ApiErrorKind::Other),
];
for &(status, kind) in cases {
let body = json!({"detail": {"message": "Server explanation"}});
let (url, _requests) = spawn(move |_| {
Outcome::error(status, body.clone())
.with_request_id("req_123")
.with_retry_after_ms(125)
})
.await;
let client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
let err = client.list_models().await.unwrap_err();
assert_eq!(err.kind(), Some(kind), "status {status}");
assert_eq!(err.status(), Some(status));
match &err {
Error::Api(api) => {
assert_eq!(
api.body.as_ref().unwrap()["detail"]["message"],
"Server explanation"
);
assert_eq!(api.request_id.as_deref(), Some("req_123"));
assert_eq!(api.message.as_deref(), Some("Server explanation"));
assert_eq!(api.retry_after, Some(Duration::from_millis(125)));
let displayed = api.to_string();
assert_eq!(
displayed,
format!("{status}: Server explanation (GET /v1/models) (request_id=req_123)")
);
}
other => panic!("expected Api error for {status}, got {other:?}"),
}
if status == 429 {
assert_eq!(err.retry_after(), Some(Duration::from_millis(125)));
}
}
}
#[tokio::test]
async fn error_messages_match_python_extraction_matrix() {
let cases: &[(serde_json::Value, &str)] = &[
(
json!({"error": "error", "message": "message", "detail": "detail"}),
"error",
),
(
json!({"error": {"message": "nested error"}, "message": "message"}),
"nested error",
),
(json!({"message": "message", "detail": "detail"}), "message"),
(json!({"detail": "detail"}), "detail"),
(
json!({"detail": {"message": "nested detail"}}),
"nested detail",
),
(
json!({"detail": [
{"loc": ["body", "questions", "q", "score", "criteria", 0], "msg": "Invalid"},
{"msg": "Missing"},
{}
]}),
"questions.q.score.criteria.0: Invalid; Missing",
),
];
for (body, message) in cases {
let expected = body.clone();
let (url, _requests) = spawn(move |_| Outcome::error(400, expected.clone())).await;
let client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
let err = client.list_models().await.unwrap_err();
match &err {
Error::Api(api) => {
assert_eq!(api.message.as_deref(), Some(*message), "body {body}");
}
other => panic!("expected Api error, got {other:?}"),
}
}
}
#[tokio::test]
async fn non_json_error_bodies_fall_back_like_python() {
let url = spawn_router(common_plain_text_router("plain text".into())).await;
let plain_client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
match plain_client.list_models().await.unwrap_err() {
Error::Api(api) => assert_eq!(api.message.as_deref(), Some("plain text")),
other => panic!("expected Api error, got {other:?}"),
}
let url = spawn_router(common_raw_body_router(
400,
br#"{"unexpected":true}"#.to_vec(),
))
.await;
let raw_client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
match raw_client.list_models().await.unwrap_err() {
Error::Api(api) => {
assert_eq!(api.message.as_deref(), Some(r#"{"unexpected":true}"#));
assert_eq!(api.code, None);
}
other => panic!("expected Api error, got {other:?}"),
}
}
fn common_plain_text_router(text: String) -> axum::Router {
axum::Router::new().route(
"/v1/models",
axum::routing::get(
move || async move { (axum::http::StatusCode::BAD_REQUEST, text.clone()) },
),
)
}
fn common_raw_body_router(status: u16, body: Vec<u8>) -> axum::Router {
axum::Router::new().route(
"/v1/models",
axum::routing::get(move || {
let body = body.clone();
async move { (axum::http::StatusCode::from_u16(status).unwrap(), body) }
}),
)
}
#[tokio::test]
async fn transport_errors_classify_like_python() {
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let attempts_for_handler = attempts.clone();
let app = axum::Router::new().route(
"/v1/models",
axum::routing::get(move || {
let n = attempts_for_handler.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async move {
if n == 0 {
tokio::time::sleep(Duration::from_millis(200)).await;
}
Json(json!({"models": []}))
}
}),
);
let url = spawn_router(app).await;
let stall_client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
let err = stall_client
.list_models_with(&RequestOptions::new().timeout(Duration::from_millis(50)))
.await
.unwrap_err();
match err {
Error::Timeout { timeout } => assert_eq!(timeout, Duration::from_millis(50)),
other => panic!("expected Timeout, got {other:?}"),
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let refused_client = client(
&format!("http://{addr}"),
openkind_client::RetryPolicy::new().max_retries(0),
);
let err = refused_client.list_models().await.unwrap_err();
assert!(matches!(err, Error::Connection(_)), "got {err:?}");
}
#[tokio::test]
async fn api_error_endpoint_names_resource_without_credentials() {
let (url, _requests) = spawn(move |_| {
Outcome::error(
404,
json!({"error": {"code": "unknown_model", "message": "nope"}}),
)
})
.await;
let client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
let err = client.list_models().await.unwrap_err();
match &err {
Error::Api(api) => {
assert_eq!(api.endpoint.as_deref(), Some("GET /v1/models"));
let displayed = api.to_string();
assert!(!displayed.contains("test-key"), "{displayed}");
}
other => panic!("expected Api error, got {other:?}"),
}
let err = client.evaluate(evaluate_request()).await.unwrap_err();
match &err {
Error::Api(api) => assert_eq!(api.endpoint.as_deref(), Some("POST /v1/systemone")),
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn error_body_edge_cases_do_not_panic() {
let url = spawn_router(common_raw_body_router(500, Vec::new())).await;
let empty_client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
match empty_client.list_models().await.unwrap_err() {
Error::Api(api) => {
assert_eq!(api.message, None);
assert_eq!(api.body, None);
}
other => panic!("expected Api error, got {other:?}"),
}
let url = spawn_router(common_raw_body_router(500, vec![0xff, 0xfe, b'x'])).await;
let lossy_client = client(&url, openkind_client::RetryPolicy::new().max_retries(0));
match lossy_client.list_models().await.unwrap_err() {
Error::Api(api) => assert!(api.message.is_some()),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn errors_are_send_sync_across_threads() {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<Error>();
assert_send_sync::<openkind_client::ApiError>();
assert_send_sync::<openkind_client::RetryPolicy>();
}
#[tokio::test]
async fn protected_headers_cannot_be_overridden() {
let received: std::sync::Arc<std::sync::Mutex<Vec<HeaderMap>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let received_for_handler = received.clone();
let app = axum::Router::new().route(
"/prefix/v1/systemone",
axum::routing::post(move |headers: HeaderMap, _body: axum::body::Bytes| {
let received = received_for_handler.clone();
async move {
received.lock().unwrap().push(headers);
Json(json!({
"model": "jev-latest",
"usage": {"input_tokens": 1, "output_tokens": 1},
"answers": {"q": {"type": "noul", "noul": 0.5}}
}))
}
}),
);
let base = spawn_router(app).await;
let url = format!("{base}/prefix///");
let client = Client::builder()
.api_key("test-key")
.base_url(&url)
.default_model("client-model")
.timeout(Duration::from_secs(5))
.header(
axum::http::HeaderName::from_static("authorization"),
axum::http::HeaderValue::from_static("injected-secret"),
)
.header(
axum::http::HeaderName::from_static("accept"),
axum::http::HeaderValue::from_static("text/plain"),
)
.header(
axum::http::HeaderName::from_static("user-agent"),
axum::http::HeaderValue::from_static("wrong"),
)
.header(
axum::http::HeaderName::from_static("x-typesafe-sdk"),
axum::http::HeaderValue::from_static("wrong"),
)
.header(
axum::http::HeaderName::from_static("x-typesafe-runtime"),
axum::http::HeaderValue::from_static("wrong"),
)
.header(
axum::http::HeaderName::from_static("x-team"),
axum::http::HeaderValue::from_static("default"),
)
.header(
axum::http::HeaderName::from_static("x-default"),
axum::http::HeaderValue::from_static("kept"),
)
.build()
.unwrap();
let opts = RequestOptions::new()
.header(
axum::http::HeaderName::from_static("x-team"),
axum::http::HeaderValue::from_static("call"),
)
.header(
axum::http::HeaderName::from_static("x-typesafe-retry-count"),
axum::http::HeaderValue::from_static("99"),
)
.header(
axum::http::HeaderName::from_static("x-call"),
axum::http::HeaderValue::from_static("present"),
);
let response = client
.system_one_with("hello", [("q", question::noul("?"))], &opts)
.await;
assert!(response.is_ok(), "{response:?}");
let headers = received.lock().unwrap();
assert_eq!(headers.len(), 1);
let h = &headers[0];
assert_eq!(h["authorization"], "Bearer test-key");
assert_eq!(h["accept"], "application/json");
assert!(h["user-agent"]
.to_str()
.unwrap()
.starts_with("openkind-client/"));
assert_eq!(h["x-typesafe-sdk"], h["user-agent"]);
assert!(h["x-typesafe-runtime"]
.to_str()
.unwrap()
.starts_with("rust"));
assert!(
h.get("x-typesafe-retry-count").is_none(),
"initial attempt carries no retry count, and extras cannot smuggle one"
);
assert_eq!(
h["x-team"], "call",
"per-call extras win over client defaults"
);
assert_eq!(h["x-default"], "kept");
assert_eq!(h["content-type"], "application/json");
}
use openkind_client::{question, Client, RequestOptions};