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}