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}