protocol_droid/adapters/secondary/transport/http/error.rs
1//! Every way a Streamable HTTP client call can fail, as one enum.
2//!
3//! The whole point of this file is that no `reqwest` type crosses the module
4//! boundary. A caller above this layer matches on [`HttpClientError`] and never
5//! learns that the transport happens to be `reqwest` today — the same reason
6//! the domain never names `tokio`.
7//!
8//! Each variant is a *decision* the caller could act on, not a stack frame:
9//! "the peer said no, and here is what it said" ([`HttpClientError::Refused`])
10//! is a different thing to handle from "the socket died" ([`HttpClientError::Io`])
11//! and from "the peer said something I cannot read"
12//! ([`HttpClientError::MediaType`]).
13
14use crate::adapters::primary::transport::http::SseError;
15use crate::domain::McpSessionIdError;
16
17/// How many characters of a refused response body to keep for the error.
18///
19/// A refusal body from this protocol is a JSON-RPC error object and fits. A
20/// peer is not obliged to send one, and an error enum holding an unbounded
21/// `String` turns a hostile response into a memory cost. Truncating says so
22/// rather than implying the message is the whole truth.
23const REFUSAL_PREVIEW: usize = 256;
24
25/// Why a Streamable HTTP call did not produce a response the caller can use.
26///
27/// # Examples
28///
29/// Every variant is distinguishable, which is the reason this transport maps
30/// into one enum instead of letting a `reqwest::Error` through:
31///
32/// ```
33/// use protocol_droid::adapters::secondary::transport::http::HttpClientError;
34///
35/// // A refusal and a dead socket are different problems for a retry policy:
36/// // one is the peer's answer, the other is a network worth waiting on. Both
37/// // are `Err`, and a caller that treats `Err` as "retry" cannot tell them
38/// // apart without matching on the variant.
39/// let error = HttpClientError::MediaType { got: "text/html".into() };
40/// assert!(matches!(error, HttpClientError::MediaType { .. }));
41/// assert!(!matches!(error, HttpClientError::Io { .. }));
42/// ```
43#[derive(Debug, thiserror::Error)]
44#[non_exhaustive]
45pub enum HttpClientError {
46 /// The request never reached the peer, or the response never came back.
47 ///
48 /// The only variant a caller should retry without thinking — and even then
49 /// only for an idempotent request. A `POST` that may have been received and
50 /// acted on before the socket failed is exactly the case the idempotency
51 /// key exists for, and retrying it blind is how a tool runs twice.
52 #[error("the {method} request to the peer could not be completed: {error}")]
53 Io {
54 /// The HTTP method that was in flight, so a log line names what was lost.
55 method: &'static str,
56 /// The transport's own account of the failure.
57 error: reqwest::Error,
58 },
59
60 /// The peer answered with a status outside 2xx.
61 #[error("the peer refused the {method} with {status}: {preview}")]
62 Refused {
63 /// The HTTP method that was refused.
64 method: &'static str,
65 /// The status the peer answered with.
66 status: reqwest::StatusCode,
67 /// The `Content-Type`, absent when the peer sent none.
68 content_type: Option<String>,
69 /// The first `REFUSAL_PREVIEW` characters of the body, decoded lossily.
70 preview: String,
71 },
72
73 /// The peer answered `2xx` with a body this client cannot read.
74 ///
75 /// Kept apart from [`HttpClientError::Refused`] on purpose. A `200` with
76 /// the wrong content type is the peer and the client disagreeing about what
77 /// a response *is*, and no retry fixes that — a policy that treated it as a
78 /// transport blip would loop against a server that answers identically
79 /// every time.
80 #[error("the peer answered 2xx with {got:?}, which is neither JSON nor an event stream")]
81 MediaType {
82 /// The `Content-Type` the peer answered with, verbatim.
83 got: String,
84 },
85
86 /// The peer answered `2xx` with no `Content-Type` at all.
87 #[error("the peer answered 2xx with no content type")]
88 MissingMediaType,
89
90 /// The peer answered `2xx` with a body that is not a JSON object.
91 ///
92 /// Its own variant, not [`HttpClientError::MediaType`]: that one means "not
93 /// JSON *or* not an event stream" and leaves the caller to pick which. Here
94 /// the peer committed to JSON and then sent something that is not a
95 /// JSON-RPC message, which is a different fault with a different fix.
96 #[error("the peer answered 2xx with a JSON body that is not an object: {got:?}")]
97 NotAMessage {
98 /// What came back, truncated to something loggable.
99 got: String,
100 },
101 /// The peer's stream closed without the answer this request was waiting on.
102 ///
103 /// Its own variant because a stream that ends is a different fault from one
104 /// that never began: the first means the peer accepted the request and
105 /// stopped talking, and a bridge reporting it as a transport error tells
106 /// the client something true about a request the server did receive.
107 #[error("the {method} stream closed before the answer arrived")]
108 Unanswered {
109 /// The request that was left unanswered.
110 method: &'static str,
111 },
112 /// The server demanded authorization and the client could not provide it.
113 ///
114 /// Carries the authorizer's own account, which never contains the token.
115 #[error("authorization failed: {0}")]
116 Authorization(String),
117 /// A body could not be encoded for sending, or a reply body could not be
118 /// read back as JSON.
119 ///
120 /// Separate from [`HttpClientError::Io`] because that one carries a
121 /// `reqwest::Error` and means the request never reached the peer. This is
122 /// the opposite: the bytes arrived and could not be understood. Folding
123 /// both into `Io` would report a marshalling fault as a network fault.
124 #[error("the {method} body could not be {what}: {detail}")]
125 Body {
126 /// The HTTP method that was in flight.
127 method: &'static str,
128 /// What was being attempted, in a form that reads as a verb.
129 what: &'static str,
130 /// The decoder's own account.
131 detail: String,
132 },
133 /// The event stream produced bytes that are not a valid SSE frame.
134 #[error("the event stream produced a frame this client cannot read: {source}")]
135 Frame {
136 /// The frame decoder's account of what was wrong.
137 source: SseError,
138 },
139
140 /// The event stream ended, or the socket broke, mid-frame.
141 ///
142 /// Not terminal on its own: a resumable read treats this as "reconnect
143 /// from the last delivered id" and only surfaces it once the reconnect
144 /// budget is gone. See [`HttpClientError::ResumeExhausted`].
145 #[error("the event stream ended after {delivered} delivered event(s)")]
146 StreamEnded {
147 /// How many events the reader had actually delivered. This is the
148 /// number that decides what a resume should ask for, and getting it
149 /// wrong is how a resume either repeats an event or skips one.
150 delivered: u64,
151 },
152
153 /// A resumable read ran out of reconnects.
154 #[error(
155 "the stream was not completed after {attempts} reconnect attempt(s), having delivered {delivered}; last failure: {source}"
156 )]
157 ResumeExhausted {
158 /// How many reconnects were attempted.
159 attempts: u32,
160 /// Events delivered before the budget ran out.
161 delivered: u64,
162 /// The failure that ended the last attempt.
163 source: Box<Self>,
164 },
165
166 /// A call that requires a session was made before one had been issued.
167 #[error("this call needs a session id and the peer has not issued one yet")]
168 NoSessionYet,
169
170 /// The peer sent a `Mcp-Session-Id` this client cannot parse.
171 ///
172 /// Refused rather than ignored: a client that silently drops an
173 /// unparseable session id and carries on sends its next request with no
174 /// session header, and the resulting failure points at the session layer
175 /// instead of at the header that was actually wrong.
176 #[error("the peer sent an Mcp-Session-Id that is not a session id: {source}")]
177 SessionId {
178 /// What was wrong with it.
179 source: McpSessionIdError,
180 },
181
182 /// The endpoint is not a URL a request can be built against.
183 #[error("the endpoint {got:?} is not a usable URL: {reason}")]
184 Url {
185 /// The endpoint as given.
186 got: String,
187 /// The URL parser's account, rendered.
188 reason: String,
189 },
190}
191
192impl HttpClientError {
193 /// Whether retrying the identical request could plausibly succeed.
194 ///
195 /// A convenience, not a policy: it says what the *error* implies, and the
196 /// caller's idempotency story still decides whether a retry is safe. A
197 /// `POST` that may already have run is `false` even when the socket died,
198 /// because the honest answer there is "ask the idempotency store", not
199 /// "send it again".
200 #[must_use]
201 pub const fn is_transient(&self) -> bool {
202 matches!(self, Self::Io { .. } | Self::StreamEnded { .. })
203 }
204
205 /// The status the peer answered with, when there was one.
206 ///
207 /// `None` for every variant where the request never got an answer, which is
208 /// the distinction a caller's logging actually needs.
209 #[must_use]
210 pub const fn status(&self) -> Option<reqwest::StatusCode> {
211 match self {
212 Self::Refused { status, .. } => Some(*status),
213 _ => None,
214 }
215 }
216}
217
218/// A body preview that is bounded, lossy, and never panics on invalid UTF-8.
219///
220/// Bounded on characters rather than bytes so a multi-byte sequence at the cut
221/// cannot split into a replacement character per byte and report a length
222/// several times the limit.
223pub(super) fn preview(bytes: &[u8]) -> String {
224 let shown = bytes.get(..REFUSAL_PREVIEW).unwrap_or(bytes);
225 String::from_utf8_lossy(shown)
226 .chars()
227 .take(REFUSAL_PREVIEW)
228 .collect()
229}