Skip to main content

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}