Skip to main content

cratestack_client_rust/rpc/
error.rs

1use cratestack_core::{CratestackError, rpc::RpcErrorBody};
2use reqwest::StatusCode;
3use serde::de::DeserializeOwned;
4
5use crate::codec::HttpClientCodec;
6use crate::envelope_error::EnvelopeError;
7use crate::error::{ClientError, TransportError};
8use crate::remote_refusal::{Challenges, www_authenticate};
9use crate::runtime::wire::RuntimeResponseWire;
10
11/// Error variant produced by the RPC client when a remote call fails with
12/// an `RpcErrorBody` payload. Distinct from the REST `ClientError::Remote`
13/// (which carries the `CratestackErrorResponse` shape) so library users can
14/// switch on the gRPC-style `code` string directly.
15#[derive(Debug, Clone)]
16pub struct RpcRemoteError {
17    pub status: StatusCode,
18    /// `code`, `message`, `details` and, in `extra`, every other member of
19    /// the error body (an `AuthRefusal`'s members, cratestack#1208).
20    pub body: RpcErrorBody,
21    /// Every `WWW-Authenticate` value the answer carried, raw and in order
22    /// (cratestack#1243). Always empty for a batch frame's error: a frame
23    /// has no headers of its own.
24    pub challenges: Challenges,
25}
26
27impl std::fmt::Display for RpcRemoteError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(
30            f,
31            "RPC call failed with code {} (status {}): {}",
32            self.body.code,
33            self.status.as_u16(),
34            self.body.message
35        )
36    }
37}
38
39impl std::error::Error for RpcRemoteError {}
40
41/// Top-level error returned by the RPC client. Mirrors `ClientError`
42/// (the REST error type) but reports server-side failures as
43/// `RpcRemoteError { code, message, details }` rather than the
44/// REST-shaped `CratestackErrorResponse`.
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum RpcClientError {
48    #[error("transport error: {0}")]
49    Transport(#[source] TransportError),
50    /// RPC parity for [`ClientError::Middleware`] (cratestack#926) —
51    /// a failure raised by a `reqwest_middleware` middleware rather
52    /// than by the transport under it. `middleware` feature only.
53    #[cfg(feature = "middleware")]
54    #[error("middleware error: {0}")]
55    Middleware(#[source] crate::middleware::MiddlewareError),
56    #[error("codec error: {0}")]
57    Codec(#[from] CratestackError),
58    #[error("invalid response: {0}")]
59    InvalidResponse(String),
60    #[error("bad input: {0}")]
61    BadInput(String),
62    /// The signed transport failed (cratestack#1007).
63    #[error("signed transport: {0}")]
64    Envelope(#[from] EnvelopeError),
65    #[error("{0}")]
66    Remote(RpcRemoteError),
67}
68
69/// Stable alias for the receiver shape that [`RpcClient::call_streaming`]
70/// returns. Exists so macro-generated code (`include_client_schema!` for
71/// `transport rpc` schemas) has a single name to bind without
72/// re-spelling the tokio/error-type plumbing on every method, and so
73/// downstream users have a typedef they can store in struct fields,
74/// function returns, etc. without leaking the implementation detail.
75pub type RpcStream<O> = tokio::sync::mpsc::Receiver<Result<O, RpcClientError>>;
76
77/// Map a gRPC-style `RpcErrorBody.code` back to a sensible HTTP status.
78/// Inverse of `cratestack_core::rpc::rpc_code`. Used for batch error
79/// frames — the wire frame doesn't carry an HTTP status (the outer
80/// `/rpc/batch` response is always 200), so we synthesize one from the
81/// code for consistency with the unary `RpcRemoteError` shape.
82pub(crate) fn http_status_for_rpc_code(code: &str) -> StatusCode {
83    match code {
84        "invalid_argument" => StatusCode::BAD_REQUEST,
85        "unauthenticated" => StatusCode::UNAUTHORIZED,
86        "permission_denied" => StatusCode::FORBIDDEN,
87        "not_found" => StatusCode::NOT_FOUND,
88        "conflict" => StatusCode::CONFLICT,
89        // An `@isolation` procedure that ran out of retries: 409 on the
90        // wire, like `conflict`, but its own code (GHSA-r67q-4qqq-g9gm).
91        "aborted" => StatusCode::CONFLICT,
92        "failed_precondition" => StatusCode::PRECONDITION_FAILED,
93        // cratestack#846: emitted by `RateLimitLayer` on a throttled
94        // request. Without this arm a batched throttle would surface to
95        // the caller as a synthetic 500, hiding the one status a client's
96        // backoff logic actually keys on.
97        "resource_exhausted" => StatusCode::TOO_MANY_REQUESTS,
98        _ => StatusCode::INTERNAL_SERVER_ERROR,
99    }
100}
101
102pub(crate) fn client_error_to_rpc(error: ClientError) -> RpcClientError {
103    match error {
104        ClientError::Transport(error) => RpcClientError::Transport(error),
105        #[cfg(feature = "middleware")]
106        ClientError::Middleware(error) => RpcClientError::Middleware(error),
107        ClientError::Codec(error) => RpcClientError::Codec(error),
108        ClientError::InvalidResponse(message) => RpcClientError::InvalidResponse(message),
109        ClientError::BadInput(message) => RpcClientError::BadInput(message),
110        ClientError::Envelope(error) => RpcClientError::Envelope(error),
111        ClientError::State(message) => RpcClientError::InvalidResponse(message),
112        ClientError::Remote {
113            status,
114            error,
115            message,
116            challenges,
117        } => {
118            // Legacy translation path — should not fire for /rpc/... URLs
119            // (the server-side dispatcher emits RpcErrorBody-shaped error
120            // bodies), but keep a sensible fallback rather than dropping
121            // the message on the floor.
122            let body = error
123                .map(|error| cratestack_core::rpc::RpcErrorBody::from_cratestack_response(*error))
124                .unwrap_or_else(|| RpcErrorBody {
125                    code: "internal".to_owned(),
126                    message,
127                    details: None,
128                    extra: Default::default(),
129                });
130            RpcClientError::Remote(RpcRemoteError {
131                status,
132                body,
133                challenges,
134            })
135        }
136    }
137}
138
139pub(crate) fn decode_rpc_unary_response<C, Output>(
140    codec: &C,
141    response: &RuntimeResponseWire,
142) -> Result<Output, RpcClientError>
143where
144    C: HttpClientCodec,
145    Output: DeserializeOwned,
146{
147    let content_type = response
148        .headers
149        .iter()
150        .find(|header| header.name.eq_ignore_ascii_case("content-type"))
151        .map(|header| header.value.as_str())
152        .ok_or_else(|| {
153            RpcClientError::InvalidResponse("response is missing Content-Type header".to_owned())
154        })?;
155
156    if (200..=299).contains(&response.status_code) {
157        codec
158            .decode_response::<Output>(content_type, &response.body)
159            .map_err(RpcClientError::Codec)
160    } else {
161        let body = codec
162            .decode_response::<RpcErrorBody>(content_type, &response.body)
163            .unwrap_or_else(|_| RpcErrorBody {
164                code: "internal".to_owned(),
165                message: format!(
166                    "unexpected RPC error body for status {}",
167                    response.status_code
168                ),
169                details: None,
170                extra: Default::default(),
171            });
172        Err(RpcClientError::Remote(RpcRemoteError {
173            status: StatusCode::from_u16(response.status_code)
174                .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
175            body,
176            challenges: www_authenticate(&response.headers),
177        }))
178    }
179}
180
181impl From<ClientError> for RpcClientError {
182    fn from(error: ClientError) -> Self {
183        client_error_to_rpc(error)
184    }
185}