Skip to main content

typesafe_rs/
error.rs

1use std::fmt;
2use std::time::Duration;
3
4use bytes::Bytes;
5use http::{HeaderMap, StatusCode};
6
7use crate::types::ResponseMeta;
8
9/// SDK error taxonomy, aligned with the official TypeScript classes.
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum Error {
13    /// No API key in config or `TYPESAFE_API_KEY`.
14    #[error(
15        "No API key was provided. Pass `api_key` to ClientConfig or set the TYPESAFE_API_KEY environment variable."
16    )]
17    MissingApiKey,
18    /// Client-side validation failed before a network call.
19    #[error("{0}")]
20    InvalidRequest(String),
21    /// DNS, TLS, or connection failure (`APIConnectionError`).
22    #[error("Connection error: {0}")]
23    Connection(#[source] TransportError),
24    /// The attempt exceeded the configured timeout (`APITimeoutError`).
25    #[error("Request timed out after {}ms.", after.as_millis())]
26    Timeout {
27        /// Timeout budget that elapsed.
28        after: Duration,
29    },
30    /// Non-2xx HTTP response after retries are exhausted.
31    #[error(transparent)]
32    Api(Box<ApiError>),
33    /// Response body was not valid JSON for the expected type.
34    #[error("failed to decode response: {source}")]
35    Decode {
36        /// Serde error.
37        #[source]
38        source: serde_json::Error,
39        /// Raw response bytes (never shown in [`Display`](fmt::Display)).
40        body: Bytes,
41        /// HTTP metadata from the failing attempt.
42        meta: Box<ResponseMeta>,
43    },
44    /// JSON parsed but did not have the documented shape.
45    #[error("Unexpected response shape from {endpoint}")]
46    UnexpectedShape {
47        /// Endpoint that returned the unexpected body.
48        endpoint: &'static str,
49        /// HTTP metadata from the failing attempt.
50        meta: Box<ResponseMeta>,
51    },
52}
53
54impl Error {
55    /// `x-typesafe-request-id` when this error came from an HTTP response.
56    #[must_use]
57    pub fn request_id(&self) -> Option<&str> {
58        match self {
59            Self::Api(err) => err.request_id.as_deref(),
60            Self::Decode { meta, .. } | Self::UnexpectedShape { meta, .. } => {
61                meta.request_id.as_deref()
62            }
63            _ => None,
64        }
65    }
66
67    /// HTTP status when this error came from an HTTP response.
68    #[must_use]
69    pub fn status(&self) -> Option<StatusCode> {
70        match self {
71            Self::Api(err) => Some(err.status),
72            Self::Decode { meta, .. } | Self::UnexpectedShape { meta, .. } => meta.status,
73            _ => None,
74        }
75    }
76
77    /// HTTP attempts performed before this error was returned.
78    #[must_use]
79    pub fn attempts(&self) -> Option<u32> {
80        match self {
81            Self::Api(err) => Some(err.attempts),
82            Self::Decode { meta, .. } | Self::UnexpectedShape { meta, .. } => Some(meta.attempts),
83            _ => None,
84        }
85    }
86
87    /// The API error when this is [`Self::Api`].
88    #[must_use]
89    pub fn as_api(&self) -> Option<&ApiError> {
90        match self {
91            Self::Api(err) => Some(err),
92            _ => None,
93        }
94    }
95
96    /// HTTP status class when this is an API error.
97    #[must_use]
98    pub fn kind(&self) -> Option<ApiErrorKind> {
99        self.as_api().map(|err| err.kind)
100    }
101
102    /// True when the API returned HTTP 429.
103    #[must_use]
104    pub fn is_rate_limited(&self) -> bool {
105        self.kind() == Some(ApiErrorKind::RateLimit)
106    }
107
108    /// True when the API returned HTTP 401.
109    #[must_use]
110    pub fn is_auth(&self) -> bool {
111        self.kind() == Some(ApiErrorKind::Authentication)
112    }
113
114    /// True when the attempt timed out.
115    #[must_use]
116    pub fn is_timeout(&self) -> bool {
117        matches!(self, Self::Timeout { .. })
118    }
119
120    /// True when a transport/connection failure occurred.
121    #[must_use]
122    pub fn is_connection(&self) -> bool {
123        matches!(self, Self::Connection(_))
124    }
125}
126
127impl From<ApiError> for Error {
128    fn from(err: ApiError) -> Self {
129        Self::Api(Box::new(err))
130    }
131}
132
133/// Sanitized transport failure. Display never includes API keys.
134#[derive(Debug, Clone)]
135pub struct TransportError {
136    message: String,
137    pre_send: bool,
138}
139
140impl TransportError {
141    pub(crate) fn from_reqwest(err: &reqwest::Error) -> Self {
142        Self {
143            message: redact_secrets(&err.to_string()),
144            pre_send: err.is_connect(),
145        }
146    }
147
148    /// True when the failure happened before request bytes were written.
149    #[must_use]
150    pub fn is_pre_send(&self) -> bool {
151        self.pre_send
152    }
153}
154
155impl fmt::Display for TransportError {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.write_str(&self.message)
158    }
159}
160
161impl std::error::Error for TransportError {}
162
163/// Non-2xx API response.
164#[derive(Debug)]
165pub struct ApiError {
166    /// HTTP status.
167    pub status: StatusCode,
168    /// Classification of the status.
169    pub kind: ApiErrorKind,
170    /// Parsed error body (not included in [`Display`](fmt::Display) in full).
171    pub body: ErrorBody,
172    /// `x-typesafe-request-id` when present.
173    pub request_id: Option<String>,
174    /// Response headers.
175    pub headers: HeaderMap,
176    /// Path that was called, e.g. `/v1/systemone`.
177    pub endpoint: String,
178    /// Total HTTP attempts including the original request.
179    pub attempts: u32,
180}
181
182impl ApiError {
183    pub(crate) fn from_response(
184        status: StatusCode,
185        body: ErrorBody,
186        headers: HeaderMap,
187        endpoint: &str,
188        attempts: u32,
189    ) -> Self {
190        let request_id = crate::headers::request_id(&headers);
191        Self {
192            status,
193            kind: ApiErrorKind::from_status(status),
194            body,
195            request_id,
196            headers,
197            endpoint: endpoint.to_owned(),
198            attempts,
199        }
200    }
201
202    fn short_message(&self) -> Option<String> {
203        let raw = match &self.body {
204            ErrorBody::Json(value) => extract_message(value),
205            ErrorBody::Text(text) if !text.is_empty() => Some(text.clone()),
206            ErrorBody::Text(_) | ErrorBody::Empty => None,
207        }?;
208        let redacted = redact_secrets(&raw);
209        if redacted.len() > 200 {
210            Some(format!("{}…", &redacted[..200]))
211        } else {
212            Some(redacted)
213        }
214    }
215}
216
217impl fmt::Display for ApiError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        write!(f, "{} {:?}", self.status.as_u16(), self.kind)?;
220        if let Some(id) = &self.request_id {
221            write!(f, " [request-id: {id}]")?;
222        }
223        if let Some(msg) = self.short_message() {
224            write!(f, ": {msg}")?;
225        }
226        Ok(())
227    }
228}
229
230impl std::error::Error for ApiError {}
231
232/// HTTP status class, matching the official SDK error subclasses.
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234#[non_exhaustive]
235pub enum ApiErrorKind {
236    /// HTTP 400.
237    BadRequest,
238    /// HTTP 401.
239    Authentication,
240    /// HTTP 403.
241    PermissionDenied,
242    /// HTTP 404.
243    NotFound,
244    /// HTTP 409.
245    Conflict,
246    /// HTTP 422.
247    UnprocessableEntity,
248    /// HTTP 429.
249    RateLimit,
250    /// HTTP 5xx (including 529).
251    InternalServer,
252    /// Any other non-2xx status.
253    Other,
254}
255
256impl ApiErrorKind {
257    /// Classify an HTTP status code.
258    #[must_use]
259    pub fn from_status(status: StatusCode) -> Self {
260        match status.as_u16() {
261            400 => Self::BadRequest,
262            401 => Self::Authentication,
263            403 => Self::PermissionDenied,
264            404 => Self::NotFound,
265            409 => Self::Conflict,
266            422 => Self::UnprocessableEntity,
267            429 => Self::RateLimit,
268            500..=599 => Self::InternalServer,
269            _ => Self::Other,
270        }
271    }
272}
273
274/// Body of an error response.
275#[derive(Clone, Debug, PartialEq)]
276pub enum ErrorBody {
277    /// Parsed JSON object or array.
278    Json(serde_json::Value),
279    /// Non-JSON text.
280    Text(String),
281    /// Empty body.
282    Empty,
283}
284
285pub(crate) fn parse_error_body(bytes: &[u8]) -> ErrorBody {
286    if bytes.is_empty() {
287        return ErrorBody::Empty;
288    }
289    match serde_json::from_slice::<serde_json::Value>(bytes) {
290        Ok(value) => ErrorBody::Json(value),
291        Err(_) => ErrorBody::Text(String::from_utf8_lossy(bytes).into_owned()),
292    }
293}
294
295fn extract_message(body: &serde_json::Value) -> Option<String> {
296    let obj = body.as_object()?;
297    if let Some(s) = obj.get("error").and_then(serde_json::Value::as_str) {
298        return Some(s.to_owned());
299    }
300    if let Some(s) = obj
301        .get("error")
302        .and_then(|v| v.get("message"))
303        .and_then(serde_json::Value::as_str)
304    {
305        return Some(s.to_owned());
306    }
307    if let Some(s) = obj.get("message").and_then(serde_json::Value::as_str) {
308        return Some(s.to_owned());
309    }
310    if let Some(s) = obj.get("detail").and_then(serde_json::Value::as_str) {
311        return Some(s.to_owned());
312    }
313    None
314}
315
316pub(crate) fn redact_secrets(input: &str) -> String {
317    let mut out = String::with_capacity(input.len());
318    let mut rest = input;
319    const NEEDLE: &str = "Bearer ";
320    while let Some(i) = rest.find(NEEDLE) {
321        out.push_str(&rest[..i + NEEDLE.len()]);
322        rest = &rest[i + NEEDLE.len()..];
323        let skip = rest
324            .find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
325            .unwrap_or(rest.len());
326        if skip > 0 {
327            out.push_str("[redacted]");
328            rest = &rest[skip..];
329        }
330    }
331    out.push_str(rest);
332    out
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn missing_key_mentions_env_var() {
341        let msg = Error::MissingApiKey.to_string();
342        assert!(msg.contains("TYPESAFE_API_KEY"));
343        assert!(!msg.contains("sk-"));
344    }
345
346    #[test]
347    fn display_redacts_bearer_tokens() {
348        let te = TransportError {
349            message: redact_secrets("Connection error: Bearer sk-secret-value-1234"),
350            pre_send: true,
351        };
352        let rendered = Error::Connection(te).to_string();
353        assert!(!rendered.contains("sk-secret"));
354        assert!(rendered.contains("[redacted]"));
355    }
356
357    #[test]
358    fn api_display_omits_raw_body() {
359        let err = ApiError::from_response(
360            StatusCode::BAD_REQUEST,
361            ErrorBody::Json(serde_json::json!({
362                "error": "bad",
363                "request": { "api_key": "sk-should-not-appear-in-full-dump" }
364            })),
365            HeaderMap::new(),
366            "/v1/systemone",
367            1,
368        );
369        let rendered = err.to_string();
370        assert!(rendered.contains("400"));
371        assert!(rendered.contains("bad"));
372        assert!(!rendered.contains("sk-should-not-appear-in-full-dump"));
373    }
374
375    #[test]
376    fn classifies_status_codes() {
377        assert_eq!(
378            ApiErrorKind::from_status(StatusCode::TOO_MANY_REQUESTS),
379            ApiErrorKind::RateLimit
380        );
381        assert_eq!(
382            ApiErrorKind::from_status(StatusCode::from_u16(529).unwrap()),
383            ApiErrorKind::InternalServer
384        );
385        assert_eq!(
386            ApiErrorKind::from_status(StatusCode::CONFLICT),
387            ApiErrorKind::Conflict
388        );
389    }
390
391    #[test]
392    fn helpers_classify_api_and_timeout() {
393        let err = Error::from(ApiError::from_response(
394            StatusCode::TOO_MANY_REQUESTS,
395            ErrorBody::Empty,
396            HeaderMap::new(),
397            "/v1/systemone",
398            2,
399        ));
400        assert!(err.is_rate_limited());
401        assert!(!err.is_auth());
402        assert_eq!(err.kind(), Some(ApiErrorKind::RateLimit));
403        assert_eq!(err.attempts(), Some(2));
404
405        let timeout = Error::Timeout {
406            after: Duration::from_secs(10),
407        };
408        assert!(timeout.is_timeout());
409        assert!(!timeout.is_connection());
410        assert!(timeout.as_api().is_none());
411    }
412}