Skip to main content

espresso_api/
error.rs

1//! Typed API errors for proper HTTP/gRPC status code mapping
2
3use std::fmt;
4
5use thiserror::Error;
6
7/// Marker errors for availability endpoint failures. These are wrapped in `anyhow::Error` by the
8/// state implementation and downcasted in the Axum handlers to select the right HTTP status code.
9#[derive(Debug, Error)]
10pub enum AvailabilityError {
11    #[error("{0}")]
12    NotFound(String),
13    #[error("{0}")]
14    RangeExceeded(String),
15    #[error("{0}")]
16    BadRequest(String),
17    /// Pruned by this node (410).
18    #[error("{0}")]
19    Pruned(String),
20}
21
22/// Why a node refused a transaction. Downcast by [`classify`] to pick the status code.
23#[derive(Debug, Error)]
24pub enum SubmitError {
25    /// No node would ever accept it (400).
26    #[error("{0}")]
27    Invalid(String),
28    /// The node cannot queue it right now, retrying later can succeed (503).
29    #[error("{0}")]
30    Overloaded(String),
31}
32
33/// API error types that can be downcast at the HTTP/gRPC boundary
34#[derive(Debug)]
35pub enum ApiError {
36    /// Client provided invalid input (maps to 400 Bad Request / INVALID_ARGUMENT)
37    BadRequest(anyhow::Error),
38    /// Requested resource does not exist (maps to 404 Not Found)
39    NotFound(anyhow::Error),
40    /// Pruned by this node (maps to 410 Gone / NOT_FOUND)
41    Gone(anyhow::Error),
42    /// Handler failed for any reason (maps to 500 Internal Server Error / INTERNAL)
43    Internal(anyhow::Error),
44    /// The node cannot serve the request right now (maps to 503 Service Unavailable / UNAVAILABLE)
45    Unavailable(anyhow::Error),
46}
47
48impl fmt::Display for ApiError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            ApiError::BadRequest(err)
52            | ApiError::NotFound(err)
53            | ApiError::Gone(err)
54            | ApiError::Internal(err)
55            | ApiError::Unavailable(err) => {
56                // Both transports render handler errors through this impl (v1 via the axum
57                // handlers, v2 via `to_status`), so a provider credential in the message is
58                // removed once, here.
59                f.write_str(&espresso_utils::redact::scrub(&err.to_string()))
60            },
61        }
62    }
63}
64
65impl std::error::Error for ApiError {
66    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
67        match self {
68            ApiError::BadRequest(err)
69            | ApiError::NotFound(err)
70            | ApiError::Gone(err)
71            | ApiError::Internal(err)
72            | ApiError::Unavailable(err) => err.source(),
73        }
74    }
75}
76
77/// Errors raised as [`SubmitError`] or [`AvailabilityError`] by a state implementation carry
78/// semantic meaning; anything else is a failure the client cannot act on. Both transports classify
79/// through here, so v1 and v2 cannot drift on what counts as a 404. gRPC has no gone code, so v2
80/// reports a 410 as `not_found`.
81pub fn classify(err: anyhow::Error) -> ApiError {
82    match err.downcast_ref::<SubmitError>() {
83        Some(SubmitError::Invalid(_)) => return ApiError::BadRequest(err),
84        Some(SubmitError::Overloaded(_)) => return ApiError::Unavailable(err),
85        None => {},
86    }
87    match err.downcast_ref::<AvailabilityError>() {
88        Some(AvailabilityError::NotFound(_)) => ApiError::NotFound(err),
89        Some(AvailabilityError::Pruned(_)) => ApiError::Gone(err),
90        Some(AvailabilityError::RangeExceeded(_) | AvailabilityError::BadRequest(_)) => {
91            ApiError::BadRequest(err)
92        },
93        None => ApiError::Internal(err),
94    }
95}
96
97/// Render a handler error for the v2 transports: the gRPC `grpc-message` trailer and, via
98/// `tonic_rest::RestError`, the REST error body. Classification and the credential scrub happen
99/// in [`classify`] and [`ApiError`]'s `Display`, shared with the v1 path.
100pub fn to_status(err: anyhow::Error) -> tonic::Status {
101    let err = classify(err);
102    let message = err.to_string();
103    match err {
104        ApiError::NotFound(_) | ApiError::Gone(_) => tonic::Status::not_found(message),
105        ApiError::BadRequest(_) => tonic::Status::invalid_argument(message),
106        ApiError::Internal(_) => tonic::Status::internal(message),
107        ApiError::Unavailable(_) => tonic::Status::unavailable(message),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// Ported from the deleted `api/src/tonic.rs` when the v2 adapter stopped rendering through
116    /// [`ApiError`]. The token endpoints inline the L1 provider's error, URL and API key included,
117    /// and both the `grpc-message` trailer and the REST body reach unauthenticated callers.
118    #[test]
119    fn to_status_scrubs_provider_credentials() {
120        let err = anyhow::anyhow!(
121            r#"failed to get total supply: reqwest::Error {{ url: "https://u:p@rpc.invalid/v1/FAKEKEY" }}"#
122        );
123
124        let status = to_status(err);
125
126        assert_eq!(status.code(), tonic::Code::Internal);
127        assert!(
128            !status.message().contains("FAKEKEY"),
129            "{}",
130            status.message()
131        );
132        assert!(!status.message().contains("u:p"), "{}", status.message());
133        assert!(
134            status.message().contains("rpc.invalid"),
135            "{}",
136            status.message()
137        );
138    }
139
140    /// A 404 has to stay a 404 through the classifier, since the scrub sits on the same path.
141    #[test]
142    fn availability_not_found_maps_to_not_found() {
143        let status = to_status(anyhow::Error::new(AvailabilityError::NotFound(
144            "leaf 7".to_string(),
145        )));
146        assert_eq!(status.code(), tonic::Code::NotFound);
147    }
148
149    #[test]
150    fn availability_pruned_maps_to_gone() {
151        let err = classify(anyhow::Error::new(AvailabilityError::Pruned(
152            "state at height 7".to_string(),
153        )));
154        assert!(matches!(err, ApiError::Gone(_)), "{err}");
155    }
156}