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/// Renders the provider code of an ambiguous outcome, or nothing when the
50/// outcome never produced a decodable provider response.
51#[derive(Clone, Copy, Debug)]
52struct AmbiguousCodeText(Option<CodeText>);
53
54impl fmt::Display for AmbiguousCodeText {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self.0 {
57            Some(code) => write!(formatter, " ({code})"),
58            None => Ok(()),
59        }
60    }
61}
62
63/// Errors returned by the `ProjectX` client.
64#[derive(Debug, Error)]
65#[non_exhaustive]
66pub enum Error {
67    /// Client configuration was invalid.
68    #[error("invalid configuration: {0}")]
69    Configuration(String),
70    /// A provider identifier was invalid.
71    #[error("invalid {kind}: {reason}")]
72    InvalidIdentifier {
73        /// Identifier category.
74        kind: &'static str,
75        /// Public-safe reason for rejection.
76        reason: &'static str,
77    },
78    /// The provider rejected the supplied authentication credentials.
79    #[error("provider rejected the credentials ({})", CodeText { code: *code, name: *name })]
80    CredentialsRejected {
81        /// Public provider rejection code.
82        code: i32,
83        /// Provider-published name for `code`, when `/api/Auth/loginKey` and
84        /// `/api/Auth/loginApp` publish one for it.
85        name: Option<&'static str>,
86    },
87    /// The provider rejected validation of the current session.
88    #[error(
89        "provider rejected session validation ({})",
90        CodeText { code: *code, name: *name }
91    )]
92    SessionValidationRejected {
93        /// Public provider rejection code.
94        code: i32,
95        /// Provider-published name for `code`, when `/api/Auth/validate`
96        /// publishes one for it.
97        name: Option<&'static str>,
98    },
99    /// The provider's success flag and required error code disagreed.
100    ///
101    /// The provider did not declare a rejection, so this reports the
102    /// contradictory pair as received instead of naming a rejection code.
103    #[error("provider returned inconsistent status (success: {success}, code: {code})")]
104    InconsistentResponseStatus {
105        /// Provider success flag.
106        success: bool,
107        /// Provider error code.
108        code: i32,
109    },
110    /// A successful authentication response omitted a usable bearer token.
111    #[error("provider returned success without a usable authentication token")]
112    MissingAuthenticationToken,
113    /// A provider authentication response contained an invalid bearer token.
114    #[error("provider returned an invalid authentication token")]
115    InvalidAuthenticationToken,
116    /// No authenticated bearer token is available.
117    #[error("the client is not authenticated")]
118    NotAuthenticated,
119    /// Session validation may have rotated the provider token, but no
120    /// trustworthy response was received.
121    #[error("session-validation outcome is ambiguous; authenticate again before using the client")]
122    AmbiguousSessionValidation,
123    /// The provider rejected an otherwise valid request.
124    #[error(transparent)]
125    Provider(#[from] ProviderError),
126    /// The HTTP transport failed.
127    #[error("HTTP transport failed")]
128    Transport(#[source] reqwest::Error),
129    /// The provider returned a non-successful HTTP status.
130    #[error("provider returned HTTP status {status}")]
131    UnexpectedStatus {
132        /// Numeric HTTP status code.
133        status: u16,
134    },
135    /// The provider status endpoint returned a body other than `pong`.
136    #[error("provider status endpoint returned an unexpected response")]
137    UnexpectedStatusResponse,
138    /// A request was not sent because the shared local budget was exhausted.
139    #[error("local {kind} rate limit is exhausted; retry after {retry_after:?}")]
140    LocallyRateLimited {
141        /// Provider budget that rejected local admission.
142        kind: RateLimitKind,
143        /// Minimum time until this client can admit another request.
144        retry_after: Duration,
145    },
146    /// The provider rejected an authenticated request with HTTP 429.
147    #[error("provider rate limit is exhausted; retry after {retry_after:?}")]
148    ProviderRateLimited {
149        /// Provider budget associated with the rejected endpoint.
150        kind: RateLimitKind,
151        /// Delay from `Retry-After`, or the configured window when absent.
152        retry_after: Duration,
153    },
154    /// A URL could not be constructed.
155    #[error("invalid endpoint URL")]
156    Url(#[source] url::ParseError),
157    /// A provider response exceeded the configured safety limit.
158    #[error("provider response exceeded the {limit_bytes}-byte limit")]
159    ResponseTooLarge {
160        /// Configured response limit.
161        limit_bytes: usize,
162    },
163    /// A provider response could not be decoded.
164    #[error("provider response was not valid JSON")]
165    Decode(#[source] serde_json::Error),
166    /// A request could not be encoded as JSON.
167    #[error("request could not be encoded as JSON")]
168    Encode(#[source] serde_json::Error),
169    /// A money-moving mutation may have reached the provider but did not
170    /// produce a trustworthy response.
171    ///
172    /// A decoded provider rejection that is documented as pending, unknown, or
173    /// otherwise not a definitive rejection stays ambiguous, and carries the
174    /// provider's code and published name so the caller can tell
175    /// `OrderPending` apart from an unrecognized future code.
176    #[error(
177        "{} outcome is ambiguous{}; reconcile provider state before retrying",
178        operation,
179        AmbiguousCodeText(code.map(|code| CodeText { code, name: *name }))
180    )]
181    AmbiguousMutation {
182        /// Public-safe operation name.
183        operation: &'static str,
184        /// Provider error code, when a provider rejection was decoded.
185        code: Option<i32>,
186        /// Provider-published name for `code`, when the endpoint's published
187        /// error-code table defines it.
188        name: Option<&'static str>,
189    },
190    /// A library-owned background task terminated unexpectedly.
191    #[error("{task} background task terminated unexpectedly")]
192    BackgroundTaskFailed {
193        /// Public-safe task name.
194        task: &'static str,
195    },
196}