Skip to main content

openkind_api/
error.rs

1//! API errors. Mapped to HTTP status codes per the Jev spec and the
2//! Python SDK exception taxonomy:
3//! - 400 `TypeSafeBadRequestError`           — malformed JSON
4//! - 401 `TypeSafeAuthenticationError`       — missing/invalid API key
5//! - 404 `TypeSafeNotFoundError`             — unknown model alias
6//! - 422 `TypeSafeUnprocessableEntityError`  — body validation failed
7//! - 429 `TypeSafeRateLimitError`            — too many requests (carries Retry-After)
8//! - 529 `TypeSafeAPIOverloadedError`        — server overloaded (carries Retry-After)
9//! - 5xx `TypeSafeInternalServerError`       — engine/backend failure
10//!
11//! Any 429/529 response includes `retry-after-ms` and (if the caller
12//! requested it) `Retry-After` HTTP headers, so the SDK's `RetryPolicy`
13//! can back off correctly.
14
15use axum::{
16    http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
17    response::{IntoResponse, Response},
18    Json,
19};
20use openkind_engine::EngineError;
21use serde_json::json;
22use std::time::Duration;
23
24const RETRY_AFTER_MS: HeaderName = HeaderName::from_static("retry-after-ms");
25const RETRY_AFTER: HeaderName = HeaderName::from_static("retry-after");
26
27/// API transport and protocol errors.
28///
29/// Mapped to canonical HTTP status codes and JSON error envelopes matching the Python SDK taxonomy.
30#[derive(Debug, thiserror::Error)]
31pub enum ApiError {
32    /// Request failed validation or syntactic constraints (HTTP 422 Unprocessable Entity, `invalid_body`).
33    #[error("invalid request body: {0}")]
34    InvalidBody(String),
35
36    /// Payload contains malformed JSON syntax (HTTP 400 Bad Request, `bad_json`).
37    #[error("invalid JSON: {0}")]
38    BadJson(String),
39
40    /// Request payload exceeds maximum allowed size (HTTP 413 Payload Too Large, `payload_too_large`).
41    #[error("payload too large: {0}")]
42    PayloadTooLarge(String),
43
44    /// Missing or invalid Bearer authentication token (HTTP 401 Unauthorized, `unauthorized`).
45    #[error("missing or invalid API key")]
46    Unauthorized,
47
48    /// Request exceeded rate limits (HTTP 429 Too Many Requests, `rate_limited`).
49    #[error("rate limited; retry after {retry_after_ms} ms")]
50    RateLimited {
51        /// Suggested backoff period in milliseconds before retrying.
52        retry_after_ms: u64,
53    },
54
55    /// Server is temporarily overloaded (HTTP 529 API Overloaded, `overloaded`).
56    #[error("server overloaded; retry after {retry_after_ms} ms")]
57    Overloaded {
58        /// Suggested backoff period in milliseconds before retrying.
59        retry_after_ms: u64,
60    },
61
62    /// Error propagated from underlying decision engine dispatch.
63    #[error("engine error: {0}")]
64    Engine(#[from] EngineError),
65
66    /// Upstream (proxied) service failed or was unreachable
67    /// (HTTP 502 Bad Gateway, `bad_gateway`).
68    #[error("bad gateway: {0}")]
69    BadGateway(String),
70
71    /// Unexpected internal server error (HTTP 500 Internal Server Error, `internal_error`).
72    #[error("internal error: {0}")]
73    Internal(String),
74}
75
76impl ApiError {
77    fn status_and_code(&self) -> (StatusCode, &'static str) {
78        match self {
79            ApiError::InvalidBody(_)
80            | ApiError::Engine(EngineError::Invalid(_))
81            | ApiError::Engine(EngineError::Unsupported { .. }) => {
82                (StatusCode::UNPROCESSABLE_ENTITY, "invalid_body")
83            }
84            ApiError::BadJson(_) => (StatusCode::BAD_REQUEST, "bad_json"),
85            ApiError::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large"),
86            ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
87            ApiError::RateLimited { .. } => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
88            ApiError::Overloaded { .. } => (StatusCode::from_u16(529).unwrap(), "overloaded"),
89            ApiError::Engine(EngineError::UnknownModel(_)) => {
90                (StatusCode::NOT_FOUND, "unknown_model")
91            }
92            ApiError::Engine(EngineError::Overloaded { .. }) => {
93                (StatusCode::from_u16(529).unwrap(), "overloaded")
94            }
95            ApiError::Engine(EngineError::DeadlineExceeded { .. }) => {
96                (StatusCode::GATEWAY_TIMEOUT, "deadline_exceeded")
97            }
98            ApiError::Engine(
99                EngineError::Backend { .. } | EngineError::BackendValidation { .. },
100            ) => (StatusCode::INTERNAL_SERVER_ERROR, "backend_error"),
101            ApiError::BadGateway(_) => (StatusCode::BAD_GATEWAY, "bad_gateway"),
102            ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
103        }
104    }
105
106    /// Retry-After / retry-after-ms in milliseconds, if this error carries one.
107    fn retry_after_ms(&self) -> Option<u64> {
108        match self {
109            ApiError::RateLimited { retry_after_ms } | ApiError::Overloaded { retry_after_ms } => {
110                Some(*retry_after_ms)
111            }
112            ApiError::Engine(EngineError::Overloaded { retry_after_ms, .. }) => {
113                Some(*retry_after_ms)
114            }
115            _ => None,
116        }
117    }
118}
119
120impl IntoResponse for ApiError {
121    fn into_response(self) -> Response {
122        let (status, code) = self.status_and_code();
123
124        let mut headers = HeaderMap::new();
125        if let Some(ms) = self.retry_after_ms() {
126            // Both: SDK reads either.
127            headers.insert(RETRY_AFTER_MS, HeaderValue::from(ms));
128            let secs = ms.div_ceil(1000);
129            if let Ok(v) = HeaderValue::from_str(&secs.to_string()) {
130                headers.insert(RETRY_AFTER, v);
131            }
132        }
133        // WWW-Authenticate hint for 401 (the SDK doesn't strictly need it
134        // but it's the correct HTTP semantic).
135        if matches!(self, ApiError::Unauthorized) {
136            if let Ok(v) = HeaderValue::from_str("Bearer") {
137                headers.insert(axum::http::header::WWW_AUTHENTICATE, v);
138            }
139        }
140
141        let body = Json(json!({
142            "error": {
143                "code": code,
144                "message": self.to_string(),
145            }
146        }));
147
148        (status, headers, body).into_response()
149    }
150}
151
152impl From<serde_json::Error> for ApiError {
153    fn from(e: serde_json::Error) -> Self {
154        ApiError::BadJson(e.to_string())
155    }
156}
157
158/// Convenience: build a RateLimited with a Duration.
159impl From<Duration> for ApiError {
160    fn from(d: Duration) -> Self {
161        ApiError::RateLimited {
162            retry_after_ms: d.as_millis().min(u64::MAX as u128) as u64,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use axum::http::StatusCode;
171    use http_body_util::BodyExt;
172    use openkind_core::ValidationError;
173
174    async fn extract_body_json(resp: Response) -> (StatusCode, HeaderMap, serde_json::Value) {
175        let status = resp.status();
176        let headers = resp.headers().clone();
177        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
178        let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
179        (status, headers, val)
180    }
181
182    #[tokio::test]
183    async fn rate_limited_error_into_response() {
184        let err = ApiError::RateLimited {
185            retry_after_ms: 1500,
186        };
187        let (status, headers, body) = extract_body_json(err.into_response()).await;
188        assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
189        assert_eq!(headers.get("retry-after-ms").unwrap(), "1500");
190        assert_eq!(headers.get("retry-after").unwrap(), "2"); // 1500.div_ceil(1000) = 2
191        assert_eq!(body["error"]["code"], "rate_limited");
192    }
193
194    #[tokio::test]
195    async fn bad_gateway_error_into_response() {
196        let err = ApiError::BadGateway("upstream unreachable".into());
197        let (status, _headers, body) = extract_body_json(err.into_response()).await;
198        assert_eq!(status, StatusCode::BAD_GATEWAY);
199        assert_eq!(body["error"]["code"], "bad_gateway");
200        assert!(
201            body["error"]["message"]
202                .as_str()
203                .unwrap()
204                .contains("upstream unreachable"),
205            "{body}"
206        );
207    }
208
209    #[tokio::test]
210    async fn overloaded_error_into_response() {
211        let err = ApiError::Overloaded {
212            retry_after_ms: 500,
213        };
214        let (status, headers, body) = extract_body_json(err.into_response()).await;
215        assert_eq!(status, StatusCode::from_u16(529).unwrap());
216        assert_eq!(headers.get("retry-after-ms").unwrap(), "500");
217        assert_eq!(headers.get("retry-after").unwrap(), "1");
218        assert_eq!(body["error"]["code"], "overloaded");
219    }
220
221    #[tokio::test]
222    async fn unauthorized_error_into_response() {
223        let err = ApiError::Unauthorized;
224        let (status, headers, body) = extract_body_json(err.into_response()).await;
225        assert_eq!(status, StatusCode::UNAUTHORIZED);
226        assert_eq!(headers.get("www-authenticate").unwrap(), "Bearer");
227        assert_eq!(body["error"]["code"], "unauthorized");
228    }
229
230    #[tokio::test]
231    async fn internal_error_into_response() {
232        let err = ApiError::Internal("db crashed".into());
233        let (status, _, body) = extract_body_json(err.into_response()).await;
234        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
235        assert_eq!(body["error"]["code"], "internal_error");
236    }
237
238    #[tokio::test]
239    async fn bad_json_and_model_not_found_into_response() {
240        let err_json = ApiError::BadJson("syntax error".into());
241        let (status, _, body) = extract_body_json(err_json.into_response()).await;
242        assert_eq!(status, StatusCode::BAD_REQUEST);
243        assert_eq!(body["error"]["code"], "bad_json");
244
245        let err_model = ApiError::Engine(EngineError::UnknownModel("gpt-5".into()));
246        let (status, _, body) = extract_body_json(err_model.into_response()).await;
247        assert_eq!(status, StatusCode::NOT_FOUND);
248        assert_eq!(body["error"]["code"], "unknown_model");
249    }
250
251    #[tokio::test]
252    async fn validation_and_backend_errors_into_response() {
253        let err_val = ApiError::Engine(EngineError::Invalid(ValidationError::NoQuestions));
254        let (status, _, body) = extract_body_json(err_val.into_response()).await;
255        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
256        assert_eq!(body["error"]["code"], "invalid_body");
257
258        let err_validation = ApiError::Engine(EngineError::BackendValidation {
259            backend: "mock".into(),
260            source: ValidationError::MissingAnswer("q".into()),
261        });
262        let (status, _, body) = extract_body_json(err_validation.into_response()).await;
263        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
264        assert_eq!(body["error"]["code"], "backend_error");
265
266        let err_backend = ApiError::Engine(EngineError::Backend {
267            backend: "mock".into(),
268            message: "simulated failure".into(),
269        });
270        let (status, _, body) = extract_body_json(err_backend.into_response()).await;
271        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
272        assert_eq!(body["error"]["code"], "backend_error");
273    }
274
275    #[tokio::test]
276    async fn unsupported_and_engine_overload_keep_transport_semantics() {
277        let unsupported = ApiError::Engine(EngineError::Unsupported {
278            backend: "native".into(),
279            message: "explicit semantic none required".into(),
280        });
281        let (status, _, body) = extract_body_json(unsupported.into_response()).await;
282        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
283        assert_eq!(body["error"]["code"], "invalid_body");
284
285        let overloaded = ApiError::Engine(EngineError::Overloaded {
286            backend: "native".into(),
287            retry_after_ms: 750,
288        });
289        let (status, headers, body) = extract_body_json(overloaded.into_response()).await;
290        assert_eq!(status, StatusCode::from_u16(529).unwrap());
291        assert_eq!(headers["retry-after-ms"], "750");
292        assert_eq!(body["error"]["code"], "overloaded");
293
294        let deadline = ApiError::Engine(EngineError::DeadlineExceeded {
295            backend: "native".into(),
296            timeout_ms: 30_000,
297        });
298        let (status, _, body) = extract_body_json(deadline.into_response()).await;
299        assert_eq!(status, StatusCode::GATEWAY_TIMEOUT);
300        assert_eq!(body["error"]["code"], "deadline_exceeded");
301    }
302
303    #[test]
304    fn duration_conversion_to_rate_limited() {
305        let err: ApiError = Duration::from_millis(2500).into();
306        match err {
307            ApiError::RateLimited { retry_after_ms } => assert_eq!(retry_after_ms, 2500),
308            _ => panic!("expected RateLimited"),
309        }
310    }
311
312    #[test]
313    fn duration_conversion_saturates_instead_of_wrapping() {
314        let err: ApiError = Duration::from_secs(u64::MAX).into();
315        assert!(matches!(
316            err,
317            ApiError::RateLimited {
318                retry_after_ms: u64::MAX
319            }
320        ));
321    }
322}