mina_archive_sdk/error.rs
1use std::fmt;
2
3/// Errors returned by the Mina Archive SDK.
4///
5/// `#[non_exhaustive]`, so a `match` needs a `_` arm. A new failure mode — a
6/// new server error class, a new transport condition — must not be an SDK
7/// major release, and adding a variant to an exhaustively matched enum is
8/// breaking.
9#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum Error {
12 /// The GraphQL endpoint returned one or more errors. Not retried.
13 ///
14 /// Note `status` is almost always 200: the API answers every GraphQL-level
15 /// error with HTTP 200 and a populated `errors` array. `extensions.status`
16 /// is a payload field, not the HTTP status.
17 #[error("GraphQL error in {query_name}: {messages}")]
18 Graphql {
19 query_name: String,
20 status: reqwest::StatusCode,
21 messages: String,
22 errors: Vec<GraphqlErrorEntry>,
23 },
24
25 /// The server's rate limiter rejected the request with HTTP 429.
26 ///
27 /// This is the **only** non-200 the API emits under normal operation, so
28 /// unlike most 4xx it unambiguously means "slow down", and it is the one
29 /// case where retrying the identical request is correct.
30 #[error("rate limited on {query_name}: {messages}")]
31 RateLimited {
32 query_name: String,
33 /// From the `retry-after` header, when the server sends one.
34 retry_after: Option<std::time::Duration>,
35 /// From `x-ratelimit-limit`.
36 limit: Option<u64>,
37 /// From `x-ratelimit-remaining`.
38 remaining: Option<u64>,
39 messages: String,
40 },
41
42 /// A non-2xx response that is not GraphQL-shaped.
43 ///
44 /// The usual cause is a URL pointing somewhere the server does not serve:
45 /// the endpoint is the root path `/`, and `/graphql` returns 404 with an
46 /// HTML or plain-text body.
47 #[error("unexpected HTTP {status} in {query_name}: {body}")]
48 UnexpectedStatus {
49 query_name: String,
50 status: reqwest::StatusCode,
51 body: String,
52 },
53
54 /// Failed to connect after exhausting all retry attempts.
55 #[error("failed to execute {query_name} after {attempts} attempts: {source}")]
56 Connection {
57 query_name: String,
58 attempts: u32,
59 #[source]
60 source: reqwest::Error,
61 },
62
63 /// A required field was missing in an otherwise-successful response —
64 /// most likely a schema mismatch.
65 #[error("missing field '{field}' in {query_name} response")]
66 MissingField { query_name: String, field: String },
67
68 /// Failed to deserialize the response body.
69 #[error("failed to decode {query_name} response: {source}")]
70 Decode {
71 query_name: String,
72 #[source]
73 source: serde_json::Error,
74 },
75
76 /// Currency subtraction would underflow.
77 #[error("currency underflow: {0} - {1} would be negative")]
78 CurrencyUnderflow(u64, u64),
79
80 /// String could not be parsed as a Mina/nanomina currency value.
81 #[error("invalid currency format: {0}")]
82 InvalidCurrency(String),
83}
84
85/// Contract error codes published by Archive-Node-API in `extensions.code`.
86///
87/// These are the supported way to branch on a failure. Message text is
88/// deliberately minimal — the server blocks GraphQL field suggestions — and
89/// carries no stability promise, so do not match on it.
90pub mod codes {
91 /// Range exceeds `BLOCK_RANGE_SIZE`. Narrow it; retrying unchanged fails.
92 pub const BLOCK_RANGE_ERROR: &str = "BLOCK_RANGE_ERROR";
93 /// The action state is not in the archive.
94 pub const ACTION_STATE_NOT_FOUND: &str = "ACTION_STATE_NOT_FOUND";
95 /// The action state falls outside the requested block range.
96 pub const ACTION_STATE_OUT_OF_RANGE: &str = "ACTION_STATE_OUT_OF_RANGE";
97 /// Too many requests. See [`crate::Error::RateLimited`].
98 pub const RATE_LIMITED: &str = "RATE_LIMITED";
99}
100
101/// One entry from a GraphQL response's `errors` array.
102///
103/// `extensions.code` is the API's intended discriminator. It publishes four
104/// codes as contract — `ACTION_STATE_NOT_FOUND`, `ACTION_STATE_OUT_OF_RANGE`,
105/// `BLOCK_RANGE_ERROR` and `RATE_LIMITED` — and deliberately keeps the message
106/// text minimal, so matching on the code is the supported way to branch.
107///
108/// Every field but `message` is optional, and `code` in particular must stay
109/// optional: the server runs `maskedErrors: { isDev: false }`, so an
110/// unexpected error arrives with a generic message and no `extensions` at all.
111#[derive(Debug, Clone, serde::Deserialize)]
112#[serde(rename_all = "camelCase")]
113#[non_exhaustive]
114pub struct GraphqlErrorEntry {
115 pub message: String,
116 /// Shortcut for `extensions.code`, lifted during decoding. `None` for a
117 /// masked error.
118 #[serde(skip)]
119 pub code: Option<String>,
120 /// Response path to the field that failed, when the server reports one.
121 #[serde(default)]
122 pub path: Option<Vec<serde_json::Value>>,
123 /// Query source locations, when the server reports them.
124 #[serde(default)]
125 pub locations: Option<Vec<serde_json::Value>>,
126 /// The whole `extensions` object, kept verbatim so callers can read fields
127 /// beyond `code` without waiting for an SDK release.
128 #[serde(default)]
129 pub extensions: Option<serde_json::Value>,
130}
131
132impl GraphqlErrorEntry {
133 /// Lift `extensions.code` into the `code` shortcut after deserialising.
134 pub(crate) fn with_lifted_code(mut self) -> Self {
135 self.code = self
136 .extensions
137 .as_ref()
138 .and_then(|e| e.get("code"))
139 .and_then(|c| c.as_str())
140 .map(str::to_string);
141 self
142 }
143}
144
145impl fmt::Display for GraphqlErrorEntry {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str(&self.message)
148 }
149}
150
151impl Error {
152 /// The `extensions.code` values carried by a [`Error::Graphql`], in order.
153 ///
154 /// Empty for every other variant, and for masked errors, which arrive with
155 /// no `extensions` at all — so an empty result means "no code was sent",
156 /// never "no error occurred".
157 pub fn graphql_codes(&self) -> Vec<&str> {
158 match self {
159 Error::Graphql { errors, .. } => {
160 errors.iter().filter_map(|e| e.code.as_deref()).collect()
161 }
162 _ => Vec::new(),
163 }
164 }
165
166 /// True when any entry carries the given `extensions.code`.
167 pub fn has_graphql_code(&self, code: &str) -> bool {
168 self.graphql_codes().contains(&code)
169 }
170}
171
172pub type Result<T> = std::result::Result<T, Error>;