Skip to main content

oanda_client/
error.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Public-safe client errors.
5
6/// Result returned by client operations.
7pub type Result<T> = std::result::Result<T, Error>;
8
9/// What OANDA actually supplied for an optional contract field.
10///
11/// A provider that sent nothing, a value this client decoded, and a value this
12/// client could not decode are three different facts. Collapsing the last two
13/// into absence would report evidence OANDA did send as evidence it never sent,
14/// so a caller reconciling a rejected mutation could not tell a body it has
15/// already accounted for from a body it is missing.
16#[derive(Debug, Clone, PartialEq)]
17#[non_exhaustive]
18pub enum Supplied<T> {
19    /// OANDA sent no value for this field. A body (or field) that arrived
20    /// empty — only whitespace — is the same fact: nothing was sent to read.
21    Absent,
22    /// OANDA sent a value that matched the documented shape.
23    Decoded(T),
24    /// OANDA sent a non-empty value that did not match the documented shape.
25    Undecoded,
26}
27
28impl<T> Supplied<T> {
29    /// Whether OANDA sent no value at all.
30    #[must_use]
31    pub fn is_absent(&self) -> bool {
32        matches!(self, Self::Absent)
33    }
34    /// Whether OANDA sent a value this client could not decode.
35    #[must_use]
36    pub fn is_undecoded(&self) -> bool {
37        matches!(self, Self::Undecoded)
38    }
39    /// The decoded value, when OANDA sent one this client could read.
40    pub fn decoded(&self) -> Option<&T> {
41        match self {
42            Self::Decoded(value) => Some(value),
43            Self::Absent | Self::Undecoded => None,
44        }
45    }
46}
47
48/// Render a provider reason without ever speaking for OANDA.
49///
50/// Both fields are `None` exactly when the provider supplied no readable
51/// reason; the rendering then says so rather than asserting one.
52fn describe_reason(code: Option<&str>, message: Option<&str>) -> String {
53    match (code, message) {
54        (Some(code), Some(message)) => format!("{code}: {message}"),
55        (Some(code), None) => format!("{code} (OANDA sent no error message)"),
56        (None, Some(message)) => message.to_owned(),
57        (None, None) => "OANDA sent no readable reason".to_owned(),
58    }
59}
60
61/// An endpoint error with its documented rejection body when available.
62#[derive(Debug, thiserror::Error)]
63#[non_exhaustive]
64pub enum OperationError<R> {
65    /// Client-side, transport, decoding, or ambiguous outcome.
66    #[error(transparent)]
67    Client(#[from] Error),
68    /// A definitive rejection whose body could not be read within local transport limits.
69    ///
70    /// The observed status remains authoritative and the account is not fenced.
71    #[error("OANDA rejected the operation with HTTP {status}; rejection body unreadable: {source}")]
72    RejectedUnreadable {
73        /// Definitive provider rejection status.
74        status: u16,
75        /// Provider request identity observed before the body failed.
76        request_id: Supplied<crate::RequestID>,
77        /// Body transport failure or local size bound.
78        #[source]
79        source: Error,
80    },
81    /// A definitive provider rejection.
82    #[error(
83        "OANDA rejected the operation with HTTP {status}: {}",
84        describe_reason(.code.as_deref(), .message.as_deref())
85    )]
86    Rejected {
87        /// HTTP status.
88        status: u16,
89        /// OANDA's `errorCode`, when it supplied a readable one.
90        code: Option<String>,
91        /// OANDA's `errorMessage`, when it supplied a readable one.
92        message: Option<String>,
93        /// The endpoint's documented rejection fields, or what OANDA actually sent for them.
94        body: Supplied<R>,
95    },
96}
97
98/// Common OANDA error fields on endpoints without a specific rejection schema.
99#[derive(Debug, Clone, Default, serde::Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct GenericRejection {
102    /// Optional provider error code.
103    pub error_code: Option<String>,
104    /// Optional provider error message.
105    pub error_message: Option<String>,
106}
107
108/// An OANDA client failure.
109#[derive(Debug, thiserror::Error)]
110#[non_exhaustive]
111pub enum Error {
112    /// The client's internal admission state is inconsistent.
113    #[error("OANDA client invariant failed: {0}")]
114    Invariant(&'static str),
115    /// Local input or environment is invalid.
116    #[error("invalid client input: {0}")]
117    InvalidInput(String),
118    /// Local query or stream capacity was exhausted.
119    #[error("OANDA request rate capacity exhausted")]
120    RateLimited,
121    /// Provider rate limited a request.
122    #[error("OANDA returned HTTP 429")]
123    ProviderRateLimited,
124    /// HTTP 429 proves refusal, but the shared cooldown could not be installed.
125    #[error("OANDA returned HTTP 429; cooldown invariant failed: {source}")]
126    ProviderRateLimitedInvariant {
127        /// The admission invariant failure, retained alongside the definitive status.
128        #[source]
129        source: Box<Error>,
130    },
131    /// The provider rejected the request definitively.
132    #[error(
133        "OANDA returned HTTP {status}: {}",
134        describe_reason(.code.as_deref(), .message.as_deref())
135    )]
136    Provider {
137        /// HTTP response status.
138        status: u16,
139        /// OANDA's `errorCode`, when it supplied a readable one.
140        code: Option<String>,
141        /// OANDA's `errorMessage`, when it supplied a readable one.
142        message: Option<String>,
143    },
144    /// An I/O or decode outcome leaves a mutation uncertain.
145    #[error("OANDA mutation outcome is ambiguous for account {account}")]
146    AmbiguousMutation {
147        /// Affected account identifier.
148        account: crate::AccountID,
149    },
150    /// This account requires explicit reconciliation before another mutation.
151    #[error("OANDA account {account} requires reconciliation")]
152    ReconciliationRequired {
153        /// Affected account identifier.
154        account: crate::AccountID,
155    },
156    /// Another mutation for this account is still in flight.
157    #[error("OANDA account {account} has a mutation in flight")]
158    MutationInFlight {
159        /// Affected account identifier.
160        account: crate::AccountID,
161    },
162    /// Transport failed before a response was available.
163    #[error("OANDA transport failed: {0}")]
164    Transport(String),
165    /// The response or stream record exceeds a configured bound.
166    #[error("OANDA response exceeds the configured size limit")]
167    ResponseTooLarge,
168    /// A finite buffered cut ended inside an unterminated stream record.
169    #[error("OANDA buffered stream ends with {bytes} unterminated bytes")]
170    IncompleteStreamRecord {
171        /// Bytes remaining after the last complete newline-delimited record.
172        bytes: usize,
173    },
174    /// The stream had ended before a buffered cut could be established.
175    #[error("OANDA stream already ended before its buffered prefix was closed")]
176    StreamAlreadyEnded,
177    /// Provider response did not match the documented shape.
178    #[error("malformed OANDA response: {0}")]
179    Decode(String),
180}