Skip to main content

protocol_droid/adapters/primary/transport/http/
refusal.rs

1//! Why a request was refused, and the status code and body it becomes.
2//!
3//! # Why the status code lives here and not in the handlers
4//!
5//! Because "which code" is the part of a transport that is easy to get subtly
6//! wrong and impossible to see. A handler that returns `Err` and lets a
7//! `IntoResponse` impl decide is a handler whose test has to go through HTTP
8//! to check a number, which is why a `#[test]` on a handler is usually a test
9//! of the mapping and not of the handler. Keeping the mapping in one exhaustive
10//! `match` means every refusal the adapter can produce is visible in one place,
11//! and adding a variant without deciding its code is a compile error rather
12//! than a `todo!()`.
13//!
14//! # The codes are not interchangeable, and the distinctions are the point
15//!
16//! - **400** for a request the server understood and could not accept: a
17//!   malformed `Mcp-Session-Id`, a missing one, an `MCP-Protocol-Version` that
18//!   disagrees with the session.
19//! - **404** for a session the server does not have. Distinct from 400 because
20//!   the client's fix is different: a 404 tells it to re-`initialize`, and a
21//!   400 tells it its header is wrong. The specification makes that distinction
22//!   explicit — "When a client receives HTTP 404 in response to a request
23//!   containing an `Mcp-Session-Id`, it MUST start a new session".
24//! - **405** for a method this endpoint does not offer here, which is the
25//!   specification's own answer for a GET on a server with no server-initiated
26//!   stream and for a DELETE on a server that will not terminate sessions.
27//! - **409** for a resume that cannot be honoured without losing an event.
28//!
29//! The 409 is this adapter's own choice; the specification does not name one,
30//! and it is the only code in this file that it does. It is a conflict between
31//! what the client asked for and what the server can still provide, which is
32//! what 409 is for, and the body names the gap rather than reporting a short
33//! read as a success.
34
35use core::num::NonZeroU64;
36
37use axum::http::{HeaderValue, StatusCode, header};
38use axum::response::{IntoResponse, Response};
39use serde_json::{Value, json};
40
41use super::JSON_MEDIA_TYPE;
42use crate::domain::EventId;
43use crate::domain::jsonrpc::WireId;
44
45/// A request the adapter refuses before or instead of answering it.
46///
47/// Not an `Error` in the `std::error::Error` sense for its own sake — it is
48/// one so it composes with `?` in a handler whose other failure is a genuine
49/// error, and so `thiserror` writes the `Display` text that goes in the body.
50#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
51pub enum Refusal {
52    /// The `Mcp-Session-Id` header was present but not a session id.
53    #[error("the Mcp-Session-Id header is not a well-formed session id")]
54    MalformedSessionId,
55
56    /// A stateful request arrived with no session id and was not `initialize`.
57    #[error("this request requires an Mcp-Session-Id header")]
58    MissingSession,
59
60    /// The session id named a session this server does not have.
61    #[error("no session is registered under that Mcp-Session-Id")]
62    UnknownSession,
63
64    /// The endpoint does not offer this method here.
65    #[error("this endpoint does not offer {method} for {reason}")]
66    MethodNotAllowed {
67        /// The HTTP method that was refused.
68        method: &'static str,
69        /// Why it is not offered, in the specification's own terms.
70        reason: &'static str,
71    },
72
73    /// `MCP-Protocol-Version` disagreed with the negotiated version, or named
74    /// something this build cannot parse.
75    #[error("the MCP-Protocol-Version header is not usable: {reason}")]
76    ProtocolHeader {
77        /// What the domain's `check_protocol_header` reported.
78        reason: String,
79    },
80
81    /// The body was larger than the configured ceiling.
82    #[error("the request body exceeds the configured limit")]
83    BodyTooLarge,
84
85    /// The request carried no credentials and the endpoint requires some.
86    #[error("this endpoint requires authentication")]
87    Unauthenticated,
88
89    /// The request was authenticated but lacks a required scope.
90    #[error("this endpoint requires a scope the caller does not hold")]
91    Forbidden,
92
93    /// The `Origin` header named an origin this server does not serve.
94    ///
95    /// The one refusal in this enum that is a **specification MUST** rather
96    /// than a JSON-RPC-shaped complaint:
97    ///
98    /// > Servers **MUST** validate the `Origin` header on all incoming
99    /// > connections to prevent DNS rebinding attacks
100    ///
101    /// Cited: <https://modelcontextprotocol.io/specification/2024-11-05/basic/transports>
102    ///
103    /// Carries nothing from the header. The value is attacker-chosen, and a
104    /// refusal that echoes it into a response body is reflecting untrusted
105    /// input back to whoever sent it — for no diagnostic gain, since the sender
106    /// knows exactly what they sent. The operator reads the refusal type; the
107    /// attacker learns nothing they did not send.
108    ///
109    /// Distinct from [`Forbidden`](Self::Forbidden), which is an
110    /// authorization decision about a *credential*. This is a decision about
111    /// where the request came from, made before the request is authenticated,
112    /// because the point is to refuse the connection rather than to reject an
113    /// identity.
114    #[error("this request's Origin is not one this server serves")]
115    ForeignOrigin,
116
117    /// The subject exceeded the configured request rate.
118    ///
119    /// OWASP API Security Top 10:2023, API4:2023 (Unrestricted Resource
120    /// Consumption). The one refusal here that answers with a *delay* rather
121    /// than a denial, which is why it carries `retry_after`: RFC 9110 §10.2.3
122    /// makes `Retry-After` a `429` SHOULD, and a client told only "429" is a
123    /// client that retries immediately and keeps the rate up.
124    ///
125    /// Distinct from [`Forbidden`](Self::Forbidden), which is about a
126    /// credential, and from [`ForeignOrigin`](Self::ForeignOrigin), which is
127    /// about where the request came from. This is about how often one subject
128    /// asked.
129    #[error("this subject exceeded its request rate; retry after {retry_after}s")]
130    RateLimited {
131        /// Whole seconds the caller should wait. Never zero: a `Retry-After: 0`
132        /// is a client told to retry now, which is the opposite of the point.
133        retry_after: NonZeroU64,
134    },
135
136    /// The handler did not answer inside the configured request deadline.
137    ///
138    /// `503` rather than `504`. A `504` names a gateway that timed out talking
139    /// to an upstream, and this server *is* the thing that timed out — it is
140    /// not forwarding to anything. OWASP API Security Top 10:2023, API4:2023.
141    #[error("this handler did not answer inside the configured request deadline")]
142    Timeout,
143
144    /// The body was not a JSON-RPC frame this build can decode.
145    #[error("the request body is not a decodable JSON-RPC frame: {reason}")]
146    MalformedBody {
147        /// What `decode` reported.
148        reason: String,
149    },
150
151    /// A `Last-Event-ID` names an event the ring has dropped.
152    ///
153    /// The whole point of the variant is the two watermarks. A client that
154    /// resumes from `requested` and is told `evicted_through` knows exactly
155    /// which of its events are gone and can re-issue them; a client that is
156    /// handed a short stream and no explanation believes it is current.
157    #[error("the stream dropped events before the requested cursor")]
158    StreamGap {
159        /// The cursor the client quoted.
160        requested: EventId,
161        /// The newest event the ring has dropped.
162        evicted_through: EventId,
163        /// The newest event the stream ever issued.
164        last: EventId,
165    },
166
167    /// A `Last-Event-ID` names an event the stream never issued.
168    #[error("the requested cursor is ahead of this stream")]
169    UnknownCursor {
170        /// The cursor the client quoted.
171        requested: EventId,
172        /// The newest event the stream ever issued.
173        last: EventId,
174    },
175
176    /// The `Last-Event-ID` header was present but is not a cursor this stream
177    /// can read.
178    ///
179    /// Separate from [`UnknownCursor`](Self::UnknownCursor) because the two
180    /// faults have different fixes and the client has to tell them apart to
181    /// make one: a cursor ahead of the stream is a well-formed id the server
182    /// cannot serve, while a cursor it cannot parse is a malformed header. A
183    /// server that answered a corrupt header by quietly starting the client
184    /// from the top would hand it a stream it believes it has already seen, so
185    /// this refuses rather than guessing — including for the empty string,
186    /// which is a client that quoted a cursor and quoted nothing.
187    #[error("the Last-Event-ID header is not a well-formed event cursor")]
188    MalformedCursor,
189
190    /// The protocol layer failed in a way the adapter does not model.
191    #[error("{0}")]
192    Handler(String),
193}
194
195impl Refusal {
196    /// The status this refusal is answered with.
197    #[must_use]
198    pub const fn status(&self) -> StatusCode {
199        match self {
200            // "Servers that require a session ID SHOULD respond to requests
201            // without an Mcp-Session-Id header (other than initialization) with
202            // HTTP 400 Bad Request." — transports, 2025-06-18.
203            //
204            // The citation covers the first three arms. `MalformedBody` is a
205            // 400 for the same reason without the quote, and `MalformedCursor`
206            // is this adapter's own 400 for the same reason again: a header
207            // the server understood and could not accept. A cursor is not a
208            // session id, and its own slug and code are what tell a client
209            // which header it got wrong.
210            Self::MalformedSessionId
211            | Self::MissingSession
212            | Self::ProtocolHeader { .. }
213            | Self::MalformedBody { .. }
214            | Self::MalformedCursor => StatusCode::BAD_REQUEST,
215
216            // "it MUST respond to requests containing that session ID with HTTP
217            // 404 Not Found" — same section.
218            Self::UnknownSession => StatusCode::NOT_FOUND,
219
220            Self::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED,
221
222            Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
223            Self::Unauthenticated => StatusCode::UNAUTHORIZED,
224
225            // 403 and not 401 for both arms, and the two are the same status
226            // for different reasons. `Forbidden` is an authorization decision
227            // about a credential. `ForeignOrigin` is a decision about where
228            // the request came from, made before the request is authenticated
229            // — and a `WWW-Authenticate` challenge on either would be advice
230            // to authenticate with something that cannot help, because
231            // nothing about the caller's identity changes a foreign origin.
232            Self::Forbidden | Self::ForeignOrigin => StatusCode::FORBIDDEN,
233            Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
234            Self::Timeout => StatusCode::SERVICE_UNAVAILABLE,
235
236            // Not the specification's number; see the module docs. Both
237            // resume refusals are a conflict between the client's cursor and
238            // the server's log, and both are reported with the watermarks that
239            // let the client act.
240            Self::StreamGap { .. } | Self::UnknownCursor { .. } => StatusCode::CONFLICT,
241
242            Self::Handler(_) => StatusCode::INTERNAL_SERVER_ERROR,
243        }
244    }
245
246    /// The JSON-RPC error code sent alongside the status.
247    ///
248    /// Separate from [`status`](Self::status) because the two answer different
249    /// questions and are allowed to disagree: the status is what a proxy, a
250    /// browser or `curl -I` sees, and the code is what a conforming MCP client
251    /// branches on. Collapsing them would force one compromise on both.
252    ///
253    /// Where the JSON-RPC specification reserves a code that fits, the
254    /// reserved code is used. The rest live in `-32000..=-32099`, the range
255    /// JSON-RPC 2.0 defines as "implementation-defined server errors" — which
256    /// is exactly what a transport-level refusal is: not a malformed JSON-RPC
257    /// message (nothing was wrong with the *message*), but a server declining
258    /// for a reason of its own. Each arm is a distinct code because a client
259    /// recovering from one of these has a different move for each.
260    #[must_use]
261    pub const fn code(&self) -> i64 {
262        match self {
263            // Reserved: the body was not a decodable JSON-RPC frame, which is
264            // precisely JSON-RPC's own "parse error".
265            Self::MalformedBody { .. } => -32700,
266
267            // Reserved: the request was understood and could not be accepted —
268            // a header that does not name a session, or a protocol version
269            // this server did not negotiate.
270            Self::MalformedSessionId | Self::MissingSession | Self::ProtocolHeader { .. } => -32600,
271
272            // Reserved: the method is not one this endpoint serves.
273            Self::MethodNotAllowed { .. } => -32601,
274
275            // Reserved: the protocol layer failed in a way this adapter does
276            // not model, so it has nothing more specific to say.
277            Self::Handler(_) => -32603,
278
279            // Implementation-defined, one per recovery.
280            Self::UnknownSession => -32001,
281            Self::BodyTooLarge => -32002,
282            Self::Unauthenticated => -32003,
283            Self::Forbidden => -32004,
284            Self::StreamGap { .. } => -32005,
285            Self::UnknownCursor { .. } => -32006,
286            Self::MalformedCursor => -32007,
287            Self::ForeignOrigin => -32008,
288            Self::RateLimited { .. } => -32009,
289            Self::Timeout => -32010,
290        }
291    }
292
293    /// The JSON body sent alongside the status.
294    ///
295    /// A well-formed JSON-RPC 2.0 error object, because this is a
296    /// machine-readable protocol and a client that receives a bare
297    /// `409 Conflict` cannot recover — nothing in it says what was lost. Three
298    /// members carry that:
299    ///
300    /// - `error.code` is the integer from [`code`](Self::code), which a
301    ///   client switches on.
302    /// - `error.message` is the human text, which is this type's `Display` —
303    ///   the same text the log line carries, so the two can never disagree.
304    /// - `error.data.error` is the stable slug from `slug`, the member a
305    ///   client branches on when it wants a name rather than a number, and
306    ///   where the resume watermarks live.
307    ///
308    /// `id` is always `null`. A refusal is frequently the answer to a request
309    /// whose body was never parsed, so there may be no id to echo; sending
310    /// `null` rather than omitting the member keeps the document valid
311    /// against the JSON-RPC schema, which requires the member on an error.
312    #[must_use]
313    pub fn body(&self) -> Value {
314        self.body_with_id(None)
315    }
316
317    /// The body, echoing the JSON-RPC id of the request that earned the refusal.
318    ///
319    /// SEP-2575 requires it and JSON-RPC has always allowed it: *"the id must
320    /// be Null if there was an error in detecting the id"*. A body this
321    /// adapter has already read is not that case — the id was detected, and
322    /// the refusal happened afterwards. Answering `id: null` for a request
323    /// that named `4` makes the client's own pending request unmatchable,
324    /// which is the one thing an error reply is supposed to prevent.
325    ///
326    /// `None` still means `null`, and deliberately so: a GET or a DELETE has
327    /// no JSON-RPC envelope to take an id from, and a body that would not
328    /// decode genuinely did not detect one.
329    pub fn body_with_id(&self, id: Option<&WireId<'_>>) -> Value {
330        let data = match self {
331            Self::StreamGap {
332                requested,
333                evicted_through,
334                last,
335            } => json!({
336                "error": "stream_gap",
337                "requested": requested.get(),
338                "evicted_through": evicted_through.get(),
339                "last": last.get(),
340            }),
341            Self::UnknownCursor { requested, last } => json!({
342                "error": "unknown_cursor",
343                "requested": requested.get(),
344                "last": last.get(),
345            }),
346            other => json!({ "error": slug(other) }),
347        };
348        let id = match id {
349            Some(WireId::Number(value)) => Value::from(*value),
350            Some(WireId::Text(text)) => Value::from(text.as_ref()),
351            None => Value::Null,
352        };
353        json!({
354            "jsonrpc": "2.0",
355            "id": id,
356            "error": {
357                "code": self.code(),
358                "message": self.to_string(),
359                "data": data,
360            },
361        })
362    }
363}
364
365/// A stable, lowercase, hyphenated identifier for a refusal variant.
366///
367/// Written out rather than derived, because the slug is part of the wire
368/// contract: a client branches on it, so renaming a Rust variant must not
369/// silently rename the string. Each arm is paired with the variant it covers
370/// so a new variant fails to compile here rather than falling through.
371const fn slug(refusal: &Refusal) -> &'static str {
372    match refusal {
373        Refusal::MalformedSessionId => "malformed_session_id",
374        Refusal::MissingSession => "missing_session",
375        Refusal::UnknownSession => "unknown_session",
376        Refusal::MethodNotAllowed { .. } => "method_not_allowed",
377        Refusal::ProtocolHeader { .. } => "protocol_header",
378        Refusal::BodyTooLarge => "body_too_large",
379        Refusal::Unauthenticated => "unauthenticated",
380        Refusal::Forbidden => "forbidden",
381        Refusal::MalformedBody { .. } => "malformed_body",
382        Refusal::StreamGap { .. } => "stream_gap",
383        Refusal::UnknownCursor { .. } => "unknown_cursor",
384        Refusal::MalformedCursor => "malformed_cursor",
385        Refusal::ForeignOrigin => "foreign_origin",
386        Refusal::RateLimited { .. } => "rate_limited",
387        Refusal::Timeout => "request_timeout",
388        Refusal::Handler(_) => "handler_error",
389    }
390}
391
392impl Refusal {
393    /// The response, echoing the JSON-RPC id of the refused request.
394    ///
395    /// Same rule as [`body_with_id`](Self::body_with_id): an id that has been
396    /// read off a body is carried into the answer, and an id that was never
397    /// detected is `null`.
398    pub fn into_response_with_id(self, id: Option<&WireId<'_>>) -> Response {
399        let status = self.status();
400        let body = serde_json::to_vec(&self.body_with_id(id)).unwrap_or_else(|_| Vec::new());
401        let mut response = Response::builder()
402            .status(status)
403            .header(header::CONTENT_TYPE, JSON_MEDIA_TYPE)
404            .body(axum::body::Body::from(body))
405            .unwrap_or_else(|_| {
406                // A response that cannot even be built means the status or a
407                // header name was not a valid HTTP value, which for the values
408                // this enum produces is not reachable. Building an empty 500
409                // rather than panicking keeps the invariant that a refusal
410                // never takes the process down.
411                let mut fallback = Response::new(axum::body::Body::empty());
412                *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
413                fallback
414            });
415        if let Self::MethodNotAllowed { method, .. } = &self
416            && let Ok(value) = HeaderValue::from_str(method)
417        {
418            response.headers_mut().insert(header::ALLOW, value);
419        }
420        // RFC 9110 §10.2.3. The seconds are already a `NonZeroU64`, so the
421        // header can never say `Retry-After: 0` — which a client would read as
422        // "come back immediately", turning the backoff into a hot loop.
423        if let Self::RateLimited { retry_after } = &self
424            && let Ok(value) = HeaderValue::from_str(&retry_after.get().to_string())
425        {
426            response.headers_mut().insert(header::RETRY_AFTER, value);
427        }
428        response
429    }
430}
431
432impl IntoResponse for Refusal {
433    /// Renders the refusal, with the security layer still applied on top.
434    ///
435    /// This impl exists so a handler can `?` a `Refusal` into a `Response`. It
436    /// sets only the status, the content type and — for the two refusals where
437    /// a header is meaningful — `Allow` and `Retry-After`; the security headers
438    /// come from the router layer, which is the only place that can guarantee
439    /// they are on *every* response including this one.
440    fn into_response(self) -> Response {
441        self.into_response_with_id(None)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use crate::domain::jsonrpc::WireId;
448
449    /// An id read off a body is echoed; no id stays `null`.
450    ///
451    /// Both halves, and the second is the half that is easy to lose: a GET and
452    /// a DELETE have no envelope, and a body that will not decode did not
453    /// detect one. JSON-RPC asks for `null` in exactly those cases, so the
454    /// single-argument `body()` — which every other caller uses — must keep
455    /// producing it.
456    #[test]
457    fn a_refusal_echoes_an_id_when_one_was_detected() {
458        let numeric = Refusal::MissingSession.body_with_id(Some(&WireId::Number(7)));
459        assert_eq!(
460            numeric.get("id"),
461            Some(&serde_json::json!(7)),
462            "a refusal to request 7 must answer 7, not null",
463        );
464
465        let textual = Refusal::MissingSession.body_with_id(Some(&WireId::text("r1")));
466        assert_eq!(
467            textual.get("id"),
468            Some(&serde_json::json!("r1")),
469            "a string id must stay a string",
470        );
471
472        assert_eq!(
473            Refusal::MissingSession.body().get("id"),
474            Some(&serde_json::Value::Null),
475            "with no id detected, the answer is null",
476        );
477    }
478
479    use assert_matches::assert_matches;
480    use axum::http::StatusCode;
481    use axum::response::IntoResponse;
482    use serde_json::Value;
483
484    use crate::domain::EventId;
485
486    use super::{Refusal, slug};
487
488    type TestError = Box<dyn std::error::Error>;
489
490    /// Every variant the adapter can refuse, paired with the status it is
491    /// answered with.
492    ///
493    /// Written out rather than derived, for the reason `slug` is: a new
494    /// variant that quietly inherited a neighbouring code would be a
495    /// client-visible regression, and a table makes that a compile error here.
496    /// Every case is iterated, never indexed, because the position in the table
497    /// is not part of what is being claimed.
498    #[test]
499    fn every_variant_maps_to_its_status() {
500        let cases = [
501            (Refusal::MalformedSessionId, StatusCode::BAD_REQUEST),
502            (Refusal::MissingSession, StatusCode::BAD_REQUEST),
503            (
504                Refusal::ProtocolHeader {
505                    reason: "unknown version".to_owned(),
506                },
507                StatusCode::BAD_REQUEST,
508            ),
509            (
510                Refusal::MalformedBody {
511                    reason: "not json".to_owned(),
512                },
513                StatusCode::BAD_REQUEST,
514            ),
515            (Refusal::UnknownSession, StatusCode::NOT_FOUND),
516            (
517                Refusal::MethodNotAllowed {
518                    method: "GET",
519                    reason: "no stream",
520                },
521                StatusCode::METHOD_NOT_ALLOWED,
522            ),
523            (Refusal::BodyTooLarge, StatusCode::PAYLOAD_TOO_LARGE),
524            (Refusal::Unauthenticated, StatusCode::UNAUTHORIZED),
525            (Refusal::Forbidden, StatusCode::FORBIDDEN),
526            (
527                Refusal::StreamGap {
528                    requested: EventId::new(1),
529                    evicted_through: EventId::new(1),
530                    last: EventId::new(1),
531                },
532                StatusCode::CONFLICT,
533            ),
534            (
535                Refusal::UnknownCursor {
536                    requested: EventId::new(1),
537                    last: EventId::new(1),
538                },
539                StatusCode::CONFLICT,
540            ),
541            (
542                Refusal::Handler("boom".to_owned()),
543                StatusCode::INTERNAL_SERVER_ERROR,
544            ),
545        ];
546        for (refusal, expected) in cases {
547            assert_eq!(refusal.status(), expected, "wrong status for {refusal:?}");
548        }
549    }
550
551    /// The body is a JSON-RPC 2.0 error object, not a bespoke shape.
552    ///
553    /// This is the claim the whole `body` contract rests on: an MCP client
554    /// parses refusals with its JSON-RPC error path, so a body that is not one
555    /// is a body the client cannot read. The assertion is structural — the
556    /// member exists, the code is an *integer*, and the message is a *string* —
557    /// because "the body is non-empty" would pass against any of the ways this
558    /// can go wrong.
559    #[test]
560    fn the_body_is_a_json_rpc_error_object() -> Result<(), TestError> {
561        for refusal in [
562            Refusal::MalformedSessionId,
563            Refusal::UnknownSession,
564            Refusal::Unauthenticated,
565            Refusal::Forbidden,
566            Refusal::BodyTooLarge,
567        ] {
568            let body = refusal.body();
569            assert_eq!(body.get("jsonrpc").and_then(Value::as_str), Some("2.0"));
570            let error = body
571                .get("error")
572                .ok_or("no error member")?
573                .as_object()
574                .ok_or("error is not an object")?;
575            // `as_u64` is the integer assertion: a code that had been written
576            // as a string, or as `null`, returns None here rather than passing.
577            let code = error
578                .get("code")
579                .and_then(Value::as_i64)
580                .ok_or("code is not an integer")?;
581            assert!(code < 0, "a server error code must be negative: {code}");
582            let message = error
583                .get("message")
584                .and_then(Value::as_str)
585                .ok_or("message is not a string")?;
586            assert!(!message.is_empty());
587            // `id` is a required member of a JSON-RPC response, and a refusal
588            // often has no id to echo, so the contract is that it is null.
589            assert!(body.get("id").is_some_and(Value::is_null));
590        }
591        Ok(())
592    }
593
594    /// Each variant has a distinct code, and the auth pair does not collide.
595    ///
596    /// A client recovers from a `401` by authenticating and from a `403` by
597    /// asking for more permission; sending the same code for both sends it
598    /// looking for a credential it already has. Codes are therefore compared
599    /// pairwise rather than merely being "present".
600    #[test]
601    fn every_variant_has_its_own_code() {
602        // The implementation-defined range is where distinctness is a
603        // requirement: each of these has a *different recovery*, so a client
604        // that cannot tell them apart retries the wrong thing. Pairwise
605        // distinctness is asserted, not merely presence.
606        let distinct = [
607            Refusal::UnknownSession,
608            Refusal::BodyTooLarge,
609            Refusal::Unauthenticated,
610            Refusal::Forbidden,
611            Refusal::StreamGap {
612                requested: EventId::new(1),
613                evicted_through: EventId::new(1),
614                last: EventId::new(1),
615            },
616            Refusal::UnknownCursor {
617                requested: EventId::new(1),
618                last: EventId::new(1),
619            },
620        ];
621        for (index, refusal) in distinct.iter().enumerate() {
622            for (other_index, other) in distinct.iter().enumerate() {
623                if index != other_index {
624                    assert_ne!(
625                        refusal.code(),
626                        other.code(),
627                        "{refusal:?} and {other:?} share a code"
628                    );
629                }
630            }
631        }
632
633        // The reserved range is deliberately *not* pairwise distinct. JSON-RPC
634        // says a malformed session id and a missing one are both "invalid
635        // request", and inventing distinct codes for them would misreport a
636        // standard. What keeps them apart on the wire is the slug, which
637        // `every_variant_has_a_stable_slug` covers.
638        assert_eq!(Refusal::MalformedSessionId.code(), -32600);
639        assert_eq!(Refusal::MissingSession.code(), -32600);
640        assert_ne!(
641            Refusal::MalformedSessionId.code(),
642            Refusal::MalformedBody {
643                reason: String::new()
644            }
645            .code()
646        );
647
648        // The auth pair specifically: a client recovers from one by
649        // authenticating and from the other by asking for more permission.
650        assert_ne!(Refusal::Unauthenticated.code(), Refusal::Forbidden.code());
651        assert_eq!(Refusal::Unauthenticated.status(), StatusCode::UNAUTHORIZED);
652        assert_eq!(Refusal::Forbidden.status(), StatusCode::FORBIDDEN);
653        assert_matches!(Refusal::Unauthenticated.to_string(), String { .. });
654    }
655
656    /// A malformed id and an unknown id are different faults with different
657    /// client remedies, and they must not collapse into one code.
658    ///
659    /// This is the distinction the specification makes explicitly — a 404
660    /// tells a client to re-`initialize`, a 400 tells it to fix its header — so
661    /// a test that only asserted "both are 4xx" would pass against an
662    /// implementation that had lost the point entirely.
663    #[test]
664    fn the_two_session_faults_are_distinguished() {
665        assert_eq!(
666            Refusal::MalformedSessionId.status(),
667            StatusCode::BAD_REQUEST,
668        );
669        assert_eq!(Refusal::UnknownSession.status(), StatusCode::NOT_FOUND);
670        assert_ne!(
671            Refusal::MalformedSessionId.code(),
672            Refusal::UnknownSession.code(),
673        );
674    }
675
676    /// The gap body names both watermarks.
677    ///
678    /// A client that receives a conflict with no numbers cannot tell which of
679    /// its events are missing, so it cannot re-request them — it can only give
680    /// up. This is the assertion that distinguishes "reported a gap" from
681    /// "refused to resume", and it is the reason the variant carries two ids
682    /// rather than one.
683    #[test]
684    fn a_gap_names_both_watermarks() -> Result<(), TestError> {
685        let refusal = Refusal::StreamGap {
686            requested: EventId::new(3),
687            evicted_through: EventId::new(9),
688            last: EventId::new(42),
689        };
690        let data = refusal
691            .body()
692            .get("error")
693            .and_then(|error| error.get("data"))
694            .cloned()
695            .ok_or("no data member")?;
696        assert_eq!(
697            data.get("error").and_then(Value::as_str),
698            Some("stream_gap")
699        );
700        assert_eq!(data.get("requested").and_then(Value::as_u64), Some(3));
701        assert_eq!(data.get("evicted_through").and_then(Value::as_u64), Some(9));
702        assert_eq!(data.get("last").and_then(Value::as_u64), Some(42));
703        Ok(())
704    }
705
706    /// A gap is a conflict, and an unknown cursor is a different one.
707    ///
708    /// Both are refusals and neither is a success, but a client that conflates
709    /// them either retries a cursor that will never become valid, or
710    /// re-requests events for a stream that never had them. The slugs keep
711    /// them apart on the wire.
712    #[test]
713    fn a_gap_and_an_unknown_cursor_are_different_faults() {
714        let gap = Refusal::StreamGap {
715            requested: EventId::new(3),
716            evicted_through: EventId::new(9),
717            last: EventId::new(42),
718        };
719        let unknown = Refusal::UnknownCursor {
720            requested: EventId::new(50),
721            last: EventId::new(42),
722        };
723        assert_eq!(gap.status(), StatusCode::CONFLICT);
724        assert_eq!(unknown.status(), StatusCode::CONFLICT);
725        assert_ne!(slug(&gap), slug(&unknown));
726        assert_ne!(gap.code(), unknown.code());
727        assert_eq!(
728            unknown
729                .body()
730                .get("error")
731                .and_then(|error| error.get("data"))
732                .and_then(|data| data.get("error"))
733                .and_then(Value::as_str),
734            Some("unknown_cursor"),
735        );
736    }
737
738    /// A `405` carries `Allow`, so a client can discover what is offered.
739    #[test]
740    fn a_refused_method_advertises_the_allowed_one() {
741        let response = Refusal::MethodNotAllowed {
742            method: "POST",
743            reason: "this session has no server-initiated stream",
744        }
745        .into_response();
746        assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
747        assert_eq!(
748            response
749                .headers()
750                .get(axum::http::header::ALLOW)
751                .and_then(|value| value.to_str().ok()),
752            Some("POST"),
753        );
754    }
755
756    /// Every variant has a slug, and none of them is empty.
757    ///
758    /// The slug is a wire contract, so a new variant that falls through to a
759    /// default would be a client-visible regression. The list is written out
760    /// rather than iterated so a new variant breaks this test.
761    #[test]
762    fn every_variant_has_a_stable_slug() {
763        let cases = [
764            (Refusal::MalformedSessionId, "malformed_session_id"),
765            (Refusal::MissingSession, "missing_session"),
766            (Refusal::UnknownSession, "unknown_session"),
767            (
768                Refusal::MethodNotAllowed {
769                    method: "GET",
770                    reason: "no stream",
771                },
772                "method_not_allowed",
773            ),
774            (
775                Refusal::ProtocolHeader {
776                    reason: "unknown".to_owned(),
777                },
778                "protocol_header",
779            ),
780            (Refusal::BodyTooLarge, "body_too_large"),
781            (Refusal::Unauthenticated, "unauthenticated"),
782            (Refusal::Forbidden, "forbidden"),
783            (
784                Refusal::MalformedBody {
785                    reason: "not json".to_owned(),
786                },
787                "malformed_body",
788            ),
789            (
790                Refusal::StreamGap {
791                    requested: EventId::new(1),
792                    evicted_through: EventId::new(1),
793                    last: EventId::new(1),
794                },
795                "stream_gap",
796            ),
797            (
798                Refusal::UnknownCursor {
799                    requested: EventId::new(1),
800                    last: EventId::new(1),
801                },
802                "unknown_cursor",
803            ),
804            (Refusal::Handler("boom".to_owned()), "handler_error"),
805        ];
806        for (refusal, expected) in cases {
807            assert_eq!(slug(&refusal), expected);
808            assert!(!expected.is_empty());
809            // The slug is a wire contract, so it must be reachable from the
810            // body as well as from `slug` — a slug that exists in Rust but not
811            // on the wire is a slug no client can branch on.
812            assert_eq!(
813                refusal
814                    .body()
815                    .get("error")
816                    .and_then(|error| error.get("data"))
817                    .and_then(|data| data.get("error"))
818                    .and_then(Value::as_str),
819                Some(expected),
820                "body slug disagrees for {expected}",
821            );
822        }
823    }
824
825    /// The `message` a client reads is the `Display` a log line carries.
826    ///
827    /// Two copies of this text is two that drift, and a client shown a message
828    /// the server never logged is a bug report nobody can reproduce.
829    #[test]
830    fn the_message_in_the_body_is_the_display_text() {
831        let refusal = Refusal::Forbidden;
832        let expected = refusal.to_string();
833        assert_eq!(
834            refusal
835                .body()
836                .get("error")
837                .and_then(|error| error.get("message"))
838                .and_then(Value::as_str),
839            Some(expected.as_str()),
840        );
841    }
842}