Skip to main content

harness_gateway_client/wire/
classify.rs

1//! The HTTP failure classifier: the one place that reads a status code and
2//! response text and turns them into a [`CompletionErrorKind`].
3//!
4//! [`classify_http_failure`] takes a non-success status and its bounded,
5//! control-escaped body. [`classify_stream_error`] applies the same body-text
6//! rules to an error envelope that arrives inside a 200 stream, where there
7//! is no status. Both keep the body as the error's opt-in
8//! [`detail`](CompletionError::detail) and never put it in the message.
9//!
10//! The rules run in this order, and the first match wins:
11//!
12//! 1. A 400 or 413 whose body names a context limit is `ContextOverflow`.
13//! 2. A 429 whose body names quota or billing is `QuotaExhausted`; every
14//!    other 429 is `RateLimited`.
15//! 3. A 503 or 529, or any 5xx whose body names `overloaded`, is
16//!    `Overloaded`; every other 5xx is `ServerError`.
17//! 4. A 401 or 403 is `Unavailable`.
18//! 5. A 400 whose body names a content policy term is `Refused`.
19//! 6. Every other status is `Rejected`.
20
21use promptforge::model::{CompletionError, CompletionErrorKind};
22
23/// Body phrases the known backends emit when a request exceeds the model's
24/// context window (OpenAI and compatible gateways, Anthropic, llama.cpp,
25/// vLLM), matched case-insensitively.
26const OVERFLOW_PHRASES: &[&str] = &[
27    "context length",
28    "context window",
29    "context size",
30    "context_length_exceeded",
31    "maximum context length",
32    "prompt is too long",
33    "too many tokens",
34    "exceeds the available context size",
35    "exceed_context_size",
36    "input is too long",
37    "exceeds the maximum number of tokens",
38    "too large for model",
39];
40
41/// Body words that mark a 429 as a spent quota rather than a rate limit.
42const QUOTA_WORDS: &[&str] = &["quota", "billing", "insufficient_quota", "credit"];
43
44/// Body words that mark a refusal on content policy grounds.
45const REFUSAL_WORDS: &[&str] = &["content_filter", "content policy", "safety", "refus"];
46
47/// The message for a 401 or 403, which says more than the generic
48/// unavailable phrase.
49const CREDENTIALS_PHRASE: &str = "the model backend did not accept the credentials";
50
51/// Classifies a failed HTTP response into a [`CompletionError`].
52///
53/// `body` must already be bounded and passed through
54/// [`escape_controls`](crate::escape_controls). The error keeps exactly that
55/// text as its [`detail`](CompletionError::detail). The message never
56/// includes it.
57///
58/// The message is the kind's fixed phrase with ` (status N)` appended. A 401
59/// or 403 is `Unavailable`, and its message uses
60/// `the model backend did not accept the credentials` in place of the
61/// phrase.
62///
63/// The result is always an error. When the body matches a rule, that rule
64/// sets the kind. Otherwise a 429 is `RateLimited`, a 503 or 529 is
65/// `Overloaded`, any other 5xx is `ServerError`, and any other status except
66/// 401 and 403 is `Rejected`.
67#[must_use]
68pub fn classify_http_failure(status: u16, body: &str) -> CompletionError {
69    let lower = body.to_lowercase();
70    let http = |kind: CompletionErrorKind| {
71        http_error(kind, kind.phrase(), status).with_detail(body.to_owned())
72    };
73    if matches!(status, 400 | 413) && names_any(&lower, OVERFLOW_PHRASES) {
74        let (prompt_tokens, window) = overflow_counts(&lower);
75        return CompletionError::context_overflow(
76            prompt_tokens,
77            window,
78            format!(
79                "{} (status {status})",
80                CompletionErrorKind::ContextOverflow.phrase()
81            ),
82        )
83        .with_detail(body.to_owned());
84    }
85    match status {
86        429 if names_any(&lower, QUOTA_WORDS) => http(CompletionErrorKind::QuotaExhausted),
87        429 => http(CompletionErrorKind::RateLimited),
88        503 | 529 => http(CompletionErrorKind::Overloaded),
89        500..=599 if lower.contains("overloaded") => http(CompletionErrorKind::Overloaded),
90        500..=599 => http(CompletionErrorKind::ServerError),
91        401 | 403 => http_error(CompletionErrorKind::Unavailable, CREDENTIALS_PHRASE, status)
92            .with_detail(body.to_owned()),
93        400 if names_any(&lower, REFUSAL_WORDS) => http(CompletionErrorKind::Refused),
94        _ => http(CompletionErrorKind::Rejected),
95    }
96}
97
98/// Classifies an error envelope that arrives inside a 200 response stream.
99///
100/// The envelope carries body text alone, so only the body-text rules apply,
101/// in this order. Text that names a context limit is `ContextOverflow`. Text
102/// that names quota or billing is `QuotaExhausted`. Text that contains
103/// `overloaded` is `Overloaded`. Text that names a content policy term is
104/// `Refused`. Any other text is `Transport`, because the stream died in
105/// flight.
106///
107/// The message is the kind's fixed phrase alone. `body` must already be
108/// bounded and control-escaped. The error keeps it as its
109/// [`detail`](CompletionError::detail).
110#[must_use]
111pub fn classify_stream_error(body: &str) -> CompletionError {
112    let lower = body.to_lowercase();
113    let error = if names_any(&lower, OVERFLOW_PHRASES) {
114        let (prompt_tokens, window) = overflow_counts(&lower);
115        CompletionError::context_overflow(
116            prompt_tokens,
117            window,
118            CompletionErrorKind::ContextOverflow.phrase(),
119        )
120    } else {
121        let kind = if names_any(&lower, QUOTA_WORDS) {
122            CompletionErrorKind::QuotaExhausted
123        } else if lower.contains("overloaded") {
124            CompletionErrorKind::Overloaded
125        } else if names_any(&lower, REFUSAL_WORDS) {
126            CompletionErrorKind::Refused
127        } else {
128            CompletionErrorKind::Transport
129        };
130        CompletionError::new(kind, kind.phrase())
131    };
132    error.with_detail(body.to_owned())
133}
134
135/// Builds an HTTP failure: `phrase` with the status appended.
136fn http_error(kind: CompletionErrorKind, phrase: &str, status: u16) -> CompletionError {
137    CompletionError::new(kind, format!("{phrase} (status {status})"))
138}
139
140fn names_any(lower: &str, words: &[&str]) -> bool {
141    words.iter().any(|word| lower.contains(word))
142}
143
144/// The prompt and window token counts a context-limit message states, each
145/// `None` when the text does not give it. `lower` is the lowercased body.
146///
147/// Two forms are read: "maximum context length is N tokens ... M tokens"
148/// (window N, prompt M), and "N tokens > M maximum" (prompt N, window M).
149fn overflow_counts(lower: &str) -> (Option<u32>, Option<u32>) {
150    if let Some(counts) = counts_from_maximum(lower) {
151        return counts;
152    }
153    counts_from_greater_than(lower).unwrap_or((None, None))
154}
155
156fn counts_from_maximum(lower: &str) -> Option<(Option<u32>, Option<u32>)> {
157    const LEAD: &str = "maximum context length is ";
158    let start = lower.find(LEAD)? + LEAD.len();
159    let (window, rest) = leading_number(&lower[start..])?;
160    Some((first_count_before_tokens(rest), window))
161}
162
163fn counts_from_greater_than(lower: &str) -> Option<(Option<u32>, Option<u32>)> {
164    const MID: &str = " tokens > ";
165    let at = lower.find(MID)?;
166    let before = &lower[..at];
167    let prompt = &before[before.trim_end_matches(|c: char| c.is_ascii_digit()).len()..];
168    if prompt.is_empty() {
169        return None;
170    }
171    let (window, rest) = leading_number(&lower[at + MID.len()..])?;
172    rest.starts_with(" maximum")
173        .then(|| (prompt.parse().ok(), window))
174}
175
176/// Splits a leading run of ASCII digits off `text`. The number is `None`
177/// when the run does not fit a `u32`; the whole function is `None` when
178/// `text` starts with no digit.
179fn leading_number(text: &str) -> Option<(Option<u32>, &str)> {
180    let end = text
181        .find(|c: char| !c.is_ascii_digit())
182        .unwrap_or(text.len());
183    if end == 0 {
184        return None;
185    }
186    Some((text[..end].parse().ok(), &text[end..]))
187}
188
189/// The first number in `text` that is directly followed by " tokens".
190fn first_count_before_tokens(text: &str) -> Option<u32> {
191    let mut rest = text;
192    while let Some(start) = rest.find(|c: char| c.is_ascii_digit()) {
193        let (number, after) = leading_number(&rest[start..])?;
194        if after.starts_with(" tokens") {
195            return number;
196        }
197        rest = after;
198    }
199    None
200}
201
202#[cfg(test)]
203#[path = "classify-tests.rs"]
204mod tests;