Skip to main content

nordnet_api/
error.rs

1//! Error type for the Nordnet API client.
2//!
3//! Mirrors the documented HTTP status codes (400, 401, 403, 429, 503) plus
4//! transport-level failures. Every non-2xx response carries the raw response
5//! body string so callers can surface the documented `ErrorResponse` shape
6//! (`{"code": ..., "message": ...}`) without re-parsing in this layer.
7//!
8//! Status mapping (per `docs-source/nordnet-api-v2.html`):
9//! - 400 -> [`Error::BadRequest`] ("Invalid parameter.")
10//! - 401 -> [`Error::Unauthorized`] ("Unauthorized to log in ...")
11//! - 403 -> [`Error::Forbidden`]
12//! - 429 -> [`Error::TooManyRequests`] (caller decides backoff; the docs
13//!   suggest 10s, but the library never sleeps or retries — POST/PUT
14//!   on `/orders` is non-idempotent and a hidden retry could double-place)
15//! - 503 -> [`Error::ServiceUnavailable`] (caller decides backoff; the
16//!   server's `Retry-After` header is preserved on the underlying response
17//!   but the library does not honor it automatically)
18//! - any other non-2xx -> [`Error::UnexpectedStatus`]
19
20use thiserror::Error;
21
22/// All recoverable failures from the Nordnet API client.
23#[derive(Debug, Error)]
24pub enum Error {
25    /// HTTP 400 — invalid parameter per docs.
26    #[error("400 Bad Request: {body}")]
27    BadRequest { body: String },
28
29    /// HTTP 401 — unauthorized (typically rejected credentials).
30    #[error("401 Unauthorized: {body}")]
31    Unauthorized { body: String },
32
33    /// HTTP 403 — forbidden.
34    #[error("403 Forbidden: {body}")]
35    Forbidden { body: String },
36
37    /// HTTP 429 — Too Many Requests. The library does not retry; the
38    /// caller chooses whether to back off and re-issue.
39    #[error("429 Too Many Requests: {body}")]
40    TooManyRequests { body: String },
41
42    /// HTTP 503 — Service Unavailable. The library does not retry; the
43    /// caller chooses whether to back off and re-issue (and is responsible
44    /// for honoring `Retry-After` if present in the underlying response).
45    #[error("503 Service Unavailable: {body}")]
46    ServiceUnavailable { body: String },
47
48    /// Any non-2xx response not specifically modelled above.
49    #[error("HTTP {status}: {body}")]
50    UnexpectedStatus { status: u16, body: String },
51
52    /// Underlying reqwest transport failure (DNS, connect, TLS, timeout, ...).
53    #[error("transport error: {0}")]
54    Transport(#[from] reqwest::Error),
55
56    /// Response body was not valid JSON for the expected type.
57    #[error("response body did not match expected schema: {source}; body was: {body}")]
58    Decode {
59        #[source]
60        source: serde_json::Error,
61        body: String,
62    },
63
64    /// Failure during the SSH-key login flow (key parsing, algorithm
65    /// mismatch, encrypted-key rejection, …). The wrapped
66    /// [`nordnet_model::AuthError`] carries the specific failure mode.
67    #[error("authentication failure: {0}")]
68    Auth(#[from] nordnet_model::AuthError),
69
70    /// Header value construction failed (typically because credentials
71    /// contain bytes that are not valid for an HTTP header).
72    #[error("invalid header value: {0}")]
73    InvalidHeader(String),
74
75    /// Form-urlencoded serialization failed (used by `Client::post_form`
76    /// and `Client::put_form` for endpoints whose Swagger 2.0 parameters
77    /// are marked `FormData`).
78    #[error("form-urlencoded serialization failed: {0}")]
79    EncodeForm(String),
80}
81
82impl Error {
83    /// Build the appropriate variant from an HTTP status code + response body.
84    pub(crate) fn from_status(status: u16, body: String) -> Self {
85        match status {
86            400 => Error::BadRequest { body },
87            401 => Error::Unauthorized { body },
88            403 => Error::Forbidden { body },
89            429 => Error::TooManyRequests { body },
90            503 => Error::ServiceUnavailable { body },
91            other => Error::UnexpectedStatus {
92                status: other,
93                body,
94            },
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn maps_documented_statuses() {
105        assert!(matches!(
106            Error::from_status(400, "x".into()),
107            Error::BadRequest { .. }
108        ));
109        assert!(matches!(
110            Error::from_status(401, "x".into()),
111            Error::Unauthorized { .. }
112        ));
113        assert!(matches!(
114            Error::from_status(403, "x".into()),
115            Error::Forbidden { .. }
116        ));
117        assert!(matches!(
118            Error::from_status(429, "x".into()),
119            Error::TooManyRequests { .. }
120        ));
121        assert!(matches!(
122            Error::from_status(503, "x".into()),
123            Error::ServiceUnavailable { .. }
124        ));
125        assert!(matches!(
126            Error::from_status(418, "x".into()),
127            Error::UnexpectedStatus { status: 418, .. }
128        ));
129    }
130}