Skip to main content

oanda_rs/
error.rs

1//! Error types returned by the SDK.
2
3use reqwest::StatusCode;
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6
7/// The unified error type returned by every SDK operation.
8#[derive(Debug, thiserror::Error)]
9#[non_exhaustive]
10pub enum Error {
11    /// The HTTP request could not be performed (connection, TLS, timeout, ...).
12    #[error("transport error: {0}")]
13    Transport(#[from] reqwest::Error),
14
15    /// OANDA answered with a non-success HTTP status.
16    ///
17    /// The parsed error body is available in [`Error::Api::body`]; the raw
18    /// payload of order-reject responses can be recovered with
19    /// [`ApiErrorBody::details`].
20    #[error("OANDA API error (HTTP {status}): {}", body.error_message)]
21    Api {
22        /// HTTP status code of the response.
23        status: StatusCode,
24        /// Value of the `RequestID` response header, when present.
25        request_id: Option<String>,
26        /// Parsed error body.
27        body: ApiErrorBody,
28    },
29
30    /// A 2xx response body could not be deserialized into the expected type.
31    #[error("failed to decode response body: {source}")]
32    Decode {
33        /// The underlying deserialization error.
34        source: serde_json::Error,
35        /// The raw response body, kept verbatim for debugging.
36        body: String,
37    },
38
39    /// A streaming connection violated the expected protocol.
40    #[error("stream protocol error: {0}")]
41    Stream(String),
42
43    /// The client was configured with invalid values.
44    #[error("invalid client configuration: {0}")]
45    Config(String),
46}
47
48impl Error {
49    /// The HTTP status code associated with this error, if any.
50    pub fn status(&self) -> Option<StatusCode> {
51        match self {
52            Error::Api { status, .. } => Some(*status),
53            Error::Transport(e) => e.status(),
54            _ => None,
55        }
56    }
57
58    /// The `RequestID` header OANDA attached to the failing response, if any.
59    pub fn request_id(&self) -> Option<&str> {
60        match self {
61            Error::Api { request_id, .. } => request_id.as_deref(),
62            _ => None,
63        }
64    }
65
66    /// Whether this error was caused by OANDA's rate limiting (HTTP 429).
67    ///
68    /// OANDA allows 120 REST requests per second and 2 new connections per
69    /// second per IP address. The client's built-in rate limiter (see
70    /// [`ClientBuilder`](crate::ClientBuilder)) keeps you below those limits
71    /// by default, so this should only occur when rate limiting was disabled
72    /// or other processes share the same IP.
73    pub fn is_rate_limited(&self) -> bool {
74        self.status() == Some(StatusCode::TOO_MANY_REQUESTS)
75    }
76}
77
78/// The JSON body OANDA returns for error responses.
79///
80/// All error responses carry `errorMessage`; some also carry `errorCode`
81/// and/or `rejectReason`. Order-related rejections include additional
82/// top-level fields (e.g. `orderRejectTransaction`, `relatedTransactionIDs`,
83/// `lastTransactionID`), which are preserved in [`ApiErrorBody::extra`] and
84/// can be decoded into a typed struct with [`ApiErrorBody::details`].
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[non_exhaustive]
87pub struct ApiErrorBody {
88    /// Human-readable description of the error.
89    #[serde(rename = "errorMessage")]
90    pub error_message: String,
91    /// Machine-readable error code, when provided.
92    #[serde(rename = "errorCode", skip_serializing_if = "Option::is_none")]
93    pub error_code: Option<String>,
94    /// The reason the request was rejected, when provided.
95    #[serde(rename = "rejectReason", skip_serializing_if = "Option::is_none")]
96    pub reject_reason: Option<String>,
97    /// Any additional top-level fields of the error body.
98    #[serde(flatten)]
99    pub extra: serde_json::Map<String, serde_json::Value>,
100}
101
102impl ApiErrorBody {
103    /// Constructs an error body from a plain-text (non-JSON) payload.
104    pub(crate) fn from_text(text: String) -> Self {
105        ApiErrorBody {
106            error_message: text,
107            error_code: None,
108            reject_reason: None,
109            extra: serde_json::Map::new(),
110        }
111    }
112
113    /// Attempts to decode the full error body into a typed view.
114    ///
115    /// Useful for order endpoints, whose 400/404 responses carry reject
116    /// transactions, e.g.
117    /// [`CreateOrderRejectBody`](crate::endpoints::orders::CreateOrderRejectBody).
118    pub fn details<T: DeserializeOwned>(&self) -> Option<T> {
119        serde_json::to_value(self)
120            .ok()
121            .and_then(|v| serde_json::from_value(v).ok())
122    }
123}