netget/utils/wire_failure.rs
1//! What a network peer may be told when netget itself fails.
2//!
3//! **Rule: an internal error string never reaches the wire.** Not the LLM backend's error,
4//! not an `anyhow` context chain, not a serde or codec message. Those name the model, the
5//! backend URL, the request body, file paths and library internals; the peer is a stranger
6//! and none of it is theirs to read. It goes to the log and the TUI status stream, which is
7//! where an operator looks.
8//!
9//! This is not a hypothetical. Every protocol that answers its peer on backend failure used
10//! to interpolate the error into the reply, so a plain `telnet` session got
11//! `[netget] cannot answer right now: ✗ LLM failed to generate valid response after retries.`
12//! — netget's own retry machinery, verbatim, on a stranger's terminal.
13//!
14//! A peer still needs an answer: silence leaves it blocked until its own timeout, which is
15//! the failure mode CLAUDE.md calls out as worse than refusing. So the protocols keep
16//! answering — they just answer with a *category*, not a diagnosis.
17//!
18//! ## Why `&'static str`
19//!
20//! [`WireFailure::text`] returns `&'static str` on purpose. A function that took an error and
21//! returned `String` would be one `format!` away from leaking again, and that is exactly how
22//! the original bug spread: each protocol copied its neighbour. A `&'static str` cannot carry
23//! anything derived from the error, so the guarantee is in the type rather than in reviewer
24//! attention.
25//!
26//! The error is still passed in — but only to *classify* it, never to render it.
27
28/// How an internal failure is described to a network peer.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum WireFailure {
31 /// The backend refused because it is saturated. Transient: a retry may well succeed, and
32 /// protocols that have a distinct "try again" code (503, RESP `LOADING`, MySQL 1205,
33 /// gRPC `UNAVAILABLE`) should use it so a client backs off instead of recording a
34 /// permanent fault.
35 Overloaded,
36 /// Anything else — the backend erred, timed out, or answered with something unusable.
37 /// Not retryable as far as the peer is concerned.
38 Unavailable,
39}
40
41impl WireFailure {
42 /// Classify an error without rendering it.
43 pub fn classify(err: &anyhow::Error) -> Self {
44 if crate::llm::is_overload_error(err) {
45 Self::Overloaded
46 } else {
47 Self::Unavailable
48 }
49 }
50
51 /// The peer-visible text.
52 ///
53 /// Safe as free text in any protocol's single-line reply: ASCII, no CR or LF (which would
54 /// forge a second response in the line-oriented protocols), no leading `.` (which would
55 /// terminate a POP3/NNTP multiline block), and short enough for a fixed-width field.
56 pub fn text(self) -> &'static str {
57 match self {
58 Self::Overloaded => "backend at capacity, retry later",
59 Self::Unavailable => "request could not be processed",
60 }
61 }
62
63 /// The same text with netget's name in front, for protocols whose free-text field is
64 /// otherwise attributed to the origin server.
65 ///
66 /// Naming the software is not debug information — it is what an HTTP `Server:` header
67 /// does — and it tells an operator poking at their own server which process answered.
68 pub fn prefixed_text(self) -> &'static str {
69 match self {
70 Self::Overloaded => "netget: backend at capacity, retry later",
71 Self::Unavailable => "netget: request could not be processed",
72 }
73 }
74
75 pub fn is_overloaded(self) -> bool {
76 matches!(self, Self::Overloaded)
77 }
78}
79
80/// Shorthand for [`WireFailure::classify`] + [`WireFailure::text`].
81pub fn wire_failure_text(err: &anyhow::Error) -> &'static str {
82 WireFailure::classify(err).text()
83}
84
85/// Shorthand for [`WireFailure::classify`] + [`WireFailure::prefixed_text`].
86pub fn prefixed_wire_failure_text(err: &anyhow::Error) -> &'static str {
87 WireFailure::classify(err).prefixed_text()
88}