Skip to main content

uarp_sdk/
error.rs

1//! Error types. Every fallible SDK call returns [`Result<T>`].
2
3use std::collections::HashMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8pub type Result<T, E = Error> = std::result::Result<T, E>;
9
10/// Everything that can go wrong while talking to the platform.
11#[derive(Debug, thiserror::Error)]
12pub enum Error {
13    /// The server answered with a non-2xx status.
14    ///
15    /// Boxed so that `Result<T, Error>` stays small on the happy path.
16    #[error(transparent)]
17    Api(Box<ApiError>),
18
19    /// The request never reached the server, or the connection dropped.
20    #[error("connection error: {0}")]
21    Connection(#[source] reqwest::Error),
22
23    /// The request exceeded the configured timeout.
24    #[error("request timed out")]
25    Timeout,
26
27    /// The response body could not be decoded into the expected type.
28    #[error("failed to decode response body: {0}")]
29    Decode(#[source] serde_json::Error),
30
31    /// The client was configured with something unusable (bad URL, missing key).
32    #[error("invalid client configuration: {0}")]
33    Config(String),
34
35    /// A query string or multipart body could not be encoded.
36    #[error("failed to encode request: {0}")]
37    Encode(String),
38
39    /// The event stream ended in the middle of a frame or could not be read.
40    #[error("event stream error: {0}")]
41    Stream(String),
42}
43
44impl From<ApiError> for Error {
45    fn from(error: ApiError) -> Self {
46        Error::Api(Box::new(error))
47    }
48}
49
50impl Error {
51    /// The HTTP status, when the failure came from the server.
52    pub fn status(&self) -> Option<u16> {
53        match self {
54            Error::Api(err) => Some(err.status),
55            _ => None,
56        }
57    }
58
59    /// Whether retrying the very same request could plausibly succeed.
60    pub fn is_retryable(&self) -> bool {
61        match self {
62            Error::Api(err) => err.is_retryable(),
63            Error::Connection(_) | Error::Timeout => true,
64            _ => false,
65        }
66    }
67}
68
69/// RFC 9457 problem document returned by the API on failure.
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct Problem {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub r#type: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub title: Option<String>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub status: Option<i64>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub detail: Option<String>,
80    /// Request identifier to quote when reporting an incident.
81    #[serde(
82        rename = "correlationId",
83        default,
84        skip_serializing_if = "Option::is_none"
85    )]
86    pub correlation_id: Option<String>,
87    /// Field-level validation failures, present on 422 responses.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub errors: Vec<FieldError>,
90    /// Anything else the server included.
91    #[serde(flatten)]
92    pub extra: HashMap<String, serde_json::Value>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FieldError {
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub field: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub message: Option<String>,
101}
102
103/// A coarse classification of an [`ApiError`], convenient for `match`.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum ApiErrorKind {
107    BadRequest,
108    Authentication,
109    PermissionDenied,
110    NotFound,
111    Conflict,
112    Gone,
113    PayloadTooLarge,
114    UnprocessableEntity,
115    RateLimit,
116    ServiceUnavailable,
117    Server,
118    Other,
119}
120
121/// A non-2xx response, with the parsed problem document attached.
122#[derive(Debug, Clone)]
123pub struct ApiError {
124    pub status: u16,
125    pub problem: Problem,
126    /// Selected response headers (lower-cased names).
127    pub headers: HashMap<String, String>,
128}
129
130impl ApiError {
131    pub fn kind(&self) -> ApiErrorKind {
132        match self.status {
133            400 => ApiErrorKind::BadRequest,
134            401 => ApiErrorKind::Authentication,
135            403 => ApiErrorKind::PermissionDenied,
136            404 => ApiErrorKind::NotFound,
137            409 => ApiErrorKind::Conflict,
138            410 => ApiErrorKind::Gone,
139            413 => ApiErrorKind::PayloadTooLarge,
140            422 => ApiErrorKind::UnprocessableEntity,
141            429 => ApiErrorKind::RateLimit,
142            503 => ApiErrorKind::ServiceUnavailable,
143            status if status >= 500 => ApiErrorKind::Server,
144            _ => ApiErrorKind::Other,
145        }
146    }
147
148    /// Request identifier for support tickets.
149    pub fn correlation_id(&self) -> Option<&str> {
150        self.problem
151            .correlation_id
152            .as_deref()
153            .or_else(|| self.headers.get("x-correlation-id").map(String::as_str))
154    }
155
156    /// Seconds the server asked the client to wait, from `Retry-After`.
157    pub fn retry_after_seconds(&self) -> Option<f64> {
158        self.headers.get("retry-after")?.parse().ok()
159    }
160
161    /// Remaining requests in the current rate-limit window.
162    pub fn rate_limit_remaining(&self) -> Option<i64> {
163        self.headers.get("x-ratelimit-remaining")?.parse().ok()
164    }
165
166    pub fn is_retryable(&self) -> bool {
167        matches!(self.status, 408 | 409 | 429 | 500 | 502 | 503 | 504)
168    }
169}
170
171impl fmt::Display for ApiError {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        let title = self.problem.title.as_deref().unwrap_or("HTTP error");
174        write!(f, "{} {}", self.status, title)?;
175        if let Some(detail) = &self.problem.detail {
176            write!(f, " — {detail}")?;
177        }
178        if let Some(correlation) = self.correlation_id() {
179            write!(f, " (correlationId: {correlation})")?;
180        }
181        Ok(())
182    }
183}
184
185impl std::error::Error for ApiError {}