Skip to main content

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}