Skip to main content

cratestack_client_rust/rpc/
error.rs

1use cratestack_core::{CoolError, rpc::RpcErrorBody};
2use reqwest::StatusCode;
3use serde::de::DeserializeOwned;
4
5use crate::codec::HttpClientCodec;
6use crate::error::ClientError;
7use crate::runtime::wire::RuntimeResponseWire;
8
9/// Error variant produced by the RPC client when a remote call fails with
10/// an `RpcErrorBody` payload. Distinct from the REST `ClientError::Remote`
11/// (which carries the `CoolErrorResponse` shape) so library users can
12/// switch on the gRPC-style `code` string directly.
13#[derive(Debug, Clone)]
14pub struct RpcRemoteError {
15    pub status: StatusCode,
16    pub body: RpcErrorBody,
17}
18
19impl std::fmt::Display for RpcRemoteError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        write!(
22            f,
23            "RPC call failed with code {} (status {}): {}",
24            self.body.code,
25            self.status.as_u16(),
26            self.body.message
27        )
28    }
29}
30
31impl std::error::Error for RpcRemoteError {}
32
33/// Top-level error returned by the RPC client. Mirrors `ClientError`
34/// (the REST error type) but reports server-side failures as
35/// `RpcRemoteError { code, message, details }` rather than the
36/// REST-shaped `CoolErrorResponse`.
37#[derive(Debug, thiserror::Error)]
38pub enum RpcClientError {
39    #[error("transport error: {0}")]
40    Transport(#[from] reqwest::Error),
41    #[error("codec error: {0}")]
42    Codec(#[from] CoolError),
43    #[error("invalid response: {0}")]
44    InvalidResponse(String),
45    #[error("bad input: {0}")]
46    BadInput(String),
47    #[error("{0}")]
48    Remote(RpcRemoteError),
49}
50
51/// Stable alias for the receiver shape that [`RpcClient::call_streaming`]
52/// returns. Exists so macro-generated code (`include_client_schema!` for
53/// `transport rpc` schemas) has a single name to bind without
54/// re-spelling the tokio/error-type plumbing on every method, and so
55/// downstream users have a typedef they can store in struct fields,
56/// function returns, etc. without leaking the implementation detail.
57pub type RpcStream<O> = tokio::sync::mpsc::Receiver<Result<O, RpcClientError>>;
58
59/// Map a gRPC-style `RpcErrorBody.code` back to a sensible HTTP status.
60/// Inverse of `cratestack_core::rpc::rpc_code`. Used for batch error
61/// frames — the wire frame doesn't carry an HTTP status (the outer
62/// `/rpc/batch` response is always 200), so we synthesize one from the
63/// code for consistency with the unary `RpcRemoteError` shape.
64pub(crate) fn http_status_for_rpc_code(code: &str) -> StatusCode {
65    match code {
66        "invalid_argument" => StatusCode::BAD_REQUEST,
67        "unauthenticated" => StatusCode::UNAUTHORIZED,
68        "permission_denied" => StatusCode::FORBIDDEN,
69        "not_found" => StatusCode::NOT_FOUND,
70        "conflict" => StatusCode::CONFLICT,
71        "failed_precondition" => StatusCode::PRECONDITION_FAILED,
72        _ => StatusCode::INTERNAL_SERVER_ERROR,
73    }
74}
75
76pub(crate) fn client_error_to_rpc(error: ClientError) -> RpcClientError {
77    match error {
78        ClientError::Transport(error) => RpcClientError::Transport(error),
79        ClientError::Codec(error) => RpcClientError::Codec(error),
80        ClientError::InvalidResponse(message) => RpcClientError::InvalidResponse(message),
81        ClientError::BadInput(message) => RpcClientError::BadInput(message),
82        ClientError::State(message) => RpcClientError::InvalidResponse(message),
83        ClientError::Remote {
84            status,
85            error,
86            message,
87        } => {
88            // Legacy translation path — should not fire for /rpc/... URLs
89            // (the server-side dispatcher emits RpcErrorBody-shaped error
90            // bodies), but keep a sensible fallback rather than dropping
91            // the message on the floor.
92            let body = error
93                .map(cratestack_core::rpc::RpcErrorBody::from_cool_response)
94                .unwrap_or_else(|| RpcErrorBody {
95                    code: "internal".to_owned(),
96                    message,
97                    details: None,
98                });
99            RpcClientError::Remote(RpcRemoteError { status, body })
100        }
101    }
102}
103
104pub(crate) fn decode_rpc_unary_response<C, Output>(
105    codec: &C,
106    response: &RuntimeResponseWire,
107) -> Result<Output, RpcClientError>
108where
109    C: HttpClientCodec,
110    Output: DeserializeOwned,
111{
112    let content_type = response
113        .headers
114        .iter()
115        .find(|header| header.name.eq_ignore_ascii_case("content-type"))
116        .map(|header| header.value.as_str())
117        .ok_or_else(|| {
118            RpcClientError::InvalidResponse("response is missing Content-Type header".to_owned())
119        })?;
120
121    if (200..=299).contains(&response.status_code) {
122        codec
123            .decode_response::<Output>(content_type, &response.body)
124            .map_err(RpcClientError::Codec)
125    } else {
126        let body = codec
127            .decode_response::<RpcErrorBody>(content_type, &response.body)
128            .unwrap_or_else(|_| RpcErrorBody {
129                code: "internal".to_owned(),
130                message: format!(
131                    "unexpected RPC error body for status {}",
132                    response.status_code
133                ),
134                details: None,
135            });
136        Err(RpcClientError::Remote(RpcRemoteError {
137            status: StatusCode::from_u16(response.status_code)
138                .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
139            body,
140        }))
141    }
142}