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}