projectx_client/error.rs
1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Error types.
5
6use std::{fmt, time::Duration};
7
8use thiserror::Error;
9
10use crate::{OrderId, RateLimitKind};
11
12/// A provider-declared failed operation.
13///
14/// The provider names every code it publishes, and the same number means
15/// different things to different endpoints: code `2` is `OrderRejected` for
16/// `/api/Order/place` and `OrderNotFound` for `/api/Order/cancel`. [`Self::name`]
17/// carries the published text for the endpoint that produced the rejection.
18///
19/// The provider's free-form `errorMessage` is available only through
20/// [`Self::untrusted_message`]. It never appears in this error's `Display`
21/// output, and `Debug` output shows only whether a message is present.
22#[derive(Clone, Eq, Error, PartialEq)]
23#[non_exhaustive]
24#[error("ProjectX rejected the operation ({})", CodeText { code: *code, name: *name })]
25pub struct ProviderError {
26 /// Provider error code.
27 pub code: i32,
28 /// Provider-published name for [`Self::code`], when the endpoint's
29 /// published error-code table defines it.
30 ///
31 /// Undocumented codes, including codes the provider adds after this crate
32 /// was published, are `None`.
33 pub name: Option<&'static str>,
34 /// Order record the provider created for a rejected placement, when it
35 /// returned one.
36 ///
37 /// Only [`Client::place_order`](crate::Client::place_order) reads this
38 /// field; every other operation reports `None`. The record exists on the
39 /// provider but was not accepted, so it never implies a working order.
40 pub order_id: Option<OrderId>,
41 message: Option<Box<str>>,
42}
43
44impl ProviderError {
45 /// Creates a rejection from its decoded provider fields.
46 pub(crate) fn new(
47 code: i32,
48 name: Option<&'static str>,
49 order_id: Option<OrderId>,
50 message: Option<String>,
51 ) -> Self {
52 Self {
53 code,
54 name,
55 order_id,
56 message: message.map(String::into_boxed_str),
57 }
58 }
59
60 /// Returns the provider's free-form `errorMessage`, when it sent one.
61 ///
62 /// The text is untrusted remote input, passed through exactly as the
63 /// provider sent it: it may contain control characters, terminal escape
64 /// sequences, markup, line breaks, or account details, and its length is
65 /// bounded only by the client's configured response-size limit. Clean or
66 /// escape it before displaying or logging it. It is `None` when the
67 /// provider sent `null` or omitted the field. Decide the outcome from
68 /// [`Self::code`], never from this text.
69 #[must_use]
70 pub fn untrusted_message(&self) -> Option<&str> {
71 self.message.as_deref()
72 }
73}
74
75impl fmt::Debug for ProviderError {
76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77 formatter
78 .debug_struct("ProviderError")
79 .field("code", &self.code)
80 .field("name", &self.name)
81 .field("order_id", &self.order_id)
82 .field("message", &self.message.as_ref().map(|_| "[REDACTED]"))
83 .finish()
84 }
85}
86
87/// Renders a provider error code together with its published name.
88#[derive(Clone, Copy, Debug)]
89struct CodeText {
90 code: i32,
91 name: Option<&'static str>,
92}
93
94impl fmt::Display for CodeText {
95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96 match self.name {
97 Some(name) => write!(formatter, "code: {} {name}", self.code),
98 None => write!(formatter, "code: {}", self.code),
99 }
100 }
101}
102
103/// The transport-level origin of an ambiguous mutation outcome, when a
104/// provider rejection was not decoded.
105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106#[non_exhaustive]
107pub enum AmbiguityOrigin {
108 /// The HTTP transport failed after the request may have been submitted,
109 /// in a way that is neither a timeout nor a connection failure.
110 Transport,
111 /// The request timed out after submission; the provider may have
112 /// processed it.
113 TransportTimeout,
114 /// The connection could not be established.
115 TransportConnect,
116 /// The provider answered with an HTTP status the client could not
117 /// classify as a definitive outcome.
118 HttpStatus(u16),
119 /// A response body arrived but could not be decoded.
120 Decode,
121 /// A success body decoded but omitted a field required to identify the
122 /// result.
123 MissingResult,
124 /// The response exceeded the configured size bound.
125 ResponseTooLarge,
126 /// The provider's success flag and required error code contradicted
127 /// each other.
128 InconsistentStatus,
129 /// The provider refused the request with HTTP 429.
130 RateLimited,
131 /// The outcome was untrustworthy in a way this crate does not classify.
132 /// Every ambiguous mutation this crate constructs carries a provider
133 /// code or one of the classified origins; this variant reports an
134 /// outcome that reached the ambiguity mapping outside those classes.
135 Unclassified,
136}
137
138impl fmt::Display for AmbiguityOrigin {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 match self {
141 Self::Transport => formatter.write_str("HTTP transport failed after submission"),
142 Self::TransportTimeout => formatter.write_str("request timed out after submission"),
143 Self::TransportConnect => formatter.write_str("connection could not be established"),
144 Self::HttpStatus(status) => write!(formatter, "provider returned HTTP status {status}"),
145 Self::Decode => formatter.write_str("response could not be decoded"),
146 Self::MissingResult => {
147 formatter.write_str("success response omitted a required result field")
148 }
149 Self::ResponseTooLarge => {
150 formatter.write_str("response exceeded the configured size limit")
151 }
152 Self::InconsistentStatus => {
153 formatter.write_str("provider returned inconsistent status fields")
154 }
155 Self::RateLimited => formatter.write_str("provider rate limit refused the request"),
156 Self::Unclassified => formatter.write_str("unclassified transport-level failure"),
157 }
158 }
159}
160
161/// Renders a decoded provider rejection, or the transport-level origin when
162/// none was decoded, or nothing when the outcome produced no classifiable
163/// evidence.
164#[derive(Clone, Copy, Debug)]
165struct AmbiguityText {
166 code: Option<i32>,
167 name: Option<&'static str>,
168 origin: Option<AmbiguityOrigin>,
169}
170
171impl fmt::Display for AmbiguityText {
172 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
173 if let Some(code) = self.code {
174 return write!(
175 formatter,
176 " ({})",
177 CodeText {
178 code,
179 name: self.name
180 }
181 );
182 }
183 if let Some(origin) = self.origin {
184 return write!(formatter, " ({origin})");
185 }
186 Ok(())
187 }
188}
189
190/// Errors returned by the `ProjectX` client.
191#[derive(Debug, Error)]
192#[non_exhaustive]
193pub enum Error {
194 /// Client configuration was invalid.
195 #[error("invalid configuration: {0}")]
196 Configuration(String),
197 /// A provider identifier was invalid.
198 #[error("invalid {kind}: {reason}")]
199 InvalidIdentifier {
200 /// Identifier category.
201 kind: &'static str,
202 /// Public-safe reason for rejection.
203 reason: &'static str,
204 },
205 /// The provider rejected the supplied authentication credentials.
206 #[error("provider rejected the credentials ({})", CodeText { code: *code, name: *name })]
207 CredentialsRejected {
208 /// Public provider rejection code.
209 code: i32,
210 /// Provider-published name for `code`, when `/api/Auth/loginKey` and
211 /// `/api/Auth/loginApp` publish one for it.
212 name: Option<&'static str>,
213 },
214 /// The provider rejected validation of the current session.
215 #[error(
216 "provider rejected session validation ({})",
217 CodeText { code: *code, name: *name }
218 )]
219 SessionValidationRejected {
220 /// Public provider rejection code.
221 code: i32,
222 /// Provider-published name for `code`, when `/api/Auth/validate`
223 /// publishes one for it.
224 name: Option<&'static str>,
225 },
226 /// The provider's success flag and required error code disagreed.
227 ///
228 /// The provider did not declare a rejection, so this reports the
229 /// contradictory pair as received instead of naming a rejection code.
230 #[error("provider returned inconsistent status (success: {success}, code: {code})")]
231 InconsistentResponseStatus {
232 /// Provider success flag.
233 success: bool,
234 /// Provider error code.
235 code: i32,
236 },
237 /// A successful authentication response omitted a usable bearer token.
238 #[error("provider returned success without a usable authentication token")]
239 MissingAuthenticationToken,
240 /// A provider authentication response contained an invalid bearer token.
241 #[error("provider returned an invalid authentication token")]
242 InvalidAuthenticationToken,
243 /// No authenticated bearer token is available.
244 #[error("the client is not authenticated")]
245 NotAuthenticated,
246 /// Session validation may have rotated the provider token, but no
247 /// trustworthy response was received.
248 #[error("session-validation outcome is ambiguous; authenticate again before using the client")]
249 AmbiguousSessionValidation,
250 /// The provider rejected an otherwise valid request.
251 #[error(transparent)]
252 Provider(#[from] ProviderError),
253 /// The HTTP transport failed.
254 #[error("HTTP transport failed")]
255 Transport(#[source] reqwest::Error),
256 /// The provider returned a non-successful HTTP status.
257 #[error("provider returned HTTP status {status}")]
258 UnexpectedStatus {
259 /// Numeric HTTP status code.
260 status: u16,
261 },
262 /// The provider status endpoint returned a body other than `pong`.
263 #[error("provider status endpoint returned an unexpected response")]
264 UnexpectedStatusResponse,
265 /// A request was not sent because the shared local budget was exhausted.
266 #[error("local {kind} rate limit is exhausted; retry after {retry_after:?}")]
267 LocallyRateLimited {
268 /// Provider budget that rejected local admission.
269 kind: RateLimitKind,
270 /// Minimum time until this client can admit another request.
271 retry_after: Duration,
272 },
273 /// The provider rejected an authenticated request with HTTP 429.
274 #[error("provider rate limit is exhausted; retry after {retry_after:?}")]
275 ProviderRateLimited {
276 /// Provider budget associated with the rejected endpoint.
277 kind: RateLimitKind,
278 /// Delay from `Retry-After`, or the configured window when absent.
279 retry_after: Duration,
280 },
281 /// A URL could not be constructed.
282 #[error("invalid endpoint URL")]
283 Url(#[source] url::ParseError),
284 /// A provider response exceeded the configured safety limit.
285 #[error("provider response exceeded the {limit_bytes}-byte limit")]
286 ResponseTooLarge {
287 /// Configured response limit.
288 limit_bytes: usize,
289 },
290 /// A provider response could not be decoded.
291 #[error("provider response was not valid JSON")]
292 Decode(#[source] serde_json::Error),
293 /// A request could not be encoded as JSON.
294 #[error("request could not be encoded as JSON")]
295 Encode(#[source] serde_json::Error),
296 /// A money-moving mutation may have reached the provider but did not
297 /// produce a trustworthy response.
298 ///
299 /// A decoded provider rejection that is documented as pending, unknown, or
300 /// otherwise not a definitive rejection stays ambiguous, and carries the
301 /// provider's code and published name so the caller can tell
302 /// `OrderPending` apart from an unrecognized future code. When no provider
303 /// rejection was decoded, `origin` instead carries the transport-level
304 /// evidence for the ambiguity. Exactly one of `code` and `origin` is set
305 /// in every error this crate constructs: a decoded rejection is the
306 /// evidence, and otherwise a classified — or explicitly
307 /// [`AmbiguityOrigin::Unclassified`] — origin is.
308 #[error(
309 "{} outcome is ambiguous{}; reconcile provider state before retrying",
310 operation,
311 AmbiguityText { code: *code, name: *name, origin: *origin }
312 )]
313 AmbiguousMutation {
314 /// Public-safe operation name.
315 operation: &'static str,
316 /// Provider error code, when a provider rejection was decoded.
317 code: Option<i32>,
318 /// Provider-published name for `code`, when the endpoint's published
319 /// error-code table defines it.
320 name: Option<&'static str>,
321 /// Transport-level origin of the ambiguity, when no provider
322 /// rejection was decoded.
323 origin: Option<AmbiguityOrigin>,
324 },
325 /// A library-owned background task terminated unexpectedly.
326 #[error("{task} background task terminated unexpectedly")]
327 BackgroundTaskFailed {
328 /// Public-safe task name.
329 task: &'static str,
330 },
331}