Skip to main content

iota_sdk_graphql_client/
error.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2025 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{
6    num::{ParseIntError, TryFromIntError},
7    string::FromUtf8Error,
8};
9
10use cynic::serde::Deserialize;
11use iota_types::{
12    AddressParseError, DigestParseError, TypeParseError, TypeTag, iota_names::error::IotaNamesError,
13};
14use reqwest::{StatusCode, Url};
15
16use crate::faucet::FaucetError;
17
18type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
19
20pub type GraphQLResult<T> = std::result::Result<T, GraphQLError>;
21
22/// Maximum number of body bytes retained in an HTTP/decode error. Load
23/// balancer and gateway pages can be hundreds of KB, so the body is truncated
24/// before being stored in the error.
25const MAX_ERROR_BODY_BYTES: usize = 512;
26
27/// Render a response body as a truncated, UTF-8-lossy string suitable for
28/// inclusion in an error message.
29fn truncated_body(bytes: &[u8]) -> String {
30    let truncated = bytes.len() > MAX_ERROR_BODY_BYTES;
31    let slice = &bytes[..bytes.len().min(MAX_ERROR_BODY_BYTES)];
32    let mut body = String::from_utf8_lossy(slice).into_owned();
33    if truncated {
34        body.push_str("… (truncated)");
35    }
36    body
37}
38
39/// A GraphQL error as reported by the server, with its [`ErrorExtensions`]
40/// decoded.
41pub type QueryError = cynic::GraphQlError<ErrorExtensions>;
42
43/// The `extensions` object of a GraphQL error returned by the server.
44#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
45#[serde(crate = "cynic::serde")]
46#[non_exhaustive]
47pub struct ErrorExtensions {
48    /// Machine-readable error kind, e.g. `BAD_USER_INPUT`.
49    pub code: Option<String>,
50}
51
52/// Convert an error whose extensions were not decoded; `code` is `None`.
53pub(crate) fn query_error(error: cynic::GraphQlError) -> QueryError {
54    QueryError::new(error.message, error.locations, error.path, None)
55}
56
57fn display_graphql_errors(errors: &[QueryError]) -> String {
58    errors
59        .iter()
60        .map(|e| e.to_string())
61        .collect::<Vec<_>>()
62        .join(", ")
63}
64
65/// Errors returned by the GraphQL client.
66///
67/// A queried object, transaction or checkpoint that does not exist is reported
68/// as `Ok(None)`, so absence never surfaces here.
69///
70/// A variant that wraps another error renders that error inline and also
71/// exposes it through [`std::error::Error::source`], so the message is
72/// self-contained and the cause stays matchable.
73#[derive(Debug, thiserror::Error)]
74#[non_exhaustive]
75pub enum GraphQLError {
76    /// The request could not be sent, or the response could not be read.
77    #[error("request error: {0}")]
78    Request(#[from] reqwest::Error),
79    /// The server answered with a non-success HTTP status.
80    #[error(
81        "GraphQL request to {url} failed with HTTP {status} while decoding `{target_type}`, \
82         body={body:?}",
83        url = .response.url,
84        status = .response.status,
85        target_type = .response.target_type,
86        body = .response.body,
87    )]
88    Http { response: Box<HttpResponse> },
89    /// The response body could not be parsed as JSON.
90    #[error(
91        "GraphQL request to {url} returned HTTP {status} but the body could not be parsed as JSON \
92         while decoding `{target_type}` (body={body:?}): {source}",
93        url = .response.url,
94        status = .response.status,
95        target_type = .response.target_type,
96        body = .response.body,
97    )]
98    Json {
99        response: Box<HttpResponse>,
100        #[source]
101        source: serde_json::Error,
102    },
103    /// The server returned errors for the query. The server sends no
104    /// [`ErrorExtensions::code`] for subscription errors.
105    #[error("query error: [{}]", display_graphql_errors(.0))]
106    Query(Vec<QueryError>),
107    /// The response carried neither data nor errors.
108    #[error("empty response: the server returned neither data nor errors")]
109    EmptyResponse,
110    /// A response field the client needs to build its return value was empty.
111    #[error("empty response field: {0}")]
112    EmptyResponseField(&'static str),
113    /// The server returned a variant of a GraphQL union or enum this client
114    /// does not know.
115    #[error("unknown {0} variant")]
116    UnknownVariant(&'static str),
117    /// A response value could not be deserialized into its SDK type.
118    #[error("deserialization error: {0}")]
119    Deserialization(#[source] BoxError),
120    /// A response value or a caller-supplied string could not be parsed.
121    #[error("parse error: {0}")]
122    Parse(#[source] BoxError),
123    /// An IOTA name could not be parsed.
124    #[error("invalid name `{name}`: {source}")]
125    InvalidName {
126        /// The name that could not be parsed.
127        name: String,
128        #[source]
129        source: IotaNamesError,
130    },
131    /// A dynamic field holds a different type than the caller asked for.
132    #[error("type mismatch: expected `{expected}`, got `{actual}`")]
133    TypeMismatch {
134        /// The type the caller asked for.
135        expected: TypeTag,
136        /// The type the field actually holds.
137        actual: TypeTag,
138    },
139    /// The caller-supplied arguments cannot be combined, or a caller-supplied
140    /// value is not usable for this operation.
141    #[error("invalid argument: {0}")]
142    InvalidArgument(&'static str),
143    /// The RPC URL scheme is not usable for subscriptions.
144    #[error("unsupported RPC scheme `{0}` for subscriptions, expected http, https, ws or wss")]
145    UnsupportedSubscriptionScheme(String),
146    /// The RPC URL needs TLS, which this build does not have.
147    #[error(
148        "scheme `{0}` needs TLS: enable the `tls-ring` or `tls-aws-lc` feature, or pass your own \
149         client to `new_with_reqwest_client`"
150    )]
151    TlsUnavailable(String),
152    /// The operation did not complete within its deadline.
153    #[error("timed out")]
154    Timeout,
155    /// A faucet request failed.
156    #[error("faucet error: {0}")]
157    Faucet(#[from] FaucetError),
158    /// The subscription transport failed.
159    #[error("subscription error: {0}")]
160    Subscription(#[source] BoxError),
161    /// The subscription server dropped `count` payloads before the next one
162    /// because the client could not keep up. The stream continues after this
163    /// error.
164    #[error("subscription lagged: {count} payload(s) dropped by the server")]
165    Lagged { count: u32 },
166}
167
168/// The HTTP response a [`GraphQLError::Http`] or [`GraphQLError::Json`] was
169/// raised for.
170#[derive(Debug)]
171pub struct HttpResponse {
172    url: Url,
173    status: StatusCode,
174    body: String,
175    target_type: &'static str,
176}
177
178impl HttpResponse {
179    fn new(url: Url, status: StatusCode, body: &[u8], target_type: &'static str) -> Box<Self> {
180        Box::new(Self {
181            url,
182            status,
183            body: truncated_body(body),
184            target_type,
185        })
186    }
187
188    /// URL the request was sent to.
189    pub fn url(&self) -> &Url {
190        &self.url
191    }
192
193    /// HTTP status the server answered with.
194    pub fn status(&self) -> StatusCode {
195        self.status
196    }
197
198    /// Truncated, UTF-8-lossy snapshot of the response body.
199    pub fn body(&self) -> &str {
200        &self.body
201    }
202
203    /// Name of the type the response was being decoded into. A bare status or
204    /// `serde_json` error does not reveal what the client was decoding.
205    pub fn target_type(&self) -> &'static str {
206        self.target_type
207    }
208}
209
210impl GraphQLError {
211    /// Build a [`GraphQLError::Http`] from a non-success response, retaining a
212    /// truncated, UTF-8-lossy snapshot of the body.
213    pub(crate) fn http(
214        url: Url,
215        status: StatusCode,
216        body: &[u8],
217        target_type: &'static str,
218    ) -> Self {
219        Self::Http {
220            response: HttpResponse::new(url, status, body, target_type),
221        }
222    }
223
224    /// Build a [`GraphQLError::Json`] from a response whose body is not valid
225    /// JSON, retaining a truncated, UTF-8-lossy snapshot of the body.
226    pub(crate) fn json(
227        url: Url,
228        status: StatusCode,
229        body: &[u8],
230        target_type: &'static str,
231        source: serde_json::Error,
232    ) -> Self {
233        Self::Json {
234            response: HttpResponse::new(url, status, body, target_type),
235            source,
236        }
237    }
238
239    /// Wrap an error raised while turning a response value into its SDK type.
240    pub(crate) fn deserialization<E: Into<BoxError>>(error: E) -> Self {
241        Self::Deserialization(error.into())
242    }
243
244    /// Wrap a subscription transport failure.
245    pub(crate) fn subscription<E: Into<BoxError>>(error: E) -> Self {
246        Self::Subscription(error.into())
247    }
248}
249
250impl From<iota_types::BcsError> for GraphQLError {
251    fn from(error: iota_types::BcsError) -> Self {
252        Self::Deserialization(error.into())
253    }
254}
255
256impl From<url::ParseError> for GraphQLError {
257    fn from(error: url::ParseError) -> Self {
258        Self::Parse(error.into())
259    }
260}
261
262impl From<ParseIntError> for GraphQLError {
263    fn from(error: ParseIntError) -> Self {
264        Self::Parse(error.into())
265    }
266}
267
268impl From<AddressParseError> for GraphQLError {
269    fn from(error: AddressParseError) -> Self {
270        Self::Parse(error.into())
271    }
272}
273
274impl From<DigestParseError> for GraphQLError {
275    fn from(error: DigestParseError) -> Self {
276        Self::Parse(error.into())
277    }
278}
279
280impl From<TryFromIntError> for GraphQLError {
281    fn from(error: TryFromIntError) -> Self {
282        Self::Parse(error.into())
283    }
284}
285
286impl From<TypeParseError> for GraphQLError {
287    fn from(error: TypeParseError) -> Self {
288        Self::Parse(error.into())
289    }
290}
291
292impl From<FromUtf8Error> for GraphQLError {
293    fn from(error: FromUtf8Error) -> Self {
294        Self::Parse(error.into())
295    }
296}
297
298#[cfg(feature = "move-types")]
299impl From<iota_move_types::FromObjectError> for GraphQLError {
300    fn from(error: iota_move_types::FromObjectError) -> Self {
301        Self::Deserialization(error.into())
302    }
303}
304
305#[cfg(all(test, not(target_arch = "wasm32")))]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn http_error_surfaces_status_body_and_decode_target() {
311        let url = Url::parse("https://graphql.devnet.iota.cafe").unwrap();
312        let error = GraphQLError::http(
313            url,
314            StatusCode::TOO_MANY_REQUESTS,
315            b"Too Many Requests",
316            "my_crate::MyResponse",
317        );
318        assert_eq!(
319            error.to_string(),
320            "GraphQL request to https://graphql.devnet.iota.cafe/ failed with HTTP 429 Too Many \
321             Requests while decoding `my_crate::MyResponse`, body=\"Too Many Requests\""
322        );
323        // The status stays inspectable instead of only being rendered.
324        assert!(
325            matches!(error, GraphQLError::Http { response } if response.status == StatusCode::TOO_MANY_REQUESTS)
326        );
327    }
328
329    #[test]
330    fn json_error_surfaces_status_body_and_source() {
331        use std::error::Error as _;
332
333        let url = Url::parse("https://graphql.devnet.iota.cafe").unwrap();
334        let serde_error = serde_json::from_slice::<serde_json::Value>(b"not json").unwrap_err();
335        let error = GraphQLError::json(
336            url,
337            StatusCode::OK,
338            b"not json",
339            "my_crate::MyResponse",
340            serde_error,
341        );
342        // The message is self-contained, and the cause stays matchable.
343        assert_eq!(
344            error.to_string(),
345            "GraphQL request to https://graphql.devnet.iota.cafe/ returned HTTP 200 OK but the \
346             body could not be parsed as JSON while decoding `my_crate::MyResponse` (body=\"not \
347             json\"): expected ident at line 1 column 2"
348        );
349        assert_eq!(
350            error.source().expect("expected a cause").to_string(),
351            "expected ident at line 1 column 2"
352        );
353    }
354
355    /// Rendering the message inline and exposing the same error through
356    /// `source` means a cause-chain formatter (`anyhow`/`eyre` `{:#}`, `{:?}`)
357    /// prints the wrapped message twice. That is the accepted cost of a
358    /// self-contained `Display`: a consumer that reads only the top frame,
359    /// such as the FFI layer, still sees the whole failure.
360    #[test]
361    fn chain_formatters_repeat_the_wrapped_message() {
362        use std::error::Error as _;
363
364        let error = GraphQLError::from(iota_types::BcsError::new(
365            bcs::from_bytes::<u64>(&[]).unwrap_err(),
366        ));
367
368        assert_eq!(
369            error.to_string(),
370            "deserialization error: unexpected end of input"
371        );
372
373        let mut chain = vec![error.to_string()];
374        let mut next = error.source();
375        while let Some(error) = next {
376            chain.push(error.to_string());
377            next = error.source();
378        }
379
380        assert_eq!(
381            chain,
382            [
383                "deserialization error: unexpected end of input",
384                "unexpected end of input",
385            ]
386        );
387    }
388
389    #[test]
390    fn body_is_truncated() {
391        let body = vec![b'a'; MAX_ERROR_BODY_BYTES + 100];
392        let rendered = truncated_body(&body);
393        assert!(rendered.contains("… (truncated)"));
394        assert_eq!(rendered.len(), MAX_ERROR_BODY_BYTES + "… (truncated)".len());
395    }
396}