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
//! [`DecisionError`]: everything a decision-model call can fail with.
use std::sync::Arc;
use std::time::Duration;
/// Why a decision-model call failed.
///
/// `Clone`, so one error can be both logged and returned (the same policy as
/// [`PriceError`](crate::provider::PriceError)). `#[non_exhaustive]`, as is
/// each struct variant: new failure kinds and new fields are minor changes.
/// Match with `matches!(e, DecisionError::Timeout(_))` and friends; there is
/// no `PartialEq`, because an underlying source error cannot be compared.
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum DecisionError {
/// The server answered with a non-success status that is neither a rate
/// limit nor a validation failure (401, 5xx other than 529, ...).
#[error("decision model returned HTTP {status}: {body}")]
#[non_exhaustive]
Http {
/// HTTP status code.
status: u16,
/// Response body, truncated.
body: String,
},
/// 429 Too Many Requests or 529 Overloaded, after retries were spent.
#[error("decision model rate limited or overloaded (HTTP {status}){}{}", .retry_after.map(|d| format!(", retry after {:.1}s", d.as_secs_f64())).unwrap_or_default(), if .body.is_empty() { String::new() } else { format!(": {}", .body) })]
#[non_exhaustive]
RateLimited {
/// 429 or 529.
status: u16,
/// The server's `retry-after`, when it sent one.
retry_after: Option<Duration>,
/// What the server said, truncated; empty when it said nothing.
body: String,
},
/// The call did not finish within the configured timeout.
#[error("decision model call timed out after {:.1}s", .0.as_secs_f64())]
Timeout(Duration),
/// The request is invalid: rejected client-side before sending, or by the
/// server with 422. The text names the offending field.
#[error("invalid decision request: {0}")]
Invalid(String),
/// The backend cannot answer this kind of question. Never emulated.
#[error("unsupported by this decision backend: {0}")]
Unsupported(String),
/// Connection, TLS or request failure before a response arrived.
/// Retried by [`SystemOneBackend`](super::SystemOneBackend).
#[error("decision model transport error: {message}")]
#[non_exhaustive]
Transport {
/// What failed.
message: String,
/// The underlying error, when there is one (shared, so the error
/// stays `Clone`).
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
},
/// A preset's API key environment variable is unset or empty. Names the
/// variable — or the variables, `"A or B"`, when a preset reads several —
/// never a value.
#[error("decision model API key missing: set {0}")]
MissingApiKey(String),
/// A custom backend's own failure, in its words.
#[error("decision backend error: {message}")]
#[non_exhaustive]
Backend {
/// What failed.
message: String,
/// The underlying error, when there is one (shared, so the error
/// stays `Clone`).
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
},
/// The model declined to answer (OpenAI's Decisions API `refusal`, or a
/// custom backend's equivalent): a judgement, not an outage. Never
/// retried, and **terminal in a fallback chain**
/// ([`DecisionModel::or`](super::DecisionModel::or)) — asking another
/// model what one declined would turn a refusal into an answer. The tool
/// gate denies and the input guard rejects on it, like on any error (an
/// input guard built [`with_fail_open`](super::InputGuard::with_fail_open)
/// lets the input through, as on any error).
#[error("decision model refused to answer: {message}")]
#[non_exhaustive]
Refused {
/// What was refused, in the backend's words.
message: String,
},
/// The answer could not be used: not JSON or unreadable, a missing
/// answer or one of the wrong type, a probability or confidence that is
/// not finite or not in `[0, 1]`, a distribution that does not cover
/// every option or does not sum to 1, a choice that is not one of the
/// options. Checked for every backend.
#[error("decision model returned an unusable response: {0}")]
BadResponse(String),
/// Every model of a fallback chain
/// ([`DecisionModel::or`](super::DecisionModel::or)) failed or was
/// skipped for its limits. Lists each member, in the order they were
/// tried. (When the chain's overall time ran out, the error is
/// [`Timeout`](Self::Timeout) instead.)
#[error("every decision model in the fallback chain failed: {}", fmt_attempts(.attempts))]
#[non_exhaustive]
AllFailed {
/// One per member, in order.
attempts: Vec<FallbackAttempt>,
},
}
/// One member's outcome in [`DecisionError::AllFailed`].
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FallbackAttempt {
model: String,
error: DecisionError,
sent: bool,
}
impl FallbackAttempt {
pub(crate) fn new(model: String, error: DecisionError, sent: bool) -> Self {
Self { model, error, sent }
}
/// The model id this member asked for.
pub fn model(&self) -> &str {
&self.model
}
/// Why it failed — or, when it was not sent, why it was skipped.
pub fn error(&self) -> &DecisionError {
&self.error
}
/// Whether a request was sent. `false` when the member was skipped
/// because the request exceeded its capabilities.
pub fn was_sent(&self) -> bool {
self.sent
}
}
fn fmt_attempts(attempts: &[FallbackAttempt]) -> String {
attempts
.iter()
.map(|a| {
let skipped = if a.sent { "" } else { " (skipped)" };
format!("[{}{skipped}] {}", a.model, a.error)
})
.collect::<Vec<_>>()
.join("; ")
}
impl DecisionError {
/// Whether retrying the same request may succeed: rate limits, overload,
/// and transport failures — and [`AllFailed`](Self::AllFailed) when any
/// member's error was one of those.
pub fn is_retryable(&self) -> bool {
match self {
Self::RateLimited { .. } | Self::Transport { .. } => true,
Self::AllFailed { attempts } => attempts.iter().any(|a| a.error.is_retryable()),
_ => false,
}
}
/// The server-specified retry delay, if any.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after, .. } => *retry_after,
_ => None,
}
}
/// An [`Http`](Self::Http) error. The variant is `#[non_exhaustive]`, so
/// custom backends build it here.
pub fn http(status: u16, body: impl Into<String>) -> Self {
Self::Http {
status,
body: body.into(),
}
}
/// A [`RateLimited`](Self::RateLimited) error, for custom backends.
pub fn rate_limited(status: u16, retry_after: Option<Duration>) -> Self {
Self::rate_limited_with_body(status, retry_after, String::new())
}
/// A [`RateLimited`](Self::RateLimited) error keeping what the server
/// said (truncate it first).
pub fn rate_limited_with_body(
status: u16,
retry_after: Option<Duration>,
body: impl Into<String>,
) -> Self {
Self::RateLimited {
status,
retry_after,
body: body.into(),
}
}
/// A [`Transport`](Self::Transport) error without a source.
pub fn transport(message: impl Into<String>) -> Self {
Self::Transport {
message: message.into(),
source: None,
}
}
/// A [`Transport`](Self::Transport) error carrying its cause.
pub fn transport_with_source(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Transport {
message: message.into(),
source: Some(Arc::new(source)),
}
}
/// A [`Refused`](Self::Refused) error, for custom backends whose model
/// declined to answer.
pub fn refused(message: impl Into<String>) -> Self {
Self::Refused {
message: message.into(),
}
}
/// Whether the model declined to answer ([`Refused`](Self::Refused)).
pub fn is_refusal(&self) -> bool {
matches!(self, Self::Refused { .. })
}
/// A [`Backend`](Self::Backend) error without a source.
pub fn backend(message: impl Into<String>) -> Self {
Self::Backend {
message: message.into(),
source: None,
}
}
/// A [`Backend`](Self::Backend) error carrying its cause.
pub fn backend_with_source(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Backend {
message: message.into(),
source: Some(Arc::new(source)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn sources_are_wired() {
let io = std::io::Error::other("disk on fire");
let e = DecisionError::backend_with_source("lookup failed", io);
assert_eq!(e.to_string(), "decision backend error: lookup failed");
assert_eq!(e.source().unwrap().to_string(), "disk on fire");
assert!(DecisionError::backend("x").source().is_none());
let t = DecisionError::transport_with_source("connect", std::io::Error::other("refused"));
assert!(t.is_retryable());
assert_eq!(t.source().unwrap().to_string(), "refused");
// Clone keeps the source.
assert!(t.clone().source().is_some());
}
}