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}