Skip to main content

cratestack_axum/rpc/
error_encode.rs

1//! Wire-side error encoding: dispatcher errors and handler-emitted error
2//! responses both end up as [`RpcErrorBody`] frames.
3
4use axum::http::HeaderMap;
5use cratestack_core::CratestackError;
6use cratestack_core::rpc::RpcErrorBody;
7use serde::Serialize;
8
9use crate::HttpTransport;
10
11use super::RPC_BINDING_CAPABILITIES;
12use super::codec_helpers::decode_rpc_body;
13use super::util::synthesize_error_for_status;
14
15/// Build an `axum::Response` carrying an [`RpcErrorBody`] for a
16/// [`CratestackError`] raised inside the dispatcher (e.g. body decode
17/// failure, unknown op id). The HTTP status comes from
18/// [`CratestackError::status_code`]; the body is codec-encoded via the
19/// request's codec, content-type negotiated against
20/// [`RPC_BINDING_CAPABILITIES`].
21pub fn encode_rpc_error<C>(
22    codec: &C,
23    headers: &HeaderMap,
24    error: &CratestackError,
25) -> axum::response::Response
26where
27    C: HttpTransport,
28{
29    // A `TRANSACTION_ABORTED` this dispatch does not own is answered as
30    // `INTERNAL_ERROR` (`crate::idempotency::unrecorded`).
31    let disowned = crate::idempotency::disowned(error);
32    let error = disowned.as_ref().unwrap_or(error);
33    let body = RpcErrorBody::from_cratestack(error);
34    let status = error.status_code();
35    let response = encode_rpc_value_response(codec, headers, status, body);
36    let response = crate::auth_challenge::attach(response, error.auth_challenges());
37    crate::idempotency::with_tag(crate::idempotency::unrecorded_tag(error), response)
38}
39
40/// Post-process a handler-emitted response. Success responses pass
41/// through unchanged. Non-2xx responses are buffered, their bodies
42/// decoded as [`cratestack_core::CratestackErrorResponse`] (the REST shape
43/// the existing axum handlers emit), translated to [`RpcErrorBody`]
44/// with the gRPC-style code, and re-encoded with the same HTTP status.
45///
46/// Called once per dispatch (inside `rpc_dispatch_inner`) so unary and
47/// batch both see uniformly RpcErrorBody-shaped error bodies.
48pub async fn convert_handler_error_response<C>(
49    response: axum::response::Response,
50    codec: &C,
51    headers: &HeaderMap,
52) -> axum::response::Response
53where
54    C: HttpTransport,
55{
56    if response.status().is_success() {
57        return response;
58    }
59    // The re-encoded response keeps the idempotency layer's do-not-record
60    // tag (`crate::idempotency::unrecorded`).
61    let tag = crate::idempotency::tag_of(&response);
62    let converted = convert_error_body(response, codec, headers).await;
63    crate::idempotency::with_tag(tag, converted)
64}
65
66async fn convert_error_body<C>(
67    response: axum::response::Response,
68    codec: &C,
69    headers: &HeaderMap,
70) -> axum::response::Response
71where
72    C: HttpTransport,
73{
74    // The refusal's `WWW-Authenticate` (cratestack#1208) rides on the
75    // handler's response; the re-encoded one below must keep it, so the
76    // headers are taken apart from the body rather than copied.
77    let (parts, body) = response.into_parts();
78    let status = parts.status;
79    let challenge_headers = parts.headers;
80    let body_bytes =
81        match axum::body::to_bytes(body, cratestack_core::MAX_RESPONSE_REBUFFER_BYTES).await {
82            Ok(bytes) => bytes.to_vec(),
83            Err(error) => {
84                // Buffering failed — synthesize an internal error frame.
85                let cool = CratestackError::Internal(format!("buffer handler error body: {error}"));
86                return encode_rpc_error(codec, headers, &cool);
87            }
88        };
89
90    let rpc_body = match decode_rpc_body::<_, cratestack_core::CratestackErrorResponse>(
91        codec,
92        headers,
93        &body_bytes,
94    ) {
95        Ok(parsed) => RpcErrorBody::from_cratestack_response(parsed),
96        Err(_) => {
97            // Handler emitted a non-2xx with a body that isn't the
98            // framework's REST error shape (unusual — would happen if a
99            // handler escaped through `into_response()` directly). Build
100            // a synthetic body from the status alone.
101            let cool = synthesize_error_for_status(status);
102            RpcErrorBody::from_cratestack(&cool)
103        }
104    };
105
106    let response = encode_rpc_value_response(codec, headers, status, rpc_body);
107    crate::auth_challenge::copy(challenge_headers, response)
108}
109
110fn encode_rpc_value_response<C, T>(
111    codec: &C,
112    headers: &HeaderMap,
113    status: axum::http::StatusCode,
114    value: T,
115) -> axum::response::Response
116where
117    C: HttpTransport,
118    T: Serialize,
119{
120    // Re-use the existing transport encoder so content negotiation
121    // happens via the same path as everything else.
122    crate::encode_transport_result_with_status_for::<_, T>(
123        codec,
124        headers,
125        &RPC_BINDING_CAPABILITIES,
126        status,
127        Ok(value),
128    )
129}