Skip to main content

connectrpc/
error.rs

1//! ConnectRPC error types and HTTP status mapping.
2//!
3//! This module provides error types that conform to the ConnectRPC protocol
4//! specification, including proper error code mappings to HTTP status codes.
5
6use std::sync::Arc;
7
8use bytes::Bytes;
9use http::StatusCode;
10use serde::Deserialize;
11use serde::Serialize;
12
13/// ConnectRPC error codes.
14///
15/// These codes follow the ConnectRPC protocol specification and map to
16/// corresponding HTTP status codes.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum ErrorCode {
21    /// The operation was cancelled.
22    Canceled,
23    /// Unknown error.
24    Unknown,
25    /// Invalid argument provided by the client.
26    InvalidArgument,
27    /// Deadline expired before operation completed.
28    DeadlineExceeded,
29    /// Requested entity was not found.
30    NotFound,
31    /// Entity already exists.
32    AlreadyExists,
33    /// Permission denied.
34    PermissionDenied,
35    /// Resource exhausted (e.g., rate limit).
36    ResourceExhausted,
37    /// Operation rejected due to system state.
38    FailedPrecondition,
39    /// Operation was aborted.
40    Aborted,
41    /// Operation was out of range.
42    OutOfRange,
43    /// Operation is not implemented.
44    Unimplemented,
45    /// Internal error.
46    Internal,
47    /// Service is unavailable.
48    Unavailable,
49    /// Unrecoverable data loss.
50    DataLoss,
51    /// Request is unauthenticated.
52    Unauthenticated,
53}
54
55impl ErrorCode {
56    /// Get the string representation of this error code.
57    #[inline]
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            Self::Canceled => "canceled",
61            Self::Unknown => "unknown",
62            Self::InvalidArgument => "invalid_argument",
63            Self::DeadlineExceeded => "deadline_exceeded",
64            Self::NotFound => "not_found",
65            Self::AlreadyExists => "already_exists",
66            Self::PermissionDenied => "permission_denied",
67            Self::ResourceExhausted => "resource_exhausted",
68            Self::FailedPrecondition => "failed_precondition",
69            Self::Aborted => "aborted",
70            Self::OutOfRange => "out_of_range",
71            Self::Unimplemented => "unimplemented",
72            Self::Internal => "internal",
73            Self::Unavailable => "unavailable",
74            Self::DataLoss => "data_loss",
75            Self::Unauthenticated => "unauthenticated",
76        }
77    }
78
79    /// Get the HTTP status code for this error code.
80    #[inline]
81    pub fn http_status(&self) -> StatusCode {
82        match self {
83            // 499 Client Closed Request (nginx-style, used by Connect protocol for Canceled)
84            Self::Canceled => {
85                // 499 is always valid (100-999 range), but avoid panic in library code.
86                StatusCode::from_u16(499).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
87            }
88            Self::Unknown => StatusCode::INTERNAL_SERVER_ERROR,
89            Self::InvalidArgument => StatusCode::BAD_REQUEST,
90            Self::DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
91            Self::NotFound => StatusCode::NOT_FOUND,
92            Self::AlreadyExists => StatusCode::CONFLICT,
93            Self::PermissionDenied => StatusCode::FORBIDDEN,
94            Self::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
95            Self::FailedPrecondition => StatusCode::BAD_REQUEST,
96            Self::Aborted => StatusCode::CONFLICT,
97            Self::OutOfRange => StatusCode::BAD_REQUEST,
98            Self::Unimplemented => StatusCode::NOT_IMPLEMENTED,
99            Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
100            Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
101            Self::DataLoss => StatusCode::INTERNAL_SERVER_ERROR,
102            Self::Unauthenticated => StatusCode::UNAUTHORIZED,
103        }
104    }
105}
106
107impl ErrorCode {
108    /// Get the gRPC numeric status code for this error code.
109    #[inline]
110    pub fn grpc_code(&self) -> u32 {
111        match self {
112            Self::Canceled => 1,
113            Self::Unknown => 2,
114            Self::InvalidArgument => 3,
115            Self::DeadlineExceeded => 4,
116            Self::NotFound => 5,
117            Self::AlreadyExists => 6,
118            Self::PermissionDenied => 7,
119            Self::ResourceExhausted => 8,
120            Self::FailedPrecondition => 9,
121            Self::Aborted => 10,
122            Self::OutOfRange => 11,
123            Self::Unimplemented => 12,
124            Self::Internal => 13,
125            Self::Unavailable => 14,
126            Self::DataLoss => 15,
127            Self::Unauthenticated => 16,
128        }
129    }
130
131    /// Create an error code from a gRPC numeric status code.
132    ///
133    /// Returns `None` for unknown codes. Code 0 (OK) returns `None` since
134    /// it represents success, not an error.
135    #[inline]
136    pub fn from_grpc_code(code: u32) -> Option<Self> {
137        match code {
138            1 => Some(Self::Canceled),
139            2 => Some(Self::Unknown),
140            3 => Some(Self::InvalidArgument),
141            4 => Some(Self::DeadlineExceeded),
142            5 => Some(Self::NotFound),
143            6 => Some(Self::AlreadyExists),
144            7 => Some(Self::PermissionDenied),
145            8 => Some(Self::ResourceExhausted),
146            9 => Some(Self::FailedPrecondition),
147            10 => Some(Self::Aborted),
148            11 => Some(Self::OutOfRange),
149            12 => Some(Self::Unimplemented),
150            13 => Some(Self::Internal),
151            14 => Some(Self::Unavailable),
152            15 => Some(Self::DataLoss),
153            16 => Some(Self::Unauthenticated),
154            _ => None,
155        }
156    }
157}
158
159impl std::str::FromStr for ErrorCode {
160    type Err = ();
161
162    /// Parse an error code from a string.
163    ///
164    /// Returns `Err(())` if the string doesn't match any known error code.
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        match s {
167            "canceled" => Ok(Self::Canceled),
168            "unknown" => Ok(Self::Unknown),
169            "invalid_argument" => Ok(Self::InvalidArgument),
170            "deadline_exceeded" => Ok(Self::DeadlineExceeded),
171            "not_found" => Ok(Self::NotFound),
172            "already_exists" => Ok(Self::AlreadyExists),
173            "permission_denied" => Ok(Self::PermissionDenied),
174            "resource_exhausted" => Ok(Self::ResourceExhausted),
175            "failed_precondition" => Ok(Self::FailedPrecondition),
176            "aborted" => Ok(Self::Aborted),
177            "out_of_range" => Ok(Self::OutOfRange),
178            "unimplemented" => Ok(Self::Unimplemented),
179            "internal" => Ok(Self::Internal),
180            "unavailable" => Ok(Self::Unavailable),
181            "data_loss" => Ok(Self::DataLoss),
182            "unauthenticated" => Ok(Self::Unauthenticated),
183            _ => Err(()),
184        }
185    }
186}
187
188/// Additional error details that can be attached to a ConnectRPC error.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ErrorDetail {
191    /// The type URL for this detail.
192    /// Named `type` per Connect protocol (distinct from protobuf JSON `@type`).
193    #[serde(rename = "type")]
194    pub type_url: String,
195    /// Base64-encoded protobuf message.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub value: Option<String>,
198    /// Debug information (JSON representation).
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub debug: Option<serde_json::Value>,
201}
202
203impl ErrorDetail {
204    /// Build a detail from a protobuf message, handling the base64 encoding
205    /// the Connect protocol requires.
206    ///
207    /// `type_name` is the message's bare fully-qualified name — e.g.
208    /// `google.rpc.RetryInfo` — which is what the Connect protocol's JSON
209    /// `type` field carries; the gRPC path prepends the standard
210    /// `type.googleapis.com/` `Any` prefix automatically (a value already
211    /// carrying a prefix is passed through unchanged on that path). The
212    /// message is encoded to protobuf wire bytes and base64'd with the
213    /// protocol's canonical unpadded-standard alphabet — prefer this over
214    /// populating [`value`](Self::value) by hand, where a wrong alphabet is
215    /// dropped from the gRPC status (with a logged warning).
216    pub fn from_message(type_name: impl Into<String>, message: &impl buffa::Message) -> Self {
217        Self {
218            type_url: type_name.into(),
219            value: Some(detail_b64::encode(&buffa::Message::encode_to_vec(message))),
220            debug: None,
221        }
222    }
223}
224
225/// The base64 form the Connect protocol uses for error-detail values:
226/// unpadded standard alphabet on encode, padding accepted on decode.
227/// Single-sourced so [`ErrorDetail::from_message`] and the gRPC status
228/// encoder can never drift apart.
229pub(crate) mod detail_b64 {
230    use base64::Engine as _;
231    use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
232
233    pub(crate) fn encode(bytes: &[u8]) -> String {
234        STANDARD_NO_PAD.encode(bytes)
235    }
236
237    pub(crate) fn decode_lenient(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
238        STANDARD_NO_PAD.decode(s).or_else(|_| STANDARD.decode(s))
239    }
240}
241
242/// A ConnectRPC error.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ConnectError {
245    /// The error code.
246    pub code: ErrorCode,
247    /// Human-readable error message.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub message: Option<String>,
250    /// Additional error details.
251    #[serde(skip_serializing_if = "Vec::is_empty", default)]
252    pub details: Vec<ErrorDetail>,
253    /// Optional HTTP status override (not serialized).
254    /// When set, this overrides the default HTTP status for the error code.
255    #[serde(skip)]
256    http_status_override: Option<StatusCode>,
257    /// Response headers to include in error response (not serialized).
258    ///
259    /// Boxed to keep `ConnectError` small enough to pass by value in
260    /// `Result` without tripping `clippy::result_large_err`. `None` means
261    /// no extra headers.
262    #[serde(skip)]
263    pub(crate) response_headers: Option<Box<http::HeaderMap>>,
264    /// Response trailers to include in error response (not serialized).
265    ///
266    /// Boxed for the same reason as `response_headers`. `None` means no
267    /// extra trailers.
268    #[serde(skip)]
269    pub(crate) trailers: Option<Box<http::HeaderMap>>,
270    /// The underlying cause, if this error was converted from another error
271    /// (not serialized — never sent over the wire).
272    ///
273    /// Surfaced through [`Error::source`](std::error::Error::source).
274    /// `Arc` (rather than `Box`) so `ConnectError` stays `Clone` without
275    /// requiring the wrapped error to be.
276    #[serde(skip)]
277    source: Option<Arc<dyn std::error::Error + Send + Sync>>,
278}
279
280/// Shared empty `HeaderMap` for the `None` arm of the read accessors, so
281/// callers can iterate / `.get()` unconditionally.
282static EMPTY_HEADERS: std::sync::LazyLock<http::HeaderMap> =
283    std::sync::LazyLock::new(http::HeaderMap::new);
284
285fn box_headers(h: http::HeaderMap) -> Option<Box<http::HeaderMap>> {
286    if h.is_empty() {
287        None
288    } else {
289        Some(Box::new(h))
290    }
291}
292
293impl ConnectError {
294    /// Create a new error with the given code and message.
295    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
296        Self {
297            code,
298            message: Some(message.into()),
299            details: Vec::new(),
300            http_status_override: None,
301            response_headers: None,
302            trailers: None,
303            source: None,
304        }
305    }
306
307    /// Add response headers to be included in the error response.
308    #[must_use]
309    pub fn with_headers(mut self, headers: http::HeaderMap) -> Self {
310        self.response_headers = box_headers(headers);
311        self
312    }
313
314    /// Add response trailers to be included in the error response.
315    #[must_use]
316    pub fn with_trailers(mut self, trailers: http::HeaderMap) -> Self {
317        self.trailers = box_headers(trailers);
318        self
319    }
320
321    /// Borrow the response headers. Returns an empty map if none were set.
322    ///
323    /// On an error returned by a client call, these are the headers the
324    /// response arrived with. Every terminal error from a
325    /// [`ServerStream`](crate::client::ServerStream) carries them, whatever
326    /// the protocol and whatever ended the stream.
327    pub fn response_headers(&self) -> &http::HeaderMap {
328        self.response_headers.as_deref().unwrap_or(&EMPTY_HEADERS)
329    }
330
331    /// Borrow the response trailers. Returns an empty map if none were set.
332    ///
333    /// On an error returned by a client call, these are the trailing
334    /// metadata the RPC ended with, populated whenever any was received —
335    /// gRPC trailers or a Connect END_STREAM `metadata` object. The
336    /// status-bearing gRPC trailers (`grpc-status`, `grpc-message`,
337    /// `grpc-status-details-bin`) are excluded, because their content is
338    /// this error's [`code`](Self::code), message and
339    /// [`details`](Self::details);
340    /// [`ServerStream::trailers()`](crate::client::ServerStream::trailers)
341    /// reports the wire map verbatim if you need them.
342    pub fn trailers(&self) -> &http::HeaderMap {
343        self.trailers.as_deref().unwrap_or(&EMPTY_HEADERS)
344    }
345
346    /// Mutably borrow the response headers, allocating an empty map if
347    /// none were set.
348    pub fn response_headers_mut(&mut self) -> &mut http::HeaderMap {
349        self.response_headers.get_or_insert_default()
350    }
351
352    /// Mutably borrow the response trailers, allocating an empty map if
353    /// none were set.
354    pub fn trailers_mut(&mut self) -> &mut http::HeaderMap {
355        self.trailers.get_or_insert_default()
356    }
357
358    /// Replace the response headers. An empty map is stored as `None`.
359    pub fn set_response_headers(&mut self, headers: http::HeaderMap) {
360        self.response_headers = box_headers(headers);
361    }
362
363    /// Replace the response trailers. An empty map is stored as `None`.
364    pub fn set_trailers(&mut self, trailers: http::HeaderMap) {
365        self.trailers = box_headers(trailers);
366    }
367
368    /// Set an HTTP status override for this error.
369    ///
370    /// When set, this overrides the default HTTP status derived from the error code.
371    /// This is useful for HTTP-level errors like 415 Unsupported Media Type.
372    #[must_use]
373    pub fn with_http_status(mut self, status: StatusCode) -> Self {
374        self.http_status_override = Some(status);
375        self
376    }
377
378    /// Create an error for unsupported media type (HTTP 415).
379    ///
380    /// This is used when the client sends a content type that the server doesn't support.
381    pub fn unsupported_media_type(message: impl Into<String>) -> Self {
382        // Connect protocol specifies Unknown for unsupported content types
383        Self::new(ErrorCode::Unknown, message).with_http_status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
384    }
385
386    /// Create an error for method not allowed (HTTP 405).
387    ///
388    /// This is used when the client uses an HTTP method other than POST.
389    pub fn method_not_allowed(message: impl Into<String>) -> Self {
390        // Connect protocol specifies Unknown for wrong HTTP method
391        Self::new(ErrorCode::Unknown, message).with_http_status(StatusCode::METHOD_NOT_ALLOWED)
392    }
393
394    /// Create a canceled error.
395    pub fn canceled(message: impl Into<String>) -> Self {
396        Self::new(ErrorCode::Canceled, message)
397    }
398
399    /// Create an unknown error.
400    pub fn unknown(message: impl Into<String>) -> Self {
401        Self::new(ErrorCode::Unknown, message)
402    }
403
404    /// Create an invalid argument error.
405    pub fn invalid_argument(message: impl Into<String>) -> Self {
406        Self::new(ErrorCode::InvalidArgument, message)
407    }
408
409    /// Create a deadline exceeded error.
410    pub fn deadline_exceeded(message: impl Into<String>) -> Self {
411        Self::new(ErrorCode::DeadlineExceeded, message)
412    }
413
414    /// Create a not found error.
415    pub fn not_found(message: impl Into<String>) -> Self {
416        Self::new(ErrorCode::NotFound, message)
417    }
418
419    /// Create an already exists error.
420    pub fn already_exists(message: impl Into<String>) -> Self {
421        Self::new(ErrorCode::AlreadyExists, message)
422    }
423
424    /// Create a permission denied error.
425    pub fn permission_denied(message: impl Into<String>) -> Self {
426        Self::new(ErrorCode::PermissionDenied, message)
427    }
428
429    /// Create a resource exhausted error.
430    pub fn resource_exhausted(message: impl Into<String>) -> Self {
431        Self::new(ErrorCode::ResourceExhausted, message)
432    }
433
434    /// Create a failed precondition error.
435    pub fn failed_precondition(message: impl Into<String>) -> Self {
436        Self::new(ErrorCode::FailedPrecondition, message)
437    }
438
439    /// Create an aborted error.
440    pub fn aborted(message: impl Into<String>) -> Self {
441        Self::new(ErrorCode::Aborted, message)
442    }
443
444    /// Create an out of range error.
445    pub fn out_of_range(message: impl Into<String>) -> Self {
446        Self::new(ErrorCode::OutOfRange, message)
447    }
448
449    /// Create an unimplemented error.
450    pub fn unimplemented(message: impl Into<String>) -> Self {
451        Self::new(ErrorCode::Unimplemented, message)
452    }
453
454    /// Create an internal error.
455    pub fn internal(message: impl Into<String>) -> Self {
456        Self::new(ErrorCode::Internal, message)
457    }
458
459    /// Create an unavailable error.
460    pub fn unavailable(message: impl Into<String>) -> Self {
461        Self::new(ErrorCode::Unavailable, message)
462    }
463
464    /// Create a data loss error.
465    pub fn data_loss(message: impl Into<String>) -> Self {
466        Self::new(ErrorCode::DataLoss, message)
467    }
468
469    /// Create an unauthenticated error.
470    pub fn unauthenticated(message: impl Into<String>) -> Self {
471        Self::new(ErrorCode::Unauthenticated, message)
472    }
473
474    /// Add an error detail.
475    #[must_use]
476    pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
477        self.details.push(detail);
478        self
479    }
480
481    /// Attach the underlying cause, surfaced through
482    /// [`Error::source`](std::error::Error::source).
483    ///
484    /// Unlike `message` (which is sent over the wire and shown to callers),
485    /// the source is local-only — useful for logging/observability without
486    /// leaking internal detail to the client. It is never populated by
487    /// decoding a `ConnectError` received over the wire (there is nothing to
488    /// attach), only by local code that calls this method — so `source()`
489    /// on an error a client parsed from a server response is always `None`.
490    /// Accepts either a concrete error or an already-boxed one, so it
491    /// composes with transport errors that are type-erased before reaching
492    /// this call. Replaces any source attached by a previous call.
493    #[must_use]
494    pub fn with_source(
495        mut self,
496        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
497    ) -> Self {
498        self.source = Some(Arc::from(source.into()));
499        self
500    }
501
502    /// Get the HTTP status code for this error.
503    ///
504    /// Returns the HTTP status override if set, otherwise derives it from the error code.
505    pub fn http_status(&self) -> StatusCode {
506        self.http_status_override
507            .unwrap_or_else(|| self.code.http_status())
508    }
509
510    /// Encode this error as JSON bytes.
511    pub fn to_json(&self) -> Bytes {
512        Bytes::from(serde_json::to_vec(self).unwrap_or_else(|_| {
513            // Fallback: produce minimal valid Connect error JSON.
514            format!(r#"{{"code":"{}"}}"#, self.code.as_str()).into_bytes()
515        }))
516    }
517}
518
519impl std::fmt::Display for ConnectError {
520    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521        write!(f, "{}", self.code.as_str())?;
522        if let Some(ref message) = self.message {
523            write!(f, ": {message}")?;
524        }
525        Ok(())
526    }
527}
528
529impl std::error::Error for ConnectError {
530    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
531        self.source
532            .as_ref()
533            .map(|e| &**e as &(dyn std::error::Error + 'static))
534    }
535}
536
537impl From<std::io::Error> for ConnectError {
538    fn from(err: std::io::Error) -> Self {
539        Self::internal(err.to_string()).with_source(err)
540    }
541}
542
543/// Lets `Response::try_with_header(..)?` propagate naturally inside a
544/// handler.
545impl From<http::Error> for ConnectError {
546    fn from(err: http::Error) -> Self {
547        Self::internal(err.to_string()).with_source(err)
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn from_http_error_is_internal() {
557        let http_err: http::Error = http::HeaderValue::from_bytes(b"bad\nval")
558            .unwrap_err()
559            .into();
560        let e: ConnectError = http_err.into();
561        assert_eq!(e.code, ErrorCode::Internal);
562    }
563
564    #[test]
565    fn from_io_error_preserves_source() {
566        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
567        let e: ConnectError = io_err.into();
568        let source = std::error::Error::source(&e).expect("source must be preserved");
569        assert_eq!(source.to_string(), "refused");
570    }
571
572    #[test]
573    fn from_http_error_preserves_source() {
574        let http_err: http::Error = http::HeaderValue::from_bytes(b"bad\nval")
575            .unwrap_err()
576            .into();
577        let e: ConnectError = http_err.into();
578        assert!(std::error::Error::source(&e).is_some());
579    }
580
581    #[test]
582    fn with_source_is_returned_by_error_source() {
583        let cause = std::io::Error::other("boom");
584        let e = ConnectError::unavailable("wrapped").with_source(cause);
585        let source = std::error::Error::source(&e).expect("source must be set");
586        assert_eq!(source.to_string(), "boom");
587    }
588
589    #[test]
590    fn with_source_accepts_already_boxed_error() {
591        let boxed: Box<dyn std::error::Error + Send + Sync> =
592            Box::new(std::io::Error::other("boxed boom"));
593        let e = ConnectError::unavailable("wrapped").with_source(boxed);
594        let source = std::error::Error::source(&e).expect("source must be set");
595        assert_eq!(source.to_string(), "boxed boom");
596    }
597
598    #[test]
599    fn no_source_by_default() {
600        let e = ConnectError::internal("plain");
601        assert!(std::error::Error::source(&e).is_none());
602    }
603
604    #[test]
605    fn with_source_survives_clone() {
606        let cause = std::io::Error::other("boom");
607        let e = ConnectError::unavailable("wrapped")
608            .with_source(cause)
609            .clone();
610        assert!(std::error::Error::source(&e).is_some());
611    }
612
613    #[test]
614    fn test_grpc_code_round_trip() {
615        let codes = [
616            ErrorCode::Canceled,
617            ErrorCode::Unknown,
618            ErrorCode::InvalidArgument,
619            ErrorCode::DeadlineExceeded,
620            ErrorCode::NotFound,
621            ErrorCode::AlreadyExists,
622            ErrorCode::PermissionDenied,
623            ErrorCode::ResourceExhausted,
624            ErrorCode::FailedPrecondition,
625            ErrorCode::Aborted,
626            ErrorCode::OutOfRange,
627            ErrorCode::Unimplemented,
628            ErrorCode::Internal,
629            ErrorCode::Unavailable,
630            ErrorCode::DataLoss,
631            ErrorCode::Unauthenticated,
632        ];
633
634        for code in codes {
635            let grpc = code.grpc_code();
636            let back = ErrorCode::from_grpc_code(grpc);
637            assert_eq!(
638                back,
639                Some(code),
640                "round-trip failed for {code:?} (grpc code {grpc})"
641            );
642        }
643    }
644
645    #[test]
646    fn test_grpc_code_values() {
647        assert_eq!(ErrorCode::Canceled.grpc_code(), 1);
648        assert_eq!(ErrorCode::Unknown.grpc_code(), 2);
649        assert_eq!(ErrorCode::Internal.grpc_code(), 13);
650        assert_eq!(ErrorCode::Unauthenticated.grpc_code(), 16);
651    }
652
653    #[test]
654    fn test_from_grpc_code_ok_returns_none() {
655        assert_eq!(ErrorCode::from_grpc_code(0), None);
656    }
657
658    #[test]
659    fn test_from_grpc_code_unknown_returns_none() {
660        assert_eq!(ErrorCode::from_grpc_code(17), None);
661        assert_eq!(ErrorCode::from_grpc_code(999), None);
662    }
663
664    #[test]
665    fn connect_error_stays_under_result_large_err_threshold() {
666        // clippy::result_large_err fires at 128 bytes. Keep some headroom so
667        // adding a small field doesn't immediately re-trip the lint.
668        const THRESHOLD: usize = 96;
669        let size = std::mem::size_of::<ConnectError>();
670        assert!(
671            size <= THRESHOLD,
672            "ConnectError is {size} bytes (threshold {THRESHOLD}); \
673             box large fields to keep Result<_, ConnectError> cheap to move"
674        );
675    }
676
677    #[test]
678    fn header_accessors() {
679        let mut e = ConnectError::internal("x");
680        assert!(e.response_headers().is_empty());
681        assert!(e.trailers().is_empty());
682
683        // Setting an empty map stays None.
684        e.set_response_headers(http::HeaderMap::new());
685        assert!(e.response_headers.is_none());
686        assert!(
687            ConnectError::new(ErrorCode::Internal, "x")
688                .with_headers(http::HeaderMap::new())
689                .response_headers
690                .is_none()
691        );
692
693        e.trailers_mut()
694            .insert("x-t", http::HeaderValue::from_static("v"));
695        assert_eq!(e.trailers().get("x-t").unwrap(), "v");
696
697        let mut h = http::HeaderMap::new();
698        h.insert("x-h", http::HeaderValue::from_static("w"));
699        let e = e.with_headers(h);
700        assert_eq!(e.response_headers().get("x-h").unwrap(), "w");
701    }
702}