metrale_bench/http/failure.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Why a streamed chat request failed, as data a caller can classify.
4//!
5//! Owner: bench (HTTP client).
6//! Invariants:
7//! - `Display` prints `message` and nothing else.
8//! - A `body` set through `with_body` holds at most `BODY_EXCERPT` bytes and is
9//! cut on a char boundary.
10
11use std::fmt;
12
13/// 2026-09-25: The cap on the response bytes a failure carries.
14pub const BODY_EXCERPT: usize = 320;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum FailureKind {
18 /// 2026-09-25: The client's deadline passed before the stream ended.
19 Timeout,
20 /// 2026-09-25: The status line carried this non-200 code.
21 Status(u16),
22 /// 2026-09-25: Connecting, writing or reading the socket failed.
23 Transport,
24 /// 2026-09-25: The response could not be decoded: bad framing, a status
25 /// line without a code, or `data:` frames that are not JSON.
26 Malformed,
27 /// 2026-09-25: A 200 stream closed with no `[DONE]` and no finish_reason.
28 Truncated,
29 /// 2026-09-25: The server sent an in-band error frame under a 200.
30 ServerError,
31 /// 2026-09-25: A failure the client did not classify.
32 Other,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct RequestFailure {
37 pub kind: FailureKind,
38 pub message: String,
39 /// 2026-09-25: The finish_reason streamed before the failure, if any.
40 pub finish_reason: Option<String>,
41 /// 2026-09-25: What the server sent that explains the failure: the error
42 /// body, the error frame, or the first undecodable frame. Empty when
43 /// nothing arrived.
44 pub body: String,
45}
46
47impl RequestFailure {
48 pub fn new(kind: FailureKind, message: impl Into<String>) -> Self {
49 Self {
50 kind,
51 message: message.into(),
52 finish_reason: None,
53 body: String::new(),
54 }
55 }
56
57 pub fn with_body(mut self, bytes: &[u8]) -> Self {
58 self.body = excerpt(bytes);
59 self
60 }
61
62 pub fn with_finish_reason(mut self, finish_reason: Option<String>) -> Self {
63 self.finish_reason = finish_reason;
64 self
65 }
66}
67
68impl fmt::Display for RequestFailure {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.write_str(&self.message)
71 }
72}
73
74impl std::error::Error for RequestFailure {}
75
76/// 2026-09-25: At most `BODY_EXCERPT` bytes of `bytes`, lossily decoded,
77/// trimmed, and never split inside a character.
78pub fn excerpt(bytes: &[u8]) -> String {
79 let text = String::from_utf8_lossy(bytes);
80 let text = text.trim();
81 let mut end = text.len().min(BODY_EXCERPT);
82 while !text.is_char_boundary(end) {
83 end -= 1;
84 }
85 text[..end].to_string()
86}
87
88/// 2026-09-25: The code in a status line such as `HTTP/1.1 503 Service
89/// Unavailable`, or `Malformed` when it has none.
90pub fn status_kind(status_line: &str) -> FailureKind {
91 status_line
92 .split_whitespace()
93 .nth(1)
94 .and_then(|c| c.parse().ok())
95 .map_or(FailureKind::Malformed, FailureKind::Status)
96}
97
98/// 2026-09-25: The failure an error from `chat_stream` describes. A typed
99/// failure anywhere in the chain wins; an I/O error is `Transport`; anything
100/// else is `Other`. The message is always the whole chain, as `{:#}` prints it.
101pub fn classify(e: &anyhow::Error) -> RequestFailure {
102 let message = format!("{e:#}");
103 if let Some(f) = e.chain().find_map(|c| c.downcast_ref::<RequestFailure>()) {
104 return RequestFailure {
105 message,
106 ..f.clone()
107 };
108 }
109 let kind = if e.chain().any(|c| c.is::<std::io::Error>()) {
110 FailureKind::Transport
111 } else {
112 FailureKind::Other
113 };
114 RequestFailure::new(kind, message)
115}