Skip to main content

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>;