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}