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 provider rejection.
69 #[error(
70 "OANDA rejected the operation with HTTP {status}: {}",
71 describe_reason(.code.as_deref(), .message.as_deref())
72 )]
73 Rejected {
74 /// HTTP status.
75 status: u16,
76 /// OANDA's `errorCode`, when it supplied a readable one.
77 code: Option<String>,
78 /// OANDA's `errorMessage`, when it supplied a readable one.
79 message: Option<String>,
80 /// The endpoint's documented rejection fields, or what OANDA actually sent for them.
81 body: Supplied<R>,
82 },
83}
84
85/// Common OANDA error fields on endpoints without a specific rejection schema.
86#[derive(Debug, Clone, Default, serde::Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct GenericRejection {
89 /// Optional provider error code.
90 pub error_code: Option<String>,
91 /// Optional provider error message.
92 pub error_message: Option<String>,
93}
94
95/// An OANDA client failure.
96#[derive(Debug, thiserror::Error)]
97#[non_exhaustive]
98pub enum Error {
99 /// The client's internal admission state is inconsistent.
100 #[error("OANDA client invariant failed: {0}")]
101 Invariant(&'static str),
102 /// Local input or environment is invalid.
103 #[error("invalid client input: {0}")]
104 InvalidInput(String),
105 /// Local query or stream capacity was exhausted.
106 #[error("OANDA request rate capacity exhausted")]
107 RateLimited,
108 /// Provider rate limited a request.
109 #[error("OANDA returned HTTP 429")]
110 ProviderRateLimited,
111 /// The provider rejected the request definitively.
112 #[error(
113 "OANDA returned HTTP {status}: {}",
114 describe_reason(.code.as_deref(), .message.as_deref())
115 )]
116 Provider {
117 /// HTTP response status.
118 status: u16,
119 /// OANDA's `errorCode`, when it supplied a readable one.
120 code: Option<String>,
121 /// OANDA's `errorMessage`, when it supplied a readable one.
122 message: Option<String>,
123 },
124 /// An I/O or decode outcome leaves a mutation uncertain.
125 #[error("OANDA mutation outcome is ambiguous for account {account}")]
126 AmbiguousMutation {
127 /// Affected account identifier.
128 account: String,
129 },
130 /// This account requires explicit reconciliation before another mutation.
131 #[error("OANDA account {account} requires reconciliation")]
132 ReconciliationRequired {
133 /// Affected account identifier.
134 account: String,
135 },
136 /// Another mutation for this account is still in flight.
137 #[error("OANDA account {account} has a mutation in flight")]
138 MutationInFlight {
139 /// Affected account identifier.
140 account: String,
141 },
142 /// Transport failed before a response was available.
143 #[error("OANDA transport failed: {0}")]
144 Transport(String),
145 /// The response or stream record exceeds a configured bound.
146 #[error("OANDA response exceeds the configured size limit")]
147 ResponseTooLarge,
148 /// Provider response did not match the documented shape.
149 #[error("malformed OANDA response: {0}")]
150 Decode(String),
151}