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}