use std::fmt;
use std::time::Duration;
use bytes::Bytes;
use http::{HeaderMap, StatusCode};
use crate::types::ResponseMeta;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(
"No API key was provided. Pass `api_key` to ClientConfig or set the TYPESAFE_API_KEY environment variable."
)]
MissingApiKey,
#[error("{0}")]
InvalidRequest(String),
#[error("Connection error: {0}")]
Connection(#[source] TransportError),
#[error("Request timed out after {}ms.", after.as_millis())]
Timeout {
after: Duration,
},
#[error(transparent)]
Api(Box<ApiError>),
#[error("failed to decode response: {source}")]
Decode {
#[source]
source: serde_json::Error,
body: Bytes,
meta: Box<ResponseMeta>,
},
#[error("Unexpected response shape from {endpoint}")]
UnexpectedShape {
endpoint: &'static str,
meta: Box<ResponseMeta>,
},
}
impl Error {
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api(err) => err.request_id.as_deref(),
Self::Decode { meta, .. } | Self::UnexpectedShape { meta, .. } => {
meta.request_id.as_deref()
}
_ => None,
}
}
#[must_use]
pub fn status(&self) -> Option<StatusCode> {
match self {
Self::Api(err) => Some(err.status),
Self::Decode { meta, .. } | Self::UnexpectedShape { meta, .. } => meta.status,
_ => None,
}
}
#[must_use]
pub fn attempts(&self) -> Option<u32> {
match self {
Self::Api(err) => Some(err.attempts),
Self::Decode { meta, .. } | Self::UnexpectedShape { meta, .. } => Some(meta.attempts),
_ => None,
}
}
#[must_use]
pub fn as_api(&self) -> Option<&ApiError> {
match self {
Self::Api(err) => Some(err),
_ => None,
}
}
#[must_use]
pub fn kind(&self) -> Option<ApiErrorKind> {
self.as_api().map(|err| err.kind)
}
#[must_use]
pub fn is_rate_limited(&self) -> bool {
self.kind() == Some(ApiErrorKind::RateLimit)
}
#[must_use]
pub fn is_auth(&self) -> bool {
self.kind() == Some(ApiErrorKind::Authentication)
}
#[must_use]
pub fn is_timeout(&self) -> bool {
matches!(self, Self::Timeout { .. })
}
#[must_use]
pub fn is_connection(&self) -> bool {
matches!(self, Self::Connection(_))
}
}
impl From<ApiError> for Error {
fn from(err: ApiError) -> Self {
Self::Api(Box::new(err))
}
}
#[derive(Debug, Clone)]
pub struct TransportError {
message: String,
pre_send: bool,
}
impl TransportError {
pub(crate) fn from_reqwest(err: &reqwest::Error) -> Self {
Self {
message: redact_secrets(&err.to_string()),
pre_send: err.is_connect(),
}
}
#[must_use]
pub fn is_pre_send(&self) -> bool {
self.pre_send
}
}
impl fmt::Display for TransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for TransportError {}
#[derive(Debug)]
pub struct ApiError {
pub status: StatusCode,
pub kind: ApiErrorKind,
pub body: ErrorBody,
pub request_id: Option<String>,
pub headers: HeaderMap,
pub endpoint: String,
pub attempts: u32,
}
impl ApiError {
pub(crate) fn from_response(
status: StatusCode,
body: ErrorBody,
headers: HeaderMap,
endpoint: &str,
attempts: u32,
) -> Self {
let request_id = crate::headers::request_id(&headers);
Self {
status,
kind: ApiErrorKind::from_status(status),
body,
request_id,
headers,
endpoint: endpoint.to_owned(),
attempts,
}
}
fn short_message(&self) -> Option<String> {
let raw = match &self.body {
ErrorBody::Json(value) => extract_message(value),
ErrorBody::Text(text) if !text.is_empty() => Some(text.clone()),
ErrorBody::Text(_) | ErrorBody::Empty => None,
}?;
let redacted = redact_secrets(&raw);
if redacted.len() > 200 {
Some(format!("{}…", &redacted[..200]))
} else {
Some(redacted)
}
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {:?}", self.status.as_u16(), self.kind)?;
if let Some(id) = &self.request_id {
write!(f, " [request-id: {id}]")?;
}
if let Some(msg) = self.short_message() {
write!(f, ": {msg}")?;
}
Ok(())
}
}
impl std::error::Error for ApiError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApiErrorKind {
BadRequest,
Authentication,
PermissionDenied,
NotFound,
Conflict,
UnprocessableEntity,
RateLimit,
InternalServer,
Other,
}
impl ApiErrorKind {
#[must_use]
pub fn from_status(status: StatusCode) -> Self {
match status.as_u16() {
400 => Self::BadRequest,
401 => Self::Authentication,
403 => Self::PermissionDenied,
404 => Self::NotFound,
409 => Self::Conflict,
422 => Self::UnprocessableEntity,
429 => Self::RateLimit,
500..=599 => Self::InternalServer,
_ => Self::Other,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ErrorBody {
Json(serde_json::Value),
Text(String),
Empty,
}
pub(crate) fn parse_error_body(bytes: &[u8]) -> ErrorBody {
if bytes.is_empty() {
return ErrorBody::Empty;
}
match serde_json::from_slice::<serde_json::Value>(bytes) {
Ok(value) => ErrorBody::Json(value),
Err(_) => ErrorBody::Text(String::from_utf8_lossy(bytes).into_owned()),
}
}
fn extract_message(body: &serde_json::Value) -> Option<String> {
let obj = body.as_object()?;
if let Some(s) = obj.get("error").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
if let Some(s) = obj
.get("error")
.and_then(|v| v.get("message"))
.and_then(serde_json::Value::as_str)
{
return Some(s.to_owned());
}
if let Some(s) = obj.get("message").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
if let Some(s) = obj.get("detail").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
None
}
pub(crate) fn redact_secrets(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
const NEEDLE: &str = "Bearer ";
while let Some(i) = rest.find(NEEDLE) {
out.push_str(&rest[..i + NEEDLE.len()]);
rest = &rest[i + NEEDLE.len()..];
let skip = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.unwrap_or(rest.len());
if skip > 0 {
out.push_str("[redacted]");
rest = &rest[skip..];
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_key_mentions_env_var() {
let msg = Error::MissingApiKey.to_string();
assert!(msg.contains("TYPESAFE_API_KEY"));
assert!(!msg.contains("sk-"));
}
#[test]
fn display_redacts_bearer_tokens() {
let te = TransportError {
message: redact_secrets("Connection error: Bearer sk-secret-value-1234"),
pre_send: true,
};
let rendered = Error::Connection(te).to_string();
assert!(!rendered.contains("sk-secret"));
assert!(rendered.contains("[redacted]"));
}
#[test]
fn api_display_omits_raw_body() {
let err = ApiError::from_response(
StatusCode::BAD_REQUEST,
ErrorBody::Json(serde_json::json!({
"error": "bad",
"request": { "api_key": "sk-should-not-appear-in-full-dump" }
})),
HeaderMap::new(),
"/v1/systemone",
1,
);
let rendered = err.to_string();
assert!(rendered.contains("400"));
assert!(rendered.contains("bad"));
assert!(!rendered.contains("sk-should-not-appear-in-full-dump"));
}
#[test]
fn classifies_status_codes() {
assert_eq!(
ApiErrorKind::from_status(StatusCode::TOO_MANY_REQUESTS),
ApiErrorKind::RateLimit
);
assert_eq!(
ApiErrorKind::from_status(StatusCode::from_u16(529).unwrap()),
ApiErrorKind::InternalServer
);
assert_eq!(
ApiErrorKind::from_status(StatusCode::CONFLICT),
ApiErrorKind::Conflict
);
}
#[test]
fn helpers_classify_api_and_timeout() {
let err = Error::from(ApiError::from_response(
StatusCode::TOO_MANY_REQUESTS,
ErrorBody::Empty,
HeaderMap::new(),
"/v1/systemone",
2,
));
assert!(err.is_rate_limited());
assert!(!err.is_auth());
assert_eq!(err.kind(), Some(ApiErrorKind::RateLimit));
assert_eq!(err.attempts(), Some(2));
let timeout = Error::Timeout {
after: Duration::from_secs(10),
};
assert!(timeout.is_timeout());
assert!(!timeout.is_connection());
assert!(timeout.as_api().is_none());
}
}