io_harness/error.rs
1//! The one error type the crate returns.
2
3use std::time::Duration;
4
5/// Why a provider call failed, and therefore whether trying it again — or trying
6/// a different provider — is worth doing.
7///
8/// Before 0.11.0 every provider failure was one string: a 429, a 503, a 401 and
9/// a DNS failure differed only in prose, so nothing above the provider could
10/// branch on them. This enum is that branch, and [`ProviderErrorKind::is_retryable`]
11/// is the decision the rest of the crate reads.
12///
13/// A caller branches on the kind rather than on the message, because the message
14/// wording is explicitly *not* part of the public contract and the kind is:
15///
16/// ```
17/// use io_harness::{Error, ProviderErrorKind};
18/// use std::time::Duration;
19///
20/// # fn next_move(failure: &Error) -> (&'static str, Option<Duration>) {
21/// // What a run that exhausted its retries hands back. The three answers a
22/// // caller actually has are "wait this long", "try again now", and "stop".
23/// match failure {
24/// // The only kind that carries *when*. Honour the server's own number
25/// // rather than backing off blind, then resume under the original run id.
26/// Error::Provider { kind: ProviderErrorKind::RateLimited, retry_after, .. } => {
27/// ("wait, then resume", Some(retry_after.unwrap_or(Duration::from_secs(30))))
28/// }
29/// // Auth and Request are terminal: the key will not become valid, and the
30/// // server has already read this exact request and refused it. Retrying
31/// // burns the budget to reach the same failure and hides it behind a later one.
32/// Error::Provider { kind, .. } if !kind.is_retryable() => ("stop and page a human", None),
33/// Error::Provider { .. } => ("resume; another attempt can succeed", None),
34/// _ => ("not a provider failure", None),
35/// }
36/// # }
37/// ```
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ProviderErrorKind {
40 /// The request never completed: connection refused, DNS failure, TLS failure,
41 /// a mid-stream byte error.
42 Transport,
43 /// The request had a deadline and passed it.
44 Timeout,
45 /// 429. Carries the server's `Retry-After` when it sent one.
46 RateLimited,
47 /// 5xx.
48 Server,
49 /// 401 or 403 — the key is wrong, missing, or not entitled to this model.
50 Auth,
51 /// 4xx other than 401/403: the request itself is unacceptable.
52 Request,
53 /// The response arrived and nothing in it could be read.
54 Malformed,
55}
56
57impl ProviderErrorKind {
58 /// Whether re-sending the same request could plausibly succeed.
59 ///
60 /// Per arm, and the reasoning is the point — a retry that cannot work costs
61 /// the budget twice and hides the real failure behind a later one:
62 ///
63 /// - [`Transport`](Self::Transport): the request never reached the model, so
64 /// nothing about it was rejected. Networks drop connections for reasons
65 /// that do not repeat.
66 /// - [`Timeout`](Self::Timeout): the deadline says nothing about the request.
67 /// A hung socket or an overloaded server is the usual cause and neither is
68 /// permanent.
69 /// - [`RateLimited`](Self::RateLimited): the server is explicitly telling you
70 /// to come back later. This is the one kind that carries *when*.
71 /// - [`Server`](Self::Server): a 5xx is the server's own admission that the
72 /// fault is its side, not the request's.
73 /// - [`Malformed`](Self::Malformed): a garbled or empty stream is far more
74 /// often a truncated transfer than a model that genuinely produced nothing,
75 /// and re-asking is cheap — cheaper than a run that reads the emptiness as
76 /// "the model chose not to call a tool" and quietly stops.
77 /// - [`Auth`](Self::Auth): the key will not become valid between two calls a
78 /// second apart. Retrying burns the retry budget to reach the same 401.
79 /// - [`Request`](Self::Request): the server has already read this exact
80 /// request and refused it. Sending it again is two failures instead of one.
81 pub fn is_retryable(self) -> bool {
82 match self {
83 Self::Transport
84 | Self::Timeout
85 | Self::RateLimited
86 | Self::Server
87 | Self::Malformed => true,
88 Self::Auth | Self::Request => false,
89 }
90 }
91
92 /// The kind an HTTP status maps to.
93 ///
94 /// The one place that mapping lives, so OpenRouter, OpenAI and Anthropic
95 /// cannot drift apart: they were byte-identical before this existed and this
96 /// is what keeps them so. A status the buckets do not cover (1xx, 3xx — the
97 /// client follows no redirects, so a 3xx is a boundary hop, not a success)
98 /// is [`Request`](Self::Request): unexpected, and not something a retry fixes.
99 pub fn from_status(status: u16) -> Self {
100 match status {
101 429 => Self::RateLimited,
102 401 | 403 => Self::Auth,
103 500..=599 => Self::Server,
104 _ => Self::Request,
105 }
106 }
107}
108
109/// Errors io-harness can return from a run.
110///
111/// The variants are separate because the *response* to each is separate — a
112/// refusal is not a malfunction, a bad checkpoint is not a bad key, and only one
113/// arm here is ever worth retrying. This is the match a caller writes around an
114/// entry point:
115///
116/// ```
117/// use io_harness::Error;
118///
119/// # fn handle(failure: Error) -> String {
120/// match failure {
121/// // The policy said no. Nothing happened and nothing is broken: either
122/// // widen the rule that refused it, or accept the refusal. The rule and
123/// // layer are carried so the operator knows which line of config to edit.
124/// Error::Refused { act, target, rule, layer } => format!(
125/// "{act} {target} refused by {} in layer {}",
126/// rule.unwrap_or_else(|| "the tier default".into()),
127/// layer.unwrap_or_else(|| "-".into()),
128/// ),
129/// // The checkpoint could not be honoured — newer format, missing run, or a
130/// // run started under a policy this resume would have silently dropped.
131/// // Never retried in a loop: re-resume the way the message names.
132/// Error::Resume { reason } => format!("resume refused: {reason}"),
133/// // Operator error, raised before the provider is called once. Fail the
134/// // job; a second attempt reaches the same missing key or duplicate tool.
135/// Error::Config(message) => format!("fix the configuration: {message}"),
136/// // The tool server never came up. The run fails rather than quietly
137/// // proceeding without a capability it was told it had.
138/// Error::Mcp { server, reason } => format!("server {server} did not start: {reason}"),
139/// // The gate never ran the code, as opposed to running it and failing it.
140/// Error::Sandbox { reason } => format!("verification never executed: {reason}"),
141/// // The one arm where another attempt is a real option — and only for some
142/// // kinds; see `ProviderErrorKind::is_retryable`.
143/// Error::Provider { kind, message, .. } if kind.is_retryable() => format!("retry: {message}"),
144/// other => other.to_string(),
145/// }
146/// # }
147/// ```
148#[derive(Debug, thiserror::Error)]
149pub enum Error {
150 /// A filesystem tool operation failed.
151 #[error("filesystem error: {0}")]
152 Io(#[from] std::io::Error),
153
154 /// The state store (rusqlite) failed.
155 #[error("state store error: {0}")]
156 State(#[from] rusqlite::Error),
157
158 /// The provider request or its streamed response failed.
159 ///
160 /// [`ProviderErrorKind`] is what a caller branches on; `status` and
161 /// `retry_after` are kept rather than folded into the message so a retry can
162 /// honour what the server actually said.
163 #[error("provider error ({kind:?}{}): {message}", .status.map(|s| format!(", HTTP {s}")).unwrap_or_default())]
164 Provider {
165 /// Why the call failed, and whether retrying is worth it.
166 kind: ProviderErrorKind,
167 /// The HTTP status, when the failure had one.
168 status: Option<u16>,
169 /// The server's `Retry-After`, when it sent one.
170 retry_after: Option<Duration>,
171 /// What the provider or the transport reported.
172 message: String,
173 },
174
175 /// Configuration was missing or invalid (e.g. no API key).
176 #[error("configuration error: {0}")]
177 Config(String),
178
179 /// The sandbox failed to start (e.g. the backend or the program could not be
180 /// spawned). Typed separately from [`Error::Io`] so a calling agent can tell
181 /// "the sandbox never ran the code" apart from "the code ran and failed", and
182 /// adapt — one failed child does not take down its siblings or the tree.
183 #[error("sandbox failed to start: {reason}")]
184 Sandbox {
185 /// Why the sandbox could not start the command.
186 reason: String,
187 },
188
189 /// The permission policy refused the action. Typed separately from
190 /// [`Error::Config`] so a refusal is distinguishable from a malfunction —
191 /// a verification that was refused is not a verification that ran and
192 /// failed, and the model is told the difference.
193 #[error("refused by policy: {act} {target}{}", .rule.as_ref().map(|r| format!(" (rule {r} in layer {})", .layer.as_deref().unwrap_or("?"))).unwrap_or_default())]
194 Refused {
195 /// The action attempted.
196 act: String,
197 /// The path or binary it targeted.
198 target: String,
199 /// The glob that refused it, when a rule rather than a default did.
200 rule: Option<String>,
201 /// The layer that rule came from.
202 layer: Option<String>,
203 },
204
205 /// An MCP server could not be reached or set up. Typed separately from
206 /// [`Error::Provider`] so a caller can tell "the model call failed" from
207 /// "the tool server the operator configured never came up" — the second is a
208 /// configuration problem, and the run fails on it rather than quietly
209 /// proceeding without a capability it was told it had.
210 ///
211 /// Failures *during* a call — a timeout, a dead transport, a tool reporting
212 /// its own error — are not this. They come back to the model as observations
213 /// it can adapt to, like a refused path or a bad regex.
214 #[error("mcp server {server}: {reason}")]
215 Mcp {
216 /// The configured server's id.
217 server: String,
218 /// What went wrong.
219 reason: String,
220 },
221
222 /// A durable run could not be resumed from its checkpoint — the checkpoint
223 /// format is newer than this binary supports, the run row is missing or
224 /// corrupt, or the run has already finished. Typed separately so a caller
225 /// handles a bad-checkpoint resume as a recoverable error instead of a panic
226 /// or a silent half-resume. A partially written (crashed mid-commit) step is
227 /// never surfaced here: the transaction rolls it back, so resume always sees
228 /// the prior consistent checkpoint, not a torn one.
229 #[error("cannot resume: {reason}")]
230 Resume {
231 /// Why the run could not be resumed.
232 reason: String,
233 },
234}
235
236impl Error {
237 /// A provider failure of `kind` carrying no HTTP status — the request never
238 /// completed, or the response could not be read.
239 pub fn provider(kind: ProviderErrorKind, message: impl Into<String>) -> Self {
240 Self::Provider {
241 kind,
242 status: None,
243 retry_after: None,
244 message: message.into(),
245 }
246 }
247
248 /// The request never completed: connection refused, DNS, TLS, a mid-stream
249 /// byte error.
250 pub fn provider_transport(message: impl Into<String>) -> Self {
251 Self::provider(ProviderErrorKind::Transport, message)
252 }
253
254 /// The response arrived and nothing in it could be read.
255 pub fn provider_malformed(message: impl Into<String>) -> Self {
256 Self::provider(ProviderErrorKind::Malformed, message)
257 }
258
259 /// A non-success HTTP status, with the kind derived once by
260 /// [`ProviderErrorKind::from_status`] so no provider can classify it
261 /// differently.
262 pub fn provider_status(
263 status: u16,
264 retry_after: Option<Duration>,
265 message: impl Into<String>,
266 ) -> Self {
267 Self::Provider {
268 kind: ProviderErrorKind::from_status(status),
269 status: Some(status),
270 retry_after,
271 message: message.into(),
272 }
273 }
274}
275
276/// Every `reqwest` failure the crate sees is a provider call that did not
277/// complete, so the timeout/transport split is decided here once instead of at
278/// each of the four call sites (three providers plus the shared SSE reader).
279impl From<reqwest::Error> for Error {
280 fn from(e: reqwest::Error) -> Self {
281 let kind = if e.is_timeout() {
282 ProviderErrorKind::Timeout
283 } else {
284 ProviderErrorKind::Transport
285 };
286 Self::provider(kind, e.to_string())
287 }
288}
289
290/// Crate result alias.
291///
292/// Every fallible call in the crate returns this, so an embedding function that
293/// adopts it gets `?` across all of them with no error mapping in between —
294/// opening the store, building a provider from the environment, and driving the
295/// run are three different failure sources and one type:
296///
297/// ```no_run
298/// use io_harness::{run_with, ApproveAll, OpenRouter, Policy, Result, Store,
299/// TaskContract, Verification};
300///
301/// // `Result<RunOutcome>` is `std::result::Result<RunOutcome, io_harness::Error>`.
302/// # async fn build_notes(repo: &str) -> Result<io_harness::RunOutcome> {
303/// let store = Store::open("runs.db")?; // rusqlite failure -> Error::State
304/// let provider = OpenRouter::from_env()?; // missing key -> Error::Config
305/// let contract = TaskContract::workspace(
306/// "summarise the README into NOTES.md",
307/// repo,
308/// Verification::WorkspaceFileContains { file: "NOTES.md".into(), needle: "#".into() },
309/// );
310/// let policy = Policy::default().layer("app").allow_write("NOTES.md");
311/// let result = run_with(&contract, &provider, &store, &policy, &ApproveAll).await?;
312/// Ok(result.outcome)
313/// # }
314/// ```
315pub type Result<T> = std::result::Result<T, Error>;
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn the_same_status_always_maps_to_the_same_kind() {
323 use ProviderErrorKind::*;
324 for (status, kind) in [
325 (429, RateLimited),
326 (401, Auth),
327 (403, Auth),
328 (400, Request),
329 (404, Request),
330 (422, Request),
331 (500, Server),
332 (503, Server),
333 (599, Server),
334 ] {
335 assert_eq!(ProviderErrorKind::from_status(status), kind, "{status}");
336 }
337 }
338
339 #[test]
340 fn retrying_is_worth_it_for_everything_except_a_rejected_request() {
341 use ProviderErrorKind::*;
342 for kind in [Transport, Timeout, RateLimited, Server, Malformed] {
343 assert!(kind.is_retryable(), "{kind:?}");
344 }
345 for kind in [Auth, Request] {
346 assert!(!kind.is_retryable(), "{kind:?}");
347 }
348 }
349
350 #[test]
351 fn a_status_failure_keeps_its_status_and_retry_after() {
352 let e = Error::provider_status(429, Some(Duration::from_secs(7)), "slow down");
353 let Error::Provider {
354 kind,
355 status,
356 retry_after,
357 ..
358 } = &e
359 else {
360 panic!("expected a provider error");
361 };
362 assert_eq!(*kind, ProviderErrorKind::RateLimited);
363 assert_eq!(*status, Some(429));
364 assert_eq!(*retry_after, Some(Duration::from_secs(7)));
365 // The rendering names the kind and the status; the fields are what code
366 // branches on.
367 let shown = e.to_string();
368 assert!(shown.contains("RateLimited"), "{shown}");
369 assert!(shown.contains("429"), "{shown}");
370 }
371}