Skip to main content

trillium_grpc/
status.rs

1//! RPC outcomes: the [`Status`] type and its [`Code`], plus the `grpc-status`
2//! trailer (de)serialization that moves them on and off the wire.
3
4use crate::Metadata;
5use trillium::Headers;
6
7/// A gRPC status code — the integer carried in the `grpc-status` trailer.
8///
9/// The discriminants are the canonical gRPC code numbers, so `as u8` and
10/// [`from_u8`](Self::from_u8) move between the enum and its wire form. `Ok`
11/// (0) is the success code; every other variant is an error.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[repr(u8)]
14pub enum Code {
15    /// Not an error; the RPC completed successfully.
16    Ok = 0,
17    /// The operation was cancelled, typically by the caller.
18    Cancelled = 1,
19    /// An error whose cause doesn't map to a more specific code. Also the
20    /// code a missing or unparseable `grpc-status` resolves to.
21    Unknown = 2,
22    /// The client supplied an argument the server could not accept (as opposed
23    /// to [`FailedPrecondition`](Self::FailedPrecondition), this is independent
24    /// of system state).
25    InvalidArgument = 3,
26    /// The deadline expired before the operation could complete.
27    DeadlineExceeded = 4,
28    /// A requested entity was not found.
29    NotFound = 5,
30    /// An entity the client tried to create already exists.
31    AlreadyExists = 6,
32    /// The caller is authenticated but lacks permission for this operation.
33    PermissionDenied = 7,
34    /// A resource has been exhausted — a quota, or perhaps the message-size
35    /// limit.
36    ResourceExhausted = 8,
37    /// The system is not in a state required for the operation (e.g. acting on
38    /// a resource that must first be initialized).
39    FailedPrecondition = 9,
40    /// The operation was aborted, often due to a concurrency conflict.
41    Aborted = 10,
42    /// The operation was attempted past the valid range.
43    OutOfRange = 11,
44    /// The operation is not implemented or not supported. Also the code a
45    /// `404` from the transport maps to.
46    Unimplemented = 12,
47    /// An internal invariant was broken — something the implementation expected
48    /// to hold did not.
49    Internal = 13,
50    /// The service is unavailable, typically a transient condition the caller
51    /// can retry with backoff.
52    Unavailable = 14,
53    /// Unrecoverable data loss or corruption.
54    DataLoss = 15,
55    /// The request lacks valid authentication credentials.
56    Unauthenticated = 16,
57}
58
59impl Code {
60    /// Convert a wire byte to a `Code`, or `None` if it isn't one of the 0–16
61    /// gRPC codes.
62    pub fn from_u8(n: u8) -> Option<Self> {
63        Some(match n {
64            0 => Self::Ok,
65            1 => Self::Cancelled,
66            2 => Self::Unknown,
67            3 => Self::InvalidArgument,
68            4 => Self::DeadlineExceeded,
69            5 => Self::NotFound,
70            6 => Self::AlreadyExists,
71            7 => Self::PermissionDenied,
72            8 => Self::ResourceExhausted,
73            9 => Self::FailedPrecondition,
74            10 => Self::Aborted,
75            11 => Self::OutOfRange,
76            12 => Self::Unimplemented,
77            13 => Self::Internal,
78            14 => Self::Unavailable,
79            15 => Self::DataLoss,
80            16 => Self::Unauthenticated,
81            _ => return None,
82        })
83    }
84
85    /// The canonical gRPC code number, as written into the `grpc-status`
86    /// trailer.
87    pub const fn as_u8(self) -> u8 {
88        self as u8
89    }
90}
91
92/// The outcome of an RPC: a [`Code`], a human-readable message, and any
93/// trailing [`Metadata`].
94///
95/// This is both the error type returned from service methods and the value
96/// parsed back out of a response's `grpc-status` trailers. It implements
97/// [`std::error::Error`], so `?` works in any method returning `Result<_,
98/// Status>`. Build one with [`new`](Self::new) or a code-named constructor
99/// such as [`not_found`](Self::not_found).
100#[derive(Debug, Clone)]
101pub struct Status {
102    /// The gRPC status code.
103    pub code: Code,
104    /// A human-readable description. Percent-encoded on the wire so it can
105    /// carry arbitrary UTF-8.
106    pub message: String,
107    /// Trailing metadata sent alongside the status.
108    pub metadata: Metadata,
109}
110
111macro_rules! status_constructors {
112    ($($name:ident => $variant:ident),* $(,)?) => {
113        $(
114            /// Construct a `Status` with this code and the given message, and
115            /// empty metadata.
116            pub fn $name(message: impl Into<String>) -> Self {
117                Self {
118                    code: Code::$variant,
119                    message: message.into(),
120                    metadata: Metadata::new(),
121                }
122            }
123        )*
124    };
125}
126
127impl Status {
128    /// Construct a `Status` with the given code and message, and empty
129    /// metadata. The code-named constructors ([`not_found`](Self::not_found),
130    /// [`internal`](Self::internal), …) are usually more convenient.
131    pub fn new(code: Code, message: impl Into<String>) -> Self {
132        Self {
133            code,
134            message: message.into(),
135            metadata: Metadata::new(),
136        }
137    }
138
139    /// The success status: code `Ok`, empty message, empty metadata.
140    pub fn ok() -> Self {
141        Self {
142            code: Code::Ok,
143            message: String::new(),
144            metadata: Metadata::new(),
145        }
146    }
147
148    /// Whether this status is the success code.
149    pub fn is_ok(&self) -> bool {
150        matches!(self.code, Code::Ok)
151    }
152
153    /// Attach trailing metadata. Carried in the `grpc-status` trailers
154    /// alongside `grpc-message`.
155    pub fn with_metadata(mut self, metadata: Metadata) -> Self {
156        self.metadata = metadata;
157        self
158    }
159
160    status_constructors! {
161        cancelled            => Cancelled,
162        unknown              => Unknown,
163        invalid_argument     => InvalidArgument,
164        deadline_exceeded    => DeadlineExceeded,
165        not_found            => NotFound,
166        already_exists       => AlreadyExists,
167        permission_denied    => PermissionDenied,
168        resource_exhausted   => ResourceExhausted,
169        failed_precondition  => FailedPrecondition,
170        aborted              => Aborted,
171        out_of_range         => OutOfRange,
172        unimplemented        => Unimplemented,
173        internal             => Internal,
174        unavailable          => Unavailable,
175        data_loss            => DataLoss,
176        unauthenticated      => Unauthenticated,
177    }
178
179    /// Serialize into a fresh `Headers`, suitable for use as response
180    /// trailers. See [`write_into`](Self::write_into) for the header layout.
181    pub fn into_trailers(self) -> Headers {
182        let mut headers = Headers::new();
183        self.write_into(&mut headers);
184        headers
185    }
186
187    /// Write `grpc-status` (and, when non-empty, the percent-encoded
188    /// `grpc-message`) plus any trailing metadata into `headers`.
189    pub fn write_into(&self, headers: &mut Headers) {
190        headers.insert("grpc-status", self.code.as_u8().to_string());
191        if !self.message.is_empty() {
192            headers.insert("grpc-message", percent_encode(&self.message));
193        }
194        self.metadata.write_into(headers);
195    }
196
197    /// Read a Status from response trailers (or trailer-only response headers).
198    /// Returns `Ok(())` for `grpc-status: 0`, `Err(Status)` otherwise.
199    /// Missing `grpc-status` is treated as `Unknown` per spec. On the Err
200    /// path the returned `Status` carries any custom trailing metadata
201    /// extracted from the same headers.
202    pub fn from_trailers(headers: &Headers) -> Result<(), Self> {
203        let code = headers
204            .get_str("grpc-status")
205            .and_then(|s| s.parse::<u8>().ok())
206            .and_then(Code::from_u8)
207            .unwrap_or(Code::Unknown);
208
209        if matches!(code, Code::Ok) {
210            return Ok(());
211        }
212
213        let message = headers
214            .get_str("grpc-message")
215            .map(percent_decode)
216            .unwrap_or_default();
217
218        Err(Self {
219            code,
220            message,
221            metadata: Metadata::from_headers(headers),
222        })
223    }
224}
225
226impl std::fmt::Display for Status {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        write!(f, "{:?}: {}", self.code, self.message)
229    }
230}
231
232impl std::error::Error for Status {}
233
234/// Percent-encode a `grpc-message` per the gRPC HTTP/2 spec.
235///
236/// Bytes 0x20–0x7E except `%` (0x25) pass through literally; everything else
237/// (control chars, non-ASCII UTF-8 continuation bytes, and `%` itself) becomes
238/// `%XX` with uppercase hex.
239fn percent_encode(s: &str) -> String {
240    let bytes = s.as_bytes();
241    let mut out = String::with_capacity(bytes.len());
242    for &b in bytes {
243        if (0x20..=0x7E).contains(&b) && b != b'%' {
244            out.push(b as char);
245        } else {
246            out.push('%');
247            out.push(hex_nibble(b >> 4));
248            out.push(hex_nibble(b & 0x0F));
249        }
250    }
251    out
252}
253
254/// Percent-decode a `grpc-message`. Invalid `%XX` sequences are passed through
255/// literally per spec ("non-spec-compliant messages should be returned without
256/// modification").
257fn percent_decode(s: &str) -> String {
258    let bytes = s.as_bytes();
259    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
260    let mut i = 0;
261    while i < bytes.len() {
262        if bytes[i] == b'%'
263            && i + 2 < bytes.len()
264            && let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
265        {
266            out.push((hi << 4) | lo);
267            i += 3;
268            continue;
269        }
270        out.push(bytes[i]);
271        i += 1;
272    }
273    String::from_utf8(out).unwrap_or_else(|e| {
274        // bytes that don't form valid UTF-8: fall back to lossy
275        String::from_utf8_lossy(&e.into_bytes()).into_owned()
276    })
277}
278
279fn hex_nibble(n: u8) -> char {
280    match n {
281        0..=9 => (b'0' + n) as char,
282        10..=15 => (b'A' + n - 10) as char,
283        _ => unreachable!(),
284    }
285}
286
287fn hex_value(b: u8) -> Option<u8> {
288    match b {
289        b'0'..=b'9' => Some(b - b'0'),
290        b'a'..=b'f' => Some(b - b'a' + 10),
291        b'A'..=b'F' => Some(b - b'A' + 10),
292        _ => None,
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn code_roundtrip() {
302        for n in 0u8..=16 {
303            let code = Code::from_u8(n).unwrap();
304            assert_eq!(code.as_u8(), n);
305        }
306        assert!(Code::from_u8(17).is_none());
307        assert!(Code::from_u8(255).is_none());
308    }
309
310    #[test]
311    fn status_into_from_trailers_ok() {
312        let trailers = Status::ok().into_trailers();
313        assert_eq!(trailers.get_str("grpc-status"), Some("0"));
314        assert_eq!(trailers.get_str("grpc-message"), None);
315        Status::from_trailers(&trailers).unwrap();
316    }
317
318    #[test]
319    fn status_into_from_trailers_err() {
320        let original = Status::not_found("user 42 missing");
321        let trailers = original.clone().into_trailers();
322        assert_eq!(trailers.get_str("grpc-status"), Some("5"));
323        assert_eq!(trailers.get_str("grpc-message"), Some("user 42 missing"));
324
325        let parsed = Status::from_trailers(&trailers).unwrap_err();
326        assert_eq!(parsed.code, original.code);
327        assert_eq!(parsed.message, original.message);
328    }
329
330    #[test]
331    fn missing_grpc_status_is_unknown() {
332        let headers = Headers::new();
333        let err = Status::from_trailers(&headers).unwrap_err();
334        assert_eq!(err.code, Code::Unknown);
335        assert!(err.message.is_empty());
336    }
337
338    #[test]
339    fn unknown_grpc_status_value_is_unknown() {
340        let mut headers = Headers::new();
341        headers.insert("grpc-status", "999");
342        let err = Status::from_trailers(&headers).unwrap_err();
343        assert_eq!(err.code, Code::Unknown);
344    }
345
346    #[test]
347    fn percent_encode_roundtrip() {
348        let cases = [
349            ("hello", "hello"),
350            ("hello world", "hello world"), // space is 0x20, allowed literal
351            ("100%", "100%25"),
352            ("\n\r\t", "%0A%0D%09"),
353            ("café", "caf%C3%A9"),
354            ("emoji: 🎉", "emoji: %F0%9F%8E%89"),
355        ];
356        for (raw, encoded) in cases {
357            assert_eq!(percent_encode(raw), encoded, "encoding {raw:?}");
358            assert_eq!(percent_decode(encoded), raw, "decoding {encoded:?}");
359        }
360    }
361
362    #[test]
363    fn percent_decode_passes_through_invalid_sequences() {
364        // Per spec: malformed %-escapes left as-is rather than erroring.
365        assert_eq!(percent_decode("100%"), "100%");
366        assert_eq!(percent_decode("100%2"), "100%2");
367        assert_eq!(percent_decode("100%ZZ"), "100%ZZ");
368    }
369
370    #[test]
371    fn message_omitted_when_empty() {
372        let trailers = Status::cancelled("").into_trailers();
373        assert_eq!(trailers.get_str("grpc-status"), Some("1"));
374        assert_eq!(trailers.get_str("grpc-message"), None);
375    }
376
377    #[test]
378    fn status_round_trip_preserves_metadata() {
379        let mut metadata = Metadata::new();
380        metadata.insert_ascii("retry-after", "30").unwrap();
381        metadata
382            .insert_binary("debug-bin", vec![0xDE, 0xAD])
383            .unwrap();
384
385        let original = Status::resource_exhausted("slow down").with_metadata(metadata);
386        let trailers = original.clone().into_trailers();
387
388        let parsed = Status::from_trailers(&trailers).unwrap_err();
389        assert_eq!(parsed.code, original.code);
390        assert_eq!(parsed.message, original.message);
391        assert_eq!(parsed.metadata.get_ascii("retry-after"), Some("30"));
392        assert_eq!(
393            parsed.metadata.get_binary("debug-bin"),
394            Some(&[0xDE, 0xAD][..]),
395        );
396    }
397}