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}