oanda_client/error.rs
1//! Public-safe client errors.
2
3/// Result returned by client operations.
4pub type Result<T> = std::result::Result<T, Error>;
5
6/// What OANDA actually supplied for an optional contract field.
7///
8/// A provider that sent nothing, a value this client decoded, and a value this
9/// client could not decode are three different facts. Collapsing the last two
10/// into absence would report evidence OANDA did send as evidence it never sent,
11/// so a caller reconciling a rejected mutation could not tell a body it has
12/// already accounted for from a body it is missing.
13#[derive(Debug, Clone, PartialEq)]
14#[non_exhaustive]
15pub enum Supplied<T> {
16 /// OANDA sent no value for this field. A body (or field) that arrived
17 /// empty — only whitespace — is the same fact: nothing was sent to read.
18 Absent,
19 /// OANDA sent a value that matched the documented shape.
20 Decoded(T),
21 /// OANDA sent a non-empty value that did not match the documented shape.
22 Undecoded,
23}
24
25impl<T> Supplied<T> {
26 /// Whether OANDA sent no value at all.
27 #[must_use]
28 pub fn is_absent(&self) -> bool {
29 matches!(self, Self::Absent)
30 }
31 /// Whether OANDA sent a value this client could not decode.
32 #[must_use]
33 pub fn is_undecoded(&self) -> bool {
34 matches!(self, Self::Undecoded)
35 }
36 /// The decoded value, when OANDA sent one this client could read.
37 pub fn decoded(&self) -> Option<&T> {
38 match self {
39 Self::Decoded(value) => Some(value),
40 Self::Absent | Self::Undecoded => None,
41 }
42 }
43}
44
45/// Render a provider reason without ever speaking for OANDA.
46///
47/// Both fields are `None` exactly when the provider supplied no readable
48/// reason; the rendering then says so rather than asserting one.
49fn describe_reason(code: Option<&str>, message: Option<&str>) -> String {
50 match (code, message) {
51 (Some(code), Some(message)) => format!("{code}: {message}"),
52 (Some(code), None) => format!("{code} (OANDA sent no error message)"),
53 (None, Some(message)) => message.to_owned(),
54 (None, None) => "OANDA sent no readable reason".to_owned(),
55 }
56}
57
58/// An endpoint error with its documented rejection body when available.
59#[derive(Debug, thiserror::Error)]
60#[non_exhaustive]
61pub enum OperationError<R> {
62 /// Client-side, transport, decoding, or ambiguous outcome.
63 #[error(transparent)]
64 Client(#[from] Error),
65 /// A definitive provider rejection.
66 #[error(
67 "OANDA rejected the operation with HTTP {status}: {}",
68 describe_reason(.code.as_deref(), .message.as_deref())
69 )]
70 Rejected {
71 /// HTTP status.
72 status: u16,
73 /// OANDA's `errorCode`, when it supplied a readable one.
74 code: Option<String>,
75 /// OANDA's `errorMessage`, when it supplied a readable one.
76 message: Option<String>,
77 /// The endpoint's documented rejection fields, or what OANDA actually sent for them.
78 body: Supplied<R>,
79 },
80}
81
82/// Common OANDA error fields on endpoints without a specific rejection schema.
83#[derive(Debug, Clone, Default, serde::Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct GenericRejection {
86 /// Optional provider error code.
87 pub error_code: Option<String>,
88 /// Optional provider error message.
89 pub error_message: Option<String>,
90}
91
92/// An OANDA client failure.
93#[derive(Debug, thiserror::Error)]
94#[non_exhaustive]
95pub enum Error {
96 /// Local input or environment is invalid.
97 #[error("invalid client input: {0}")]
98 InvalidInput(String),
99 /// Local query or stream capacity was exhausted.
100 #[error("OANDA request rate capacity exhausted")]
101 RateLimited,
102 /// Provider rate limited a request.
103 #[error("OANDA returned HTTP 429")]
104 ProviderRateLimited,
105 /// The provider rejected the request definitively.
106 #[error(
107 "OANDA returned HTTP {status}: {}",
108 describe_reason(.code.as_deref(), .message.as_deref())
109 )]
110 Provider {
111 /// HTTP response status.
112 status: u16,
113 /// OANDA's `errorCode`, when it supplied a readable one.
114 code: Option<String>,
115 /// OANDA's `errorMessage`, when it supplied a readable one.
116 message: Option<String>,
117 },
118 /// An I/O or decode outcome leaves a mutation uncertain.
119 #[error("OANDA mutation outcome is ambiguous for account {account}")]
120 AmbiguousMutation {
121 /// Affected account identifier.
122 account: String,
123 },
124 /// This account requires explicit reconciliation before another mutation.
125 #[error("OANDA account {account} requires reconciliation")]
126 ReconciliationRequired {
127 /// Affected account identifier.
128 account: String,
129 },
130 /// Another mutation for this account is still in flight.
131 #[error("OANDA account {account} has a mutation in flight")]
132 MutationInFlight {
133 /// Affected account identifier.
134 account: String,
135 },
136 /// Transport failed before a response was available.
137 #[error("OANDA transport failed: {0}")]
138 Transport(String),
139 /// The response or stream record exceeds a configured bound.
140 #[error("OANDA response exceeds the configured size limit")]
141 ResponseTooLarge,
142 /// Provider response did not match the documented shape.
143 #[error("malformed OANDA response: {0}")]
144 Decode(String),
145}