Skip to main content

fizzy_sdk/
error.rs

1//! The one error type every call answers with, its categories and their exit codes.
2
3use std::fmt;
4
5use serde::de::DeserializeOwned;
6
7use crate::http::{HeaderMap, Method, StatusCode};
8
9/// The call succeeded.
10pub const EXIT_OK: i32 = 0;
11/// Invalid arguments or flags.
12pub const EXIT_USAGE: i32 = 1;
13/// Resource not found.
14pub const EXIT_NOT_FOUND: i32 = 2;
15/// Not authenticated.
16pub const EXIT_AUTH: i32 = 3;
17/// Access denied.
18pub const EXIT_FORBIDDEN: i32 = 4;
19/// Rate limited (429).
20pub const EXIT_RATE_LIMIT: i32 = 5;
21/// Connection, DNS or timeout failure.
22pub const EXIT_NETWORK: i32 = 6;
23/// The server returned an error.
24pub const EXIT_API: i32 = 7;
25/// A name matched more than one record.
26pub const EXIT_AMBIGUOUS: i32 = 8;
27/// The server rejected the contents of the request (422).
28pub const EXIT_VALIDATION: i32 = 9;
29
30/// The most of a failure's body an error keeps, mirroring Go's `MaxErrorBodyBytes`. A body
31/// past it is kept up to the bound and no further: an error is diagnostic, and a server
32/// answering a refusal with a megabyte of anything has said everything useful long before.
33pub const MAX_ERROR_BODY_BYTES: usize = 10 * 1024;
34
35/// The most of a server's own message an error's hint carries, mirroring Go's
36/// `MaxErrorMessageBytes`. A longer one is cut and ends in `...`.
37pub const MAX_ERROR_MESSAGE_BYTES: usize = 500;
38
39/// Machine-readable error categories, shared with the other Fizzy SDKs.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum ErrorCode {
43    /// The call was built wrong: a bad argument, a plain-HTTP endpoint, a missing account.
44    Usage,
45    /// 404.
46    NotFound,
47    /// 401, or no credentials to send.
48    AuthRequired,
49    /// 403.
50    Forbidden,
51    /// 429, or the client's own rate limiter.
52    RateLimit,
53    /// No answer came back: connection, DNS, timeout, cancellation.
54    Network,
55    /// The server answered with a failure the other codes do not name, including every 5xx,
56    /// and the calls the SDK refused for itself — see [`Error::refusal`].
57    ApiError,
58    /// 422.
59    Validation,
60    /// A name matched more than one record.
61    Ambiguous,
62}
63
64impl ErrorCode {
65    /// The code as the other SDKs spell it: `auth_required`, `rate_limit`.
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            ErrorCode::Usage => "usage",
69            ErrorCode::NotFound => "not_found",
70            ErrorCode::AuthRequired => "auth_required",
71            ErrorCode::Forbidden => "forbidden",
72            ErrorCode::RateLimit => "rate_limit",
73            ErrorCode::Network => "network",
74            ErrorCode::ApiError => "api_error",
75            ErrorCode::Validation => "validation",
76            ErrorCode::Ambiguous => "ambiguous",
77        }
78    }
79
80    /// The process exit status a command should end with for this category.
81    pub fn exit_code(&self) -> i32 {
82        match self {
83            ErrorCode::Usage => EXIT_USAGE,
84            ErrorCode::NotFound => EXIT_NOT_FOUND,
85            ErrorCode::AuthRequired => EXIT_AUTH,
86            ErrorCode::Forbidden => EXIT_FORBIDDEN,
87            ErrorCode::RateLimit => EXIT_RATE_LIMIT,
88            ErrorCode::Network => EXIT_NETWORK,
89            ErrorCode::ApiError => EXIT_API,
90            ErrorCode::Validation => EXIT_VALIDATION,
91            ErrorCode::Ambiguous => EXIT_AMBIGUOUS,
92        }
93    }
94}
95
96impl fmt::Display for ErrorCode {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str(self.as_str())
99    }
100}
101
102/// A call the SDK turned away itself, before anything was sent. It shares
103/// [`ErrorCode::ApiError`] or [`ErrorCode::RateLimit`] with the server's own refusals and
104/// is told apart by carrying no HTTP status and one of these.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
106#[non_exhaustive]
107pub enum Refusal {
108    /// The scope's circuit breaker is open. See [`crate::resilience`].
109    CircuitOpen,
110    /// The scope already has as many calls in flight as its bulkhead allows.
111    BulkheadFull,
112    /// The client's own rate limiter has no token to spend.
113    RateLimited,
114}
115
116/// The error every SDK call can answer with.
117#[derive(Debug)]
118pub struct Error {
119    code: ErrorCode,
120    message: String,
121    hint: Option<String>,
122    http_status: Option<u16>,
123    retryable: bool,
124    request_id: Option<String>,
125    refusal: Option<Refusal>,
126    cancelled: bool,
127    source: Option<Box<dyn std::error::Error + Send + Sync>>,
128    response_too_large: bool,
129    /// What Fizzy answered the failure with, kept whole so a caller can read the server's
130    /// own account of the refusal. See [`Error::body`]. A boxed slice rather than a
131    /// [`bytes::Bytes`]: an error is never cloned, and half the width keeps every `Result`
132    /// in the crate under clippy's `result_large_err` bound.
133    body: Option<Box<[u8]>>,
134}
135
136impl Error {
137    /// An error of a category, with a message.
138    pub fn new(code: ErrorCode, message: impl Into<String>) -> Error {
139        Error {
140            code,
141            message: message.into(),
142            hint: None,
143            http_status: None,
144            retryable: false,
145            request_id: None,
146            refusal: None,
147            cancelled: false,
148            source: None,
149            response_too_large: false,
150            body: None,
151        }
152    }
153
154    /// The call was built wrong.
155    pub fn usage(message: impl Into<String>) -> Error {
156        Error::new(ErrorCode::Usage, message)
157    }
158
159    /// The call was built wrong, and here is what to do about it.
160    pub fn usage_with_hint(message: impl Into<String>, hint: impl Into<String>) -> Error {
161        Error::usage(message).with_hint(hint)
162    }
163
164    /// A record that is not there.
165    pub fn not_found(resource: &str, identifier: impl fmt::Display) -> Error {
166        Error::new(
167            ErrorCode::NotFound,
168            format!("{resource} not found: {identifier}"),
169        )
170        .with_status(404)
171    }
172
173    /// Credentials missing or refused.
174    pub fn auth(message: impl Into<String>) -> Error {
175        Error::new(ErrorCode::AuthRequired, message).with_status(401)
176    }
177
178    /// Access denied.
179    pub fn forbidden(message: impl Into<String>) -> Error {
180        Error::new(ErrorCode::Forbidden, message).with_status(403)
181    }
182
183    /// Access denied for want of scope, as a write answered 403 usually is.
184    pub fn forbidden_scope() -> Error {
185        Error::forbidden("Access denied: insufficient scope")
186            .with_hint("Re-authenticate with full scope")
187    }
188
189    /// The rate-limit error a caller raises for itself, worded the way the other SDKs word
190    /// theirs: "Rate limited". A 429 that came back from Fizzy reads "rate limited - try
191    /// again later" instead — see [`Error::from_response`].
192    pub fn rate_limit(retry_after: Option<u64>) -> Error {
193        Error::new(ErrorCode::RateLimit, "Rate limited")
194            .with_hint(retry_hint(retry_after))
195            .with_status(429)
196            .retryable()
197    }
198
199    /// The client's own rate limiter refused a call, so nothing was sent.
200    pub fn rate_limited() -> Error {
201        Error::new(ErrorCode::RateLimit, "rate limit exceeded").refusing_as(Refusal::RateLimited)
202    }
203
204    /// The scope's circuit breaker is open, so the SDK refused the call itself.
205    pub fn circuit_open() -> Error {
206        Error::new(ErrorCode::ApiError, "circuit breaker is open").refusing_as(Refusal::CircuitOpen)
207    }
208
209    /// The scope already has as many calls in flight as its bulkhead allows.
210    pub fn bulkhead_full() -> Error {
211        Error::new(ErrorCode::ApiError, "bulkhead is full").refusing_as(Refusal::BulkheadFull)
212    }
213
214    /// The caller dropped the future before it finished — a `tokio::time::timeout` that
215    /// expired, a `select!` that took another branch. Nobody is waiting for this error: it
216    /// is what the operation hooks are told the call ended as, so the bookkeeping every
217    /// layer keeps per operation is closed out rather than left open.
218    ///
219    /// It is a [`ErrorCode::Network`] because that is what a call that never got an answer
220    /// is. It is not retryable: there is nobody left to answer. It does not count against
221    /// the circuit breaker — a deadline the caller chose says nothing about Fizzy.
222    pub fn cancelled() -> Error {
223        Error {
224            cancelled: true,
225            ..Error::new(ErrorCode::Network, "operation cancelled")
226        }
227    }
228
229    /// No answer came back.
230    pub fn network(source: impl std::error::Error + Send + Sync + 'static) -> Error {
231        Error::new(ErrorCode::Network, "Network error")
232            .with_hint(source.to_string())
233            .retryable()
234            .with_source(source)
235    }
236
237    /// The server answered with a failure.
238    pub fn api(status: u16, message: impl Into<String>) -> Error {
239        Error::new(ErrorCode::ApiError, message).with_status(status)
240    }
241
242    /// A body the client refused to read, because reading it whole is what the caller
243    /// would have gone on to do. It carries no HTTP status of its own: the answer never
244    /// arrived in full, so there is nothing to report about it but the refusal.
245    pub fn response_too_large(limit: usize, method: &Method, path: &str) -> Error {
246        Error {
247            response_too_large: true,
248            ..Error::new(
249                ErrorCode::ApiError,
250                format!("{method} {path}: response body exceeds {limit} bytes"),
251            )
252        }
253    }
254
255    /// Puts a failure to read the body behind the error a status maps to, so a non-2xx
256    /// answer whose body broke off still reports the status the answer carried.
257    pub(crate) fn refusing(mut self, refusal: Error) -> Error {
258        self.response_too_large = refusal.response_too_large;
259        if self.hint.is_none() {
260            self.hint = Some(refusal.to_string());
261        }
262        self.with_source(refusal)
263    }
264
265    /// The messages the server itself produced, joined. With none of them the error still
266    /// says something: "validation error".
267    pub fn validation(messages: &[String]) -> Error {
268        let mut message = messages.join("; ");
269        if message.is_empty() {
270            message = "validation error".to_string();
271        }
272        Error::new(ErrorCode::Validation, message).with_status(422)
273    }
274
275    /// A name that matched more than one record. Up to five matches are named in the hint;
276    /// beyond that the only useful advice is to narrow the search.
277    pub fn ambiguous(resource: &str, matches: &[String]) -> Error {
278        let hint = match matches.len() {
279            1..=5 => format!("Did you mean: {}", matches.join(", ")),
280            _ => "Be more specific".to_string(),
281        };
282        Error::new(ErrorCode::Ambiguous, format!("Ambiguous {resource}")).with_hint(hint)
283    }
284
285    /// Wraps an error from outside the SDK as an API error that reads the way the original
286    /// did, for the callers that have to answer with this type and nothing better fits.
287    pub fn from_std(source: impl std::error::Error + Send + Sync + 'static) -> Error {
288        Error::new(ErrorCode::ApiError, source.to_string()).with_source(source)
289    }
290
291    /// Maps a non-2xx response onto the SDK's error vocabulary. The hint carries whatever
292    /// message the server put in the body, when it sent one, and the body itself is kept on
293    /// the error for a caller that needs more of it than a hint — see [`Error::body`].
294    pub fn from_response(
295        status: StatusCode,
296        method: &Method,
297        headers: &HeaderMap,
298        body: &[u8],
299    ) -> Error {
300        let error = match status.as_u16() {
301            401 => Error::auth("Authentication failed"),
302            403 if method != Method::GET => Error::forbidden_scope(),
303            403 => Error::forbidden("Access denied"),
304            404 => Error::new(ErrorCode::NotFound, "Resource not found").with_status(404),
305            422 => Error::new(ErrorCode::Validation, "Validation failed").with_status(422),
306            429 => Error::new(ErrorCode::RateLimit, "Rate limited - try again later")
307                .with_hint(retry_hint(retry_after_seconds(headers)))
308                .with_status(429)
309                .retryable(),
310            code => {
311                let error = Error::api(code, format!("API error: {status}"));
312                if status.is_server_error() {
313                    match retry_after_seconds(headers) {
314                        Some(seconds) => error.with_hint(retry_hint(Some(seconds))).retryable(),
315                        None => error.retryable(),
316                    }
317                } else {
318                    error
319                }
320            }
321        };
322        let error = match headers
323            .get("x-request-id")
324            .and_then(|value| value.to_str().ok())
325        {
326            Some(request_id) => error.with_request_id(request_id),
327            None => error,
328        };
329        let mut error = match (error.hint.is_none(), server_message(body)) {
330            (true, Some(message)) => error.with_hint(message),
331            _ => error,
332        };
333        if !body.is_empty() {
334            let kept = body.len().min(MAX_ERROR_BODY_BYTES);
335            error.body = Some(Box::from(&body[..kept]));
336        }
337        error
338    }
339
340    /// Adds advice.
341    pub fn with_hint(mut self, hint: impl Into<String>) -> Error {
342        self.hint = Some(hint.into());
343        self
344    }
345
346    /// Records the HTTP status.
347    pub fn with_status(mut self, status: u16) -> Error {
348        self.http_status = Some(status);
349        self
350    }
351
352    /// Records the server's request id.
353    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Error {
354        self.request_id = Some(request_id.into());
355        self
356    }
357
358    /// Records what caused this.
359    pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Error {
360        self.source = Some(Box::new(source));
361        self
362    }
363
364    /// Marks the call as worth resending.
365    pub fn retryable(mut self) -> Error {
366        self.retryable = true;
367        self
368    }
369
370    fn refusing_as(mut self, refusal: Refusal) -> Error {
371        self.refusal = Some(refusal);
372        self
373    }
374
375    /// The category.
376    pub fn code(&self) -> ErrorCode {
377        self.code
378    }
379
380    /// Whether the error is of this category.
381    pub fn is_code(&self, code: ErrorCode) -> bool {
382        self.code == code
383    }
384
385    /// The process exit status a command should end with for this error.
386    pub fn exit_code(&self) -> i32 {
387        self.code.exit_code()
388    }
389
390    /// What went wrong.
391    pub fn message(&self) -> &str {
392        &self.message
393    }
394
395    /// What to do about it, when there is advice.
396    pub fn hint(&self) -> Option<&str> {
397        self.hint.as_deref()
398    }
399
400    /// The HTTP status, when the server answered.
401    pub fn http_status(&self) -> Option<u16> {
402        self.http_status
403    }
404
405    /// Whether resending would be worth trying.
406    pub fn is_retryable(&self) -> bool {
407        self.retryable
408    }
409
410    /// The server's `X-Request-Id`, when it sent one.
411    pub fn request_id(&self) -> Option<&str> {
412        self.request_id.as_deref()
413    }
414
415    /// Which of the SDK's own layers turned the call away, when one did.
416    pub fn refusal(&self) -> Option<Refusal> {
417        self.refusal
418    }
419
420    /// The caller gave up on the call before it finished. See [`Error::cancelled`].
421    pub fn is_cancelled(&self) -> bool {
422        self.cancelled
423    }
424
425    /// The answer was longer than the client will hold in memory, whether that refusal is
426    /// the error itself or sits behind the status the answer carried.
427    pub fn is_response_too_large(&self) -> bool {
428        self.response_too_large
429    }
430
431    /// What Fizzy answered the failure with, up to [`MAX_ERROR_BODY_BYTES`]. A 422 describes
432    /// the fields it objected to in the body, and this is where that account is kept. It is
433    /// `None` when the answer carried no body, when the SDK raised the error itself, and
434    /// when the body was too long to read.
435    pub fn body(&self) -> Option<&[u8]> {
436        self.body.as_deref()
437    }
438
439    /// The failure body read as `T`, or `None` when there is no body or it does not read as
440    /// one.
441    pub fn body_json<T: DeserializeOwned>(&self) -> Option<T> {
442        serde_json::from_slice(self.body()?).ok()
443    }
444}
445
446impl fmt::Display for Error {
447    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448        match &self.hint {
449            Some(hint) => write!(f, "{}: {hint}", self.message),
450            None => f.write_str(&self.message),
451        }
452    }
453}
454
455impl std::error::Error for Error {
456    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
457        self.source
458            .as_deref()
459            .map(|source| source as &(dyn std::error::Error + 'static))
460    }
461}
462
463impl From<serde_json::Error> for Error {
464    fn from(error: serde_json::Error) -> Error {
465        Error::new(ErrorCode::ApiError, "unexpected JSON")
466            .with_hint(error.to_string())
467            .with_source(error)
468    }
469}
470
471impl From<url::ParseError> for Error {
472    fn from(error: url::ParseError) -> Error {
473        Error::usage(format!("invalid URL: {error}")).with_source(error)
474    }
475}
476
477fn retry_hint(retry_after: Option<u64>) -> String {
478    match retry_after {
479        Some(seconds) if seconds > 0 => format!("Try again in {seconds} seconds"),
480        _ => "Try again later".to_string(),
481    }
482}
483
484/// The wait `Retry-After` asks for, in seconds. The header carries either a count of
485/// seconds or the HTTP-date the wait is over, and a date already past asks for no wait at
486/// all.
487pub(crate) fn retry_after_seconds(headers: &HeaderMap) -> Option<u64> {
488    let asked = headers.get("retry-after")?.to_str().ok()?.trim();
489    if let Ok(seconds) = asked.parse::<i64>() {
490        u64::try_from(seconds).ok()
491    } else {
492        let until = chrono::DateTime::parse_from_rfc2822(asked).ok()?;
493        seconds_until(until.with_timezone(&chrono::Utc), chrono::Utc::now())
494    }
495}
496
497/// The whole seconds from `now` until `until`, counting a started second as one: the date
498/// names the moment the wait is over, so rounding down would resend before it.
499fn seconds_until(
500    until: chrono::DateTime<chrono::Utc>,
501    now: chrono::DateTime<chrono::Utc>,
502) -> Option<u64> {
503    let left = until.signed_duration_since(now);
504    let whole = left.num_seconds();
505    let started = left > chrono::Duration::seconds(whole);
506    u64::try_from(if started { whole + 1 } else { whole }).ok()
507}
508
509fn server_message(body: &[u8]) -> Option<String> {
510    let value: serde_json::Value = serde_json::from_slice(body).ok()?;
511    let message = value
512        .get("message")
513        .or_else(|| value.get("error"))?
514        .as_str()?;
515    Some(truncate(message, MAX_ERROR_MESSAGE_BYTES))
516}
517
518/// Cuts a message to `limit` characters, saying so with a trailing `...`, the way Go's
519/// `truncateString` does.
520pub(crate) fn truncate(message: &str, limit: usize) -> String {
521    if message.chars().count() <= limit {
522        message.to_string()
523    } else {
524        let kept: String = message.chars().take(limit.saturating_sub(3)).collect();
525        format!("{kept}...")
526    }
527}
528
529#[cfg(test)]
530#[allow(clippy::unwrap_used)]
531mod tests {
532    use super::*;
533    use chrono::{DateTime, Duration, Utc};
534
535    fn at(millis: i64) -> DateTime<Utc> {
536        DateTime::from_timestamp_millis(1_700_000_000_000 + millis).unwrap()
537    }
538
539    #[test]
540    fn a_started_second_counts_as_a_whole_one() {
541        assert_eq!(seconds_until(at(2_000), at(0)), Some(2));
542        assert_eq!(seconds_until(at(2_000), at(50)), Some(2));
543        assert_eq!(seconds_until(at(2_000), at(1_050)), Some(1));
544        assert_eq!(seconds_until(at(2_000), at(1_999)), Some(1));
545        assert_eq!(
546            seconds_until(at(2_000), at(0) - Duration::nanoseconds(1)),
547            Some(3)
548        );
549    }
550
551    #[test]
552    fn a_date_already_past_asks_for_no_wait() {
553        assert_eq!(seconds_until(at(0), at(0)), Some(0));
554        assert_eq!(seconds_until(at(0), at(500)), Some(0));
555        assert_eq!(seconds_until(at(0), at(1_500)), None);
556    }
557
558    #[test]
559    fn retry_after_reads_seconds_and_dates() {
560        let mut headers = HeaderMap::new();
561        headers.insert("retry-after", "7".parse().unwrap());
562        assert_eq!(retry_after_seconds(&headers), Some(7));
563        headers.insert(
564            "retry-after",
565            "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap(),
566        );
567        assert_eq!(retry_after_seconds(&headers), None);
568        headers.insert("retry-after", "soon".parse().unwrap());
569        assert_eq!(retry_after_seconds(&headers), None);
570    }
571
572    #[test]
573    fn every_code_has_an_exit_status_and_a_name() {
574        let codes = [
575            (ErrorCode::Usage, "usage", 1),
576            (ErrorCode::NotFound, "not_found", 2),
577            (ErrorCode::AuthRequired, "auth_required", 3),
578            (ErrorCode::Forbidden, "forbidden", 4),
579            (ErrorCode::RateLimit, "rate_limit", 5),
580            (ErrorCode::Network, "network", 6),
581            (ErrorCode::ApiError, "api_error", 7),
582            (ErrorCode::Validation, "validation", 9),
583            (ErrorCode::Ambiguous, "ambiguous", 8),
584        ];
585        for (code, name, exit) in codes {
586            assert_eq!(code.as_str(), name);
587            assert_eq!(code.exit_code(), exit);
588        }
589    }
590
591    #[test]
592    fn refusals_carry_no_status_and_say_which_layer() {
593        assert_eq!(Error::circuit_open().refusal(), Some(Refusal::CircuitOpen));
594        assert_eq!(
595            Error::bulkhead_full().refusal(),
596            Some(Refusal::BulkheadFull)
597        );
598        assert_eq!(Error::rate_limited().refusal(), Some(Refusal::RateLimited));
599        assert_eq!(Error::rate_limited().code(), ErrorCode::RateLimit);
600        assert_eq!(Error::circuit_open().http_status(), None);
601        assert_eq!(Error::api(500, "boom").refusal(), None);
602    }
603}