Skip to main content

openkind_client/error/
api_error.rs

1//! Definition of `ApiError` and `ApiErrorKind` taxonomies.
2
3use std::time::Duration;
4
5use serde_json::Value;
6
7use super::envelope::{extract_code, extract_message, MAX_ERROR_BODY_LENGTH};
8
9/// A single unsuccessful HTTP response, with everything a caller needs to
10/// react: status, parsed error envelope, request id for support tickets, and
11/// the retry delay the server asked for.
12#[derive(Debug, Clone)]
13pub struct ApiError {
14    /// HTTP status code (e.g. `429`, `529`).
15    pub status: u16,
16    /// Machine-readable `code` from the error envelope (`rate_limited`, ...).
17    pub code: Option<String>,
18    /// Human-readable message from the error envelope, if any.
19    pub message: Option<String>,
20    /// The parsed JSON error body, when the body was valid JSON.
21    pub body: Option<Value>,
22    /// The `x-typesafe-request-id` response header, for support requests.
23    pub request_id: Option<String>,
24    /// Delay the server asked the client to wait via `retry-after-ms` /
25    /// `Retry-After`, if sent.
26    pub retry_after: Option<Duration>,
27    /// `METHOD path` of the failed request, e.g. `POST /v1/systemone`.
28    pub endpoint: Option<String>,
29}
30
31impl ApiError {
32    pub(crate) fn from_response(
33        status: u16,
34        request_id: Option<String>,
35        retry_after: Option<Duration>,
36        body: &[u8],
37        endpoint: String,
38    ) -> Self {
39        let parsed: Option<Value> = if body.is_empty() {
40            None
41        } else {
42            serde_json::from_slice(body).ok()
43        };
44        let message = extract_message(parsed.as_ref()).or_else(|| {
45            if body.is_empty() {
46                return None;
47            }
48            // Fall back to a truncated slice of whatever the body was.
49            let raw = String::from_utf8_lossy(body);
50            let mut text = raw.chars().take(MAX_ERROR_BODY_LENGTH).collect::<String>();
51            if raw.chars().count() > MAX_ERROR_BODY_LENGTH {
52                text.push('…');
53            }
54            Some(text)
55        });
56        Self {
57            status,
58            code: extract_code(parsed.as_ref()),
59            message,
60            body: parsed,
61            request_id,
62            retry_after,
63            endpoint: Some(endpoint),
64        }
65    }
66
67    /// Classify this error by HTTP status. See [`ApiErrorKind`].
68    pub fn kind(&self) -> ApiErrorKind {
69        ApiErrorKind::from_status(self.status)
70    }
71
72    /// True when this is 429 Too Many Requests.
73    pub fn is_rate_limit(&self) -> bool {
74        self.status == 429
75    }
76
77    /// True when this is 529 Overloaded (the openkind/TypeSafe overload status).
78    pub fn is_overloaded(&self) -> bool {
79        self.status == 529
80    }
81
82    /// True for 429 and 529 — the statuses that mean "retry later".
83    pub fn is_retryable_later(&self) -> bool {
84        self.is_rate_limit() || self.is_overloaded()
85    }
86}
87
88impl std::fmt::Display for ApiError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        write!(f, "{}", self.status)?;
91        if let Some(code) = &self.code {
92            write!(f, " {code}")?;
93        }
94        if let Some(message) = &self.message {
95            write!(f, ": ")?;
96            for ch in message.chars().take(super::envelope::MAX_ERROR_BODY_LENGTH) {
97                write!(f, "{ch}")?;
98            }
99            if message
100                .chars()
101                .nth(super::envelope::MAX_ERROR_BODY_LENGTH)
102                .is_some()
103            {
104                write!(f, "…")?;
105            }
106        }
107        if let Some(endpoint) = &self.endpoint {
108            write!(f, " ({endpoint})")?;
109        }
110        if let Some(request_id) = &self.request_id {
111            write!(f, " (request_id={request_id})")?;
112        }
113        Ok(())
114    }
115}
116
117impl std::error::Error for ApiError {}
118
119/// Coarse classification of an [`ApiError`] by status, mirroring the Python
120/// SDK exception subclasses.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub enum ApiErrorKind {
123    /// 400 — request body was not valid JSON (`bad_json`).
124    BadRequest,
125    /// 401 — missing or invalid API key (`unauthorized`).
126    Authentication,
127    /// 403 — key valid but not permitted for this resource
128    /// (mirrors the Python SDK's `TypeSafePermissionDeniedError`).
129    PermissionDenied,
130    /// 404 — unknown model alias (`unknown_model`).
131    NotFound,
132    /// 413 — request payload exceeded the server limit (`payload_too_large`).
133    PayloadTooLarge,
134    /// 422 — body failed schema validation (`invalid_body`).
135    UnprocessableEntity,
136    /// 429 — rate limited (`rate_limited`); carries [`ApiError::retry_after`].
137    RateLimit,
138    /// 529 — server overloaded (`overloaded`); carries [`ApiError::retry_after`].
139    Overloaded,
140    /// 5xx — engine/backend failure (`backend_error`, `internal_error`).
141    InternalServer,
142    /// Any other status.
143    Other,
144}
145
146impl ApiErrorKind {
147    /// Convert an HTTP status code into an [`ApiErrorKind`].
148    pub fn from_status(status: u16) -> Self {
149        match status {
150            400 => Self::BadRequest,
151            401 => Self::Authentication,
152            403 => Self::PermissionDenied,
153            404 => Self::NotFound,
154            413 => Self::PayloadTooLarge,
155            422 => Self::UnprocessableEntity,
156            429 => Self::RateLimit,
157            529 => Self::Overloaded,
158            s if (500..600).contains(&s) => Self::InternalServer,
159            _ => Self::Other,
160        }
161    }
162}