Skip to main content

spacetraders_client/client/
error.rs

1use std::time::Duration;
2
3use thiserror::Error;
4
5/// Whether an endpoint may be safely replayed after a transient failure.
6///
7/// Reads are idempotent — replaying a GET cannot corrupt server state — so any
8/// transient error (429 / 5xx / communication) is retried automatically.
9/// Mutations are not: after an ambiguous 5xx or a dropped connection the server
10/// may already have applied the change, so replaying the POST risks
11/// double-applying it. (A `sell_cargo` retried after a 500 that had in fact sold
12/// the goods is exactly how a ship ends up with negative cargo.)
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum RetryPolicy {
15    /// Safe/idempotent reads (GET): retry every transient failure.
16    Idempotent,
17    /// Non-idempotent mutations (POST/PATCH): only retry a 429, which the rate
18    /// limiter rejects *before* the action runs; never replay on an ambiguous
19    /// 5xx or communication error.
20    Mutating,
21}
22
23/// A typed failure from an [`StClient`](crate::StClient) call: transport errors,
24/// `401`/`429`/`5xx` HTTP classes, deserialization failures, and other game/API
25/// errors. Use [`ClientError::is_retryable`] / [`ClientError::is_unauthorized`]
26/// to branch on transient vs. terminal outcomes.
27///
28/// ```
29/// use spacetraders_client::ClientError;
30///
31/// let err = ClientError::RateLimited { retry_after: None };
32/// assert!(err.is_retryable());
33/// assert!(!err.is_unauthorized());
34/// ```
35#[derive(Debug, Error)]
36pub enum ClientError {
37    /// Transport/communication failure (connection reset, timeout, DNS, ...).
38    /// Transient: the engine should back off and retry rather than crash.
39    #[error("http error: {0}")]
40    Http(#[from] reqwest::Error),
41
42    /// 401: the agent token is no longer valid, typically after a server reset.
43    /// Run-control keeps this on a dedicated path (notify `FleetCommander`).
44    #[error("unauthorized: {message}")]
45    Unauthorized { message: String },
46
47    /// 429: rate limited. `retry_after` carries the server's `Retry-After` hint
48    /// when present. Transient.
49    #[error("rate limit exceeded: retry after {retry_after:?}")]
50    RateLimited { retry_after: Option<Duration> },
51
52    /// 5xx: server-side / maintenance failure. Transient.
53    #[error("server error {status}: {message}")]
54    RetryableServer { status: u16, message: String },
55
56    /// A non-retryable game/API error: bad state, invalid action, insufficient
57    /// funds, 404, and any other status we do not special-case.
58    #[error("api error: {0}")]
59    Api(String),
60
61    #[error("serde error: {0}")]
62    Serde(#[from] serde_json::Error),
63}
64
65impl ClientError {
66    pub fn is_unauthorized(&self) -> bool {
67        match self {
68            ClientError::Unauthorized { .. } => true,
69            // Defensive: a transport-level error that still carries a 401 status.
70            ClientError::Http(e) => e.status().is_some_and(|s| s.as_u16() == 401),
71            _ => false,
72        }
73    }
74
75    /// Whether the failure is transient and worth retrying automatically:
76    /// communication errors, 429 rate limits, and 5xx server errors. This is the
77    /// idempotent-read view; mutations must go through [`Self::is_retryable_under`].
78    pub fn is_retryable(&self) -> bool {
79        matches!(
80            self,
81            ClientError::Http(_)
82                | ClientError::RateLimited { .. }
83                | ClientError::RetryableServer { .. }
84        )
85    }
86
87    /// Whether this error is safe to retry automatically under `policy`.
88    ///
89    /// For [`RetryPolicy::Idempotent`] this is exactly [`Self::is_retryable`].
90    /// For [`RetryPolicy::Mutating`] only a 429 qualifies: the rate limiter
91    /// rejects it before the mutation runs, so no change was applied. A 5xx or
92    /// communication error is ambiguous — the server may have applied the
93    /// change — so a non-idempotent request must *not* be replayed.
94    pub fn is_retryable_under(&self, policy: RetryPolicy) -> bool {
95        match policy {
96            RetryPolicy::Idempotent => self.is_retryable(),
97            RetryPolicy::Mutating => matches!(self, ClientError::RateLimited { .. }),
98        }
99    }
100
101    /// Whether a failed mutation left the outcome *ambiguous*: the request may
102    /// have reached the game and applied server-side even though the client saw
103    /// an error. True for 5xx and communication failures (the response was lost
104    /// or the server faulted mid-write); false for a 429 (rejected before the
105    /// action ran) and for deterministic game errors (e.g. insufficient funds),
106    /// where the prior state is known to still hold. Callers use this to decide
107    /// whether to re-read authoritative state before acting again.
108    pub fn is_ambiguous_mutation(&self) -> bool {
109        matches!(
110            self,
111            ClientError::Http(_) | ClientError::RetryableServer { .. }
112        )
113    }
114
115    /// Server-suggested delay before retrying, when one was provided (the
116    /// `Retry-After` header on a 429).
117    pub fn retry_after(&self) -> Option<Duration> {
118        match self {
119            ClientError::RateLimited { retry_after } => *retry_after,
120            _ => None,
121        }
122    }
123
124    pub fn class(&self) -> &'static str {
125        match self {
126            ClientError::Http(_) => "communication",
127            ClientError::Unauthorized { .. } => "unauthorized",
128            ClientError::RateLimited { .. } => "rate_limited",
129            ClientError::RetryableServer { .. } => "server",
130            ClientError::Api(_) => "api",
131            ClientError::Serde(_) => "serde",
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn server_error() -> ClientError {
141        ClientError::RetryableServer {
142            status: 500,
143            message: "boom".into(),
144        }
145    }
146
147    fn rate_limited() -> ClientError {
148        ClientError::RateLimited {
149            retry_after: Some(Duration::from_secs(1)),
150        }
151    }
152
153    #[test]
154    fn idempotent_policy_retries_every_transient_error() {
155        for err in [
156            rate_limited(),
157            server_error(),
158            ClientError::RateLimited { retry_after: None },
159        ] {
160            assert!(err.is_retryable_under(RetryPolicy::Idempotent));
161        }
162    }
163
164    #[test]
165    fn mutating_policy_only_retries_rate_limit() {
166        // 429 is rejected before the mutation runs → safe to replay.
167        assert!(rate_limited().is_retryable_under(RetryPolicy::Mutating));
168        // 5xx and communication errors are ambiguous → must not replay a POST.
169        assert!(!server_error().is_retryable_under(RetryPolicy::Mutating));
170        // A bad-request style game error is never retried under either policy.
171        assert!(!ClientError::Api("nope".into()).is_retryable_under(RetryPolicy::Mutating));
172        assert!(!ClientError::Api("nope".into()).is_retryable_under(RetryPolicy::Idempotent));
173    }
174
175    #[test]
176    fn ambiguous_mutation_flags_only_could_have_applied_failures() {
177        // The server may have applied the write before faulting / dropping us.
178        assert!(server_error().is_ambiguous_mutation());
179        // Rejected before the action ran → prior state still holds.
180        assert!(!rate_limited().is_ambiguous_mutation());
181        // Deterministic game rejection → prior state still holds.
182        assert!(!ClientError::Api("insufficient funds".into()).is_ambiguous_mutation());
183    }
184}