Skip to main content

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}