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::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#[derive(Clone, Debug, Eq, Error, PartialEq)]
19#[non_exhaustive]
20#[error("ProjectX rejected the operation ({})", CodeText { code: *code, name: *name })]
21pub struct ProviderError {
22    /// Provider error code.
23    pub code: i32,
24    /// Provider-published name for [`Self::code`], when the endpoint's
25    /// published error-code table defines it.
26    ///
27    /// Undocumented codes, including codes the provider adds after this crate
28    /// was published, are `None`. The provider's free-form `errorMessage` is
29    /// untrusted remote text and is never exposed here.
30    pub name: Option<&'static str>,
31}
32
33/// Renders a provider error code together with its published name.
34#[derive(Clone, Copy, Debug)]
35struct CodeText {
36    code: i32,
37    name: Option<&'static str>,
38}
39
40impl fmt::Display for CodeText {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self.name {
43            Some(name) => write!(formatter, "code: {} {name}", self.code),
44            None => write!(formatter, "code: {}", self.code),
45        }
46    }
47}
48
49/// The transport-level origin of an ambiguous mutation outcome, when a
50/// provider rejection was not decoded.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52#[non_exhaustive]
53pub enum AmbiguityOrigin {
54    /// The HTTP transport failed after the request may have been submitted,
55    /// in a way that is neither a timeout nor a connection failure.
56    Transport,
57    /// The request timed out after submission; the provider may have
58    /// processed it.
59    TransportTimeout,
60    /// The connection could not be established.
61    TransportConnect,
62    /// The provider answered with an HTTP status the client could not
63    /// classify as a definitive outcome.
64    HttpStatus(u16),
65    /// A response body arrived but could not be decoded.
66    Decode,
67    /// A success body decoded but omitted a field required to identify the
68    /// result.
69    MissingResult,
70    /// The response exceeded the configured size bound.
71    ResponseTooLarge,
72    /// The provider's success flag and required error code contradicted
73    /// each other.
74    InconsistentStatus,
75    /// The provider refused the request with HTTP 429.
76    RateLimited,
77    /// The outcome was untrustworthy in a way this crate does not classify.
78    /// Every ambiguous mutation this crate constructs carries a provider
79    /// code or one of the classified origins; this variant reports an
80    /// outcome that reached the ambiguity mapping outside those classes.
81    Unclassified,
82}
83
84impl fmt::Display for AmbiguityOrigin {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::Transport => formatter.write_str("HTTP transport failed after submission"),
88            Self::TransportTimeout => formatter.write_str("request timed out after submission"),
89            Self::TransportConnect => formatter.write_str("connection could not be established"),
90            Self::HttpStatus(status) => write!(formatter, "provider returned HTTP status {status}"),
91            Self::Decode => formatter.write_str("response could not be decoded"),
92            Self::MissingResult => {
93                formatter.write_str("success response omitted a required result field")
94            }
95            Self::ResponseTooLarge => {
96                formatter.write_str("response exceeded the configured size limit")
97            }
98            Self::InconsistentStatus => {
99                formatter.write_str("provider returned inconsistent status fields")
100            }
101            Self::RateLimited => formatter.write_str("provider rate limit refused the request"),
102            Self::Unclassified => formatter.write_str("unclassified transport-level failure"),
103        }
104    }
105}
106
107/// Renders a decoded provider rejection, or the transport-level origin when
108/// none was decoded, or nothing when the outcome produced no classifiable
109/// evidence.
110#[derive(Clone, Copy, Debug)]
111struct AmbiguityText {
112    code: Option<i32>,
113    name: Option<&'static str>,
114    origin: Option<AmbiguityOrigin>,
115}
116
117impl fmt::Display for AmbiguityText {
118    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119        if let Some(code) = self.code {
120            return write!(
121                formatter,
122                " ({})",
123                CodeText {
124                    code,
125                    name: self.name
126                }
127            );
128        }
129        if let Some(origin) = self.origin {
130            return write!(formatter, " ({origin})");
131        }
132        Ok(())
133    }
134}
135
136/// Errors returned by the `ProjectX` client.
137#[derive(Debug, Error)]
138#[non_exhaustive]
139pub enum Error {
140    /// Client configuration was invalid.
141    #[error("invalid configuration: {0}")]
142    Configuration(String),
143    /// A provider identifier was invalid.
144    #[error("invalid {kind}: {reason}")]
145    InvalidIdentifier {
146        /// Identifier category.
147        kind: &'static str,
148        /// Public-safe reason for rejection.
149        reason: &'static str,
150    },
151    /// The provider rejected the supplied authentication credentials.
152    #[error("provider rejected the credentials ({})", CodeText { code: *code, name: *name })]
153    CredentialsRejected {
154        /// Public provider rejection code.
155        code: i32,
156        /// Provider-published name for `code`, when `/api/Auth/loginKey` and
157        /// `/api/Auth/loginApp` publish one for it.
158        name: Option<&'static str>,
159    },
160    /// The provider rejected validation of the current session.
161    #[error(
162        "provider rejected session validation ({})",
163        CodeText { code: *code, name: *name }
164    )]
165    SessionValidationRejected {
166        /// Public provider rejection code.
167        code: i32,
168        /// Provider-published name for `code`, when `/api/Auth/validate`
169        /// publishes one for it.
170        name: Option<&'static str>,
171    },
172    /// The provider's success flag and required error code disagreed.
173    ///
174    /// The provider did not declare a rejection, so this reports the
175    /// contradictory pair as received instead of naming a rejection code.
176    #[error("provider returned inconsistent status (success: {success}, code: {code})")]
177    InconsistentResponseStatus {
178        /// Provider success flag.
179        success: bool,
180        /// Provider error code.
181        code: i32,
182    },
183    /// A successful authentication response omitted a usable bearer token.
184    #[error("provider returned success without a usable authentication token")]
185    MissingAuthenticationToken,
186    /// A provider authentication response contained an invalid bearer token.
187    #[error("provider returned an invalid authentication token")]
188    InvalidAuthenticationToken,
189    /// No authenticated bearer token is available.
190    #[error("the client is not authenticated")]
191    NotAuthenticated,
192    /// Session validation may have rotated the provider token, but no
193    /// trustworthy response was received.
194    #[error("session-validation outcome is ambiguous; authenticate again before using the client")]
195    AmbiguousSessionValidation,
196    /// The provider rejected an otherwise valid request.
197    #[error(transparent)]
198    Provider(#[from] ProviderError),
199    /// The HTTP transport failed.
200    #[error("HTTP transport failed")]
201    Transport(#[source] reqwest::Error),
202    /// The provider returned a non-successful HTTP status.
203    #[error("provider returned HTTP status {status}")]
204    UnexpectedStatus {
205        /// Numeric HTTP status code.
206        status: u16,
207    },
208    /// The provider status endpoint returned a body other than `pong`.
209    #[error("provider status endpoint returned an unexpected response")]
210    UnexpectedStatusResponse,
211    /// A request was not sent because the shared local budget was exhausted.
212    #[error("local {kind} rate limit is exhausted; retry after {retry_after:?}")]
213    LocallyRateLimited {
214        /// Provider budget that rejected local admission.
215        kind: RateLimitKind,
216        /// Minimum time until this client can admit another request.
217        retry_after: Duration,
218    },
219    /// The provider rejected an authenticated request with HTTP 429.
220    #[error("provider rate limit is exhausted; retry after {retry_after:?}")]
221    ProviderRateLimited {
222        /// Provider budget associated with the rejected endpoint.
223        kind: RateLimitKind,
224        /// Delay from `Retry-After`, or the configured window when absent.
225        retry_after: Duration,
226    },
227    /// A URL could not be constructed.
228    #[error("invalid endpoint URL")]
229    Url(#[source] url::ParseError),
230    /// A provider response exceeded the configured safety limit.
231    #[error("provider response exceeded the {limit_bytes}-byte limit")]
232    ResponseTooLarge {
233        /// Configured response limit.
234        limit_bytes: usize,
235    },
236    /// A provider response could not be decoded.
237    #[error("provider response was not valid JSON")]
238    Decode(#[source] serde_json::Error),
239    /// A request could not be encoded as JSON.
240    #[error("request could not be encoded as JSON")]
241    Encode(#[source] serde_json::Error),
242    /// A money-moving mutation may have reached the provider but did not
243    /// produce a trustworthy response.
244    ///
245    /// A decoded provider rejection that is documented as pending, unknown, or
246    /// otherwise not a definitive rejection stays ambiguous, and carries the
247    /// provider's code and published name so the caller can tell
248    /// `OrderPending` apart from an unrecognized future code. When no provider
249    /// rejection was decoded, `origin` instead carries the transport-level
250    /// evidence for the ambiguity. Exactly one of `code` and `origin` is set
251    /// in every error this crate constructs: a decoded rejection is the
252    /// evidence, and otherwise a classified — or explicitly
253    /// [`AmbiguityOrigin::Unclassified`] — origin is.
254    #[error(
255        "{} outcome is ambiguous{}; reconcile provider state before retrying",
256        operation,
257        AmbiguityText { code: *code, name: *name, origin: *origin }
258    )]
259    AmbiguousMutation {
260        /// Public-safe operation name.
261        operation: &'static str,
262        /// Provider error code, when a provider rejection was decoded.
263        code: Option<i32>,
264        /// Provider-published name for `code`, when the endpoint's published
265        /// error-code table defines it.
266        name: Option<&'static str>,
267        /// Transport-level origin of the ambiguity, when no provider
268        /// rejection was decoded.
269        origin: Option<AmbiguityOrigin>,
270    },
271    /// A library-owned background task terminated unexpectedly.
272    #[error("{task} background task terminated unexpectedly")]
273    BackgroundTaskFailed {
274        /// Public-safe task name.
275        task: &'static str,
276    },
277}