1use std::fmt;
2use std::time::Duration;
3
4use bytes::Bytes;
5use http::{HeaderMap, StatusCode};
6
7use crate::types::ResponseMeta;
8
9#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum Error {
13 #[error(
15 "No API key was provided. Pass `api_key` to ClientConfig or set the TYPESAFE_API_KEY environment variable."
16 )]
17 MissingApiKey,
18 #[error("{0}")]
20 InvalidRequest(String),
21 #[error("Connection error: {0}")]
23 Connection(#[source] TransportError),
24 #[error("Request timed out after {}ms.", after.as_millis())]
26 Timeout {
27 after: Duration,
29 },
30 #[error(transparent)]
32 Api(Box<ApiError>),
33 #[error("failed to decode response: {source}")]
35 Decode {
36 #[source]
38 source: serde_json::Error,
39 body: Bytes,
41 meta: Box<ResponseMeta>,
43 },
44 #[error("Unexpected response shape from {endpoint}")]
46 UnexpectedShape {
47 endpoint: &'static str,
49 meta: Box<ResponseMeta>,
51 },
52}
53
54impl Error {
55 #[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 #[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 #[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 #[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 #[must_use]
98 pub fn kind(&self) -> Option<ApiErrorKind> {
99 self.as_api().map(|err| err.kind)
100 }
101
102 #[must_use]
104 pub fn is_rate_limited(&self) -> bool {
105 self.kind() == Some(ApiErrorKind::RateLimit)
106 }
107
108 #[must_use]
110 pub fn is_auth(&self) -> bool {
111 self.kind() == Some(ApiErrorKind::Authentication)
112 }
113
114 #[must_use]
116 pub fn is_timeout(&self) -> bool {
117 matches!(self, Self::Timeout { .. })
118 }
119
120 #[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#[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 #[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#[derive(Debug)]
165pub struct ApiError {
166 pub status: StatusCode,
168 pub kind: ApiErrorKind,
170 pub body: ErrorBody,
172 pub request_id: Option<String>,
174 pub headers: HeaderMap,
176 pub endpoint: String,
178 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234#[non_exhaustive]
235pub enum ApiErrorKind {
236 BadRequest,
238 Authentication,
240 PermissionDenied,
242 NotFound,
244 Conflict,
246 UnprocessableEntity,
248 RateLimit,
250 InternalServer,
252 Other,
254}
255
256impl ApiErrorKind {
257 #[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#[derive(Clone, Debug, PartialEq)]
276pub enum ErrorBody {
277 Json(serde_json::Value),
279 Text(String),
281 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}