1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
//! Typed error hierarchy for the SDK.
//!
//! Every fallible boundary returns `Result<T>` aliased to this crate's
//! `Error`. Wrapping inner errors via `#[from]` keeps call sites terse
//! (`foo()?`) while preserving the underlying source for `tracing` and
//! `std::error::Error::source` walks.
use std::time::Duration;
use thiserror::Error;
/// All errors the SDK can produce.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
/// OS-level I/O error.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// JSON serialization/deserialization error.
#[error("json: {0}")]
Json(#[from] serde_json::Error),
/// HTTP transport error.
#[deprecated(
since = "0.69.0",
note = "never constructed by the SDK; use Error::http_status (non-2xx with a \
real status) or Error::transport (request/stream transport failure)"
)]
#[error("http: {0}")]
Http(String),
/// A network/transport failure reaching a backend: the request POST
/// failed, or a mid-stream chunk read died. Like [`Error::HttpStatus`],
/// `Display` prints the message verbatim so surfaced text is byte-identical
/// to the legacy `Other` path. Constructed via [`Error::transport`].
#[error("{0}")]
Transport(String),
/// A payload failed to decode (a provider JSON body / SSE frame, restored
/// history bytes). `what` names the codec boundary; `Display` prints
/// `"{what}: {message}"` — byte-identical to the legacy `Other` strings.
/// Constructed via [`Error::decode`].
#[error("{what}: {message}")]
Decode {
/// The codec boundary that failed (e.g. `"gemini JSON"`).
what: String,
/// The decoder's error text (may embed the offending payload).
message: String,
},
/// An HTTP failure carrying its REAL status code (structured — consumers
/// read [`Error::http_status_code`] instead of substring-parsing "429"
/// out of the message). `message` is the full user-facing text; `Display`
/// prints it verbatim, so surfaced messages are identical to the legacy
/// string-only path. Constructed via [`Error::http_status`].
#[error("{message}")]
HttpStatus {
/// The HTTP status code (e.g. `429`).
status: u16,
/// The full formatted error message (what the user sees).
message: String,
},
/// The connection was closed unexpectedly.
#[error("connection closed")]
Closed,
/// Operation requires a started agent.
#[error("not started")]
NotStarted,
/// `start()` was called more than once.
#[error("already started")]
AlreadyStarted,
/// Invalid configuration.
#[error("config: {0}")]
Config(String),
/// No tool registered under this name.
#[error("tool '{name}' not found")]
ToolNotFound {
/// The requested tool name.
name: String,
},
/// A tool returned an error during execution.
#[error("tool '{name}' failed: {message}")]
ToolFailed {
/// The tool that failed.
name: String,
/// The error message from the tool.
message: String,
},
/// A policy blocked the operation.
#[error("policy denied: {0}")]
PolicyDenied(String),
/// An operation exceeded its deadline.
#[error("timed out after {0:?}")]
Timeout(Duration),
/// Catch-all for errors that don't fit other variants.
#[error("{0}")]
Other(String),
}
impl Error {
/// Construct a catch-all `Other` error.
pub fn other(msg: impl Into<String>) -> Self {
Self::Other(msg.into())
}
/// Construct a configuration error.
pub fn config(msg: impl Into<String>) -> Self {
Self::Config(msg.into())
}
/// Construct a transport error (request send / chunk read died). `msg` is
/// surfaced verbatim.
pub fn transport(msg: impl Into<String>) -> Self {
Self::Transport(msg.into())
}
/// Construct a decode error: `what` names the codec boundary, `message`
/// the decoder's error text. Surfaces as `"{what}: {message}"`.
pub fn decode(what: impl Into<String>, message: impl Into<String>) -> Self {
Self::Decode { what: what.into(), message: message.into() }
}
/// Construct a structured HTTP error: `msg` is what users see (unchanged
/// from the legacy string path); `status` rides alongside so
/// [`Error::code`] classifies off the real number instead of substring
/// matching. Backends route their non-2xx responses here.
pub fn http_status(status: u16, msg: impl Into<String>) -> Self {
Self::HttpStatus { status, message: msg.into() }
}
/// The HTTP status code, when this error carries one structurally
/// (i.e. it is [`Error::HttpStatus`]). Legacy string-wrapping variants
/// return `None` — they only ever knew the status as prose.
pub fn http_status_code(&self) -> Option<u16> {
match self {
Error::HttpStatus { status, .. } => Some(*status),
_ => None,
}
}
/// The stable `LHxxxx` code for this error (see [`crate::error_codes`]).
/// [`Error::HttpStatus`] classifies STRUCTURALLY off its real status code
/// ([`crate::error_codes::classify_http`]); the legacy string-wrapping
/// variants (`Http`/`ToolFailed`/`Other`) fall back to substring parsing
/// via [`crate::error_codes::classify`] so an upstream "429"/"401"/… body
/// resolves to the right `LH3xxx` backend code; [`Error::Transport`] /
/// [`Error::Decode`] classify the same way but fall back to
/// `BACKEND_NETWORK` / `CORE_DECODE`; every other variant maps to its own
/// `LH4xxx` core code. Always resolves to a registered code.
pub fn code(&self) -> u16 {
use crate::error_codes as ec;
match self {
Error::Io(_) => ec::CORE_IO,
Error::Json(_) => ec::CORE_JSON,
#[allow(deprecated)]
Error::Http(s) => ec::classify(s).unwrap_or(ec::CORE_HTTP),
// Transport failures classify off the message (a "429" body / bare
// "error sending request" keeps its precise LH3xxx class) and fall
// back to the network class — an unmatched transport failure IS a
// network failure, so the #29 stream-open retry treats it as
// transient instead of failing fast on CORE_OTHER.
Error::Transport(s) => ec::classify(s).unwrap_or(ec::BACKEND_NETWORK),
Error::Decode { message, .. } => ec::classify(message).unwrap_or(ec::CORE_DECODE),
Error::HttpStatus { status, message } => {
ec::classify_http(*status, message).unwrap_or(ec::CORE_HTTP)
}
Error::Closed => ec::CORE_CLOSED,
Error::NotStarted => ec::CORE_NOT_STARTED,
Error::AlreadyStarted => ec::CORE_ALREADY_STARTED,
Error::Config(_) => ec::CORE_CONFIG,
Error::ToolNotFound { .. } => ec::CORE_TOOL_NOT_FOUND,
Error::ToolFailed { message, .. } => {
ec::classify(message).unwrap_or(ec::CORE_TOOL_FAILED)
}
Error::PolicyDenied(_) => ec::CORE_POLICY_DENIED,
Error::Timeout(_) => ec::CORE_TIMEOUT,
Error::Other(s) => ec::classify(s).unwrap_or(ec::CORE_OTHER),
}
}
/// The canonical `LHxxxx` label for this error, e.g. `"LH4009"`.
pub fn code_label(&self) -> String {
crate::error_codes::fmt_label(self.code())
}
}
/// Convenience alias for `std::result::Result<T, localharness::Error>`.
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(deprecated)]
fn every_variant_maps_to_a_registered_code() {
let variants = [
Error::Io(std::io::Error::other("x")),
Error::Json(serde_json::from_str::<i32>("nope").unwrap_err()),
Error::Http("boom".into()),
Error::http_status(500, "boom"),
Error::transport("boom"),
Error::decode("gemini JSON", "boom"),
Error::Closed,
Error::NotStarted,
Error::AlreadyStarted,
Error::Config("c".into()),
Error::ToolNotFound { name: "t".into() },
Error::ToolFailed { name: "t".into(), message: "m".into() },
Error::PolicyDenied("p".into()),
Error::Timeout(Duration::from_secs(1)),
Error::other("o"),
];
for e in &variants {
assert!(
crate::error_codes::lookup(e.code()).is_some(),
"{:?} -> {} is not in the registry",
e,
e.code_label()
);
}
}
#[test]
#[allow(deprecated)]
fn string_wrapping_variants_classify_to_backend_codes() {
use crate::error_codes as ec;
assert_eq!(Error::Http("HTTP 429 too many requests".into()).code(), ec::BACKEND_RATE_LIMIT);
assert_eq!(Error::other("402 no $LH").code(), ec::BACKEND_CREDITS);
// An unmatched body falls back to the variant's own core code.
assert_eq!(Error::Http("plain".into()).code(), ec::CORE_HTTP);
assert_eq!(Error::other("plain").code(), ec::CORE_OTHER);
}
#[test]
fn http_status_classifies_off_the_real_status() {
use crate::error_codes as ec;
// The status FIELD decides — no "429" substring needed in the body.
assert_eq!(Error::http_status(429, "gemini HTTP <opaque>").code(), ec::BACKEND_RATE_LIMIT);
assert_eq!(Error::http_status(401, "nope").code(), ec::BACKEND_AUTH);
assert_eq!(Error::http_status(402, "x").code(), ec::BACKEND_CREDITS);
assert_eq!(Error::http_status(503, "x").code(), ec::BACKEND_SERVER);
// A stale-device-clock body overrides the 401 (must NOT read as bad key).
assert_eq!(
Error::http_status(401, "stale or future timestamp").code(),
ec::BACKEND_STALE_AUTH
);
// Unmapped status → body string classification → core fallback.
assert_eq!(Error::http_status(400, "API key not valid").code(), ec::BACKEND_AUTH);
assert_eq!(Error::http_status(418, "teapot").code(), ec::CORE_HTTP);
}
/// The typed transport/decode variants (slice A of the Error migration):
/// `Display` stays byte-identical to the legacy `Error::other` strings,
/// classification keeps the precise LH3xxx class when the message carries
/// one, and the fallbacks are BACKEND_NETWORK (transport IS a network
/// failure — retryable) / CORE_DECODE.
#[test]
fn transport_and_decode_display_and_classify() {
use crate::error_codes as ec;
let t = Error::transport("gemini POST: error sending request");
assert_eq!(t.to_string(), "gemini POST: error sending request");
assert_eq!(t.code(), ec::BACKEND_SEND); // #41 retry-once class preserved
assert_eq!(Error::transport("gemini chunk read: <opaque>").code(), ec::BACKEND_NETWORK);
assert_eq!(Error::transport("x").http_status_code(), None);
let d = Error::decode("gemini JSON", "expected value at line 1");
assert_eq!(d.to_string(), "gemini JSON: expected value at line 1");
assert_eq!(d.code(), ec::CORE_DECODE);
// A payload echo that names a backend cause keeps its LH3xxx class.
assert_eq!(
Error::decode("gemini sse decode", "eof; payload: exceeded your quota").code(),
ec::BACKEND_RATE_LIMIT
);
}
#[test]
#[allow(deprecated)]
fn http_status_display_and_accessor() {
let e = Error::http_status(429, "gemini HTTP 429 Too Many Requests: quota");
// Display prints the message VERBATIM — byte-identical to the legacy
// `Error::other(...)` surface these errors used to ride.
assert_eq!(e.to_string(), "gemini HTTP 429 Too Many Requests: quota");
assert_eq!(e.http_status_code(), Some(429));
assert_eq!(Error::other("x").http_status_code(), None);
assert_eq!(Error::Http("x".into()).http_status_code(), None);
}
}