Skip to main content

cortexkit_model_batch/
errors.rs

1//! The error vocabulary every model-batch role shares: codes, their classes,
2//! and the two error shapes of a batch call.
3//!
4//! An admission refusal answers the whole request with an `ERROR` frame
5//! whose body is `{code, message, detail}` ([`Refusal`]); it writes nothing
6//! and dispatches nothing. After admission the batch never fails as a
7//! whole: an item that could not be answered carries an [`ItemError`] in
8//! its place in the reply.
9//!
10//! Codes are open strings on the wire: a code this crate does not list is
11//! kept as it was sent. Classes decode open too ([`ErrorClass::Other`]); a
12//! caller that meets an unknown class treats it as permanent
13//! ([`ErrorClass::effective`]), which is the safe direction.
14//!
15//! One vocabulary serves every role, so each role's own "this catalog row is
16//! not my kind" code ([`MODEL_NOT_CLASSIFIER`], [`MODEL_NOT_EXTRACTOR`]) is
17//! listed here too. Which codes a role actually answers with is the role's
18//! list (`ADMISSION_CODES` and `ITEM_CODES` in each role crate).
19
20use std::fmt;
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use serde_json::Number;
24
25// ---- admission refusals ---------------------------------------------------
26
27/// A malformed request, or a limit exceeded. `detail.field` names the
28/// field, and `detail.limit` the limit where one applies. Permanent.
29pub const INVALID_PARAMS: &str = "invalid_params";
30
31/// The `batch_id` names a recorded batch whose body differs.
32/// `detail.field` names the first differing field. Permanent.
33pub const BATCH_ID_REUSE: &str = "batch_id_reuse";
34
35/// The model is not in the runner's catalog. `detail.model` names it.
36/// Permanent.
37pub const MODEL_UNKNOWN: &str = "model_unknown";
38
39/// `classifier/v1`: the model's catalog row is not a classifier.
40/// `detail.model` and `detail.kind` name it and its kind. Permanent.
41pub const MODEL_NOT_CLASSIFIER: &str = "model_not_classifier";
42
43/// `extractor/v1`: the model's catalog row is not an extraction model,
44/// including a row that states no kind. `detail.model` names it, and
45/// `detail.kind` its kind when the row has one. Permanent.
46pub const MODEL_NOT_EXTRACTOR: &str = "model_not_extractor";
47
48/// As an admission refusal: the batch's estimated cost is over
49/// `max_cost_usd`; `detail.estimate_usd` and `detail.max_cost_usd` carry
50/// both. As a per-item error: answering the item would cross the batch's
51/// ceiling. Permanent either way.
52pub const COST_EXCEEDED: &str = "cost_exceeded";
53
54/// The provider account is at its limit, as the runner's own spend guard
55/// knows before dispatch. `detail.resets_at_ms` when known. Transient.
56pub const ACCOUNT_WALLED: &str = "account_walled";
57
58/// Another call holds this `batch_id`. `detail.retry_after_ms` says when to
59/// re-send. Transient. Nothing was written or dispatched.
60pub const BATCH_IN_PROGRESS: &str = "batch_in_progress";
61
62/// The provider refused the module's credentials (HTTP 401 or 403).
63/// Permanent. As an admission refusal: the call's first provider call met
64/// it, so the whole call is refused and nothing is written. As a per-item
65/// error: it stopped the call, and every item the call left unanswered
66/// carries it. Never stored, so a re-send after a re-login asks the
67/// provider again.
68pub const AUTH_FAILED: &str = "auth_failed";
69
70/// The provider does not serve the model (HTTP 404, or a Workers AI "no
71/// such model" code). Permanent. As an admission refusal, `detail.model`
72/// names the model; the call's first provider call met it, so the whole
73/// call is refused and nothing is written. As a per-item error: it stopped
74/// the call, and every item the call left unanswered carries it. Never
75/// stored, so a re-send after a catalog fix asks the provider again.
76pub const MODEL_UNAVAILABLE: &str = "model_unavailable";
77
78/// The provider account used up its daily free allocation (Workers AI code
79/// 3036). Transient: the allocation resets. Batch-level, handled like
80/// [`AUTH_FAILED`]: an admission refusal when the call's first provider
81/// call meets it, otherwise it stops the call and every item left
82/// unanswered carries it. Never stored, so a re-send after the reset
83/// answers the item.
84pub const ACCOUNT_LIMIT_EXHAUSTED: &str = "account_limit_exhausted";
85
86/// The provider account may not use the model: it needs a paid plan
87/// (Workers AI 5035), has not accepted the model's agreement (5016), or is
88/// blocked (3023). Permanent until an operator acts. Batch-level, handled
89/// like [`AUTH_FAILED`]: an admission refusal when the call's first
90/// provider call meets it, otherwise it stops the call and every item left
91/// unanswered carries it. Never stored.
92pub const ACCOUNT_NOT_ENTITLED: &str = "account_not_entitled";
93
94// ---- per-item errors ------------------------------------------------------
95
96/// The provider answered 429 or 529 after the runner's own retries.
97/// `retry_after_ms` when the provider gave one. Transient. It stops the
98/// call: the items after it that would need a provider call are not sent,
99/// and carry `rate_limited` too, unstored, so a re-send retries them.
100pub const RATE_LIMITED: &str = "rate_limited";
101
102/// The provider answered 5xx or 408, or the transport failed, after the
103/// runner's own retries. Transient.
104pub const PROVIDER_ERROR: &str = "provider_error";
105
106/// The provider refused the content. Permanent.
107pub const PROVIDER_REFUSED: &str = "provider_refused";
108
109/// The provider rejected this item's input (400, 413, 422, or another 4xx
110/// the contract does not map elsewhere). Permanent.
111pub const INVALID_ITEM: &str = "invalid_item";
112
113/// The provider answered, but one answer is larger than the bound the
114/// module states for the model, so the module does not forward it.
115/// `detail.field` names the field, `detail.limit` the bound in force and
116/// `detail.size` the answer's size in bytes. Permanent and stored; a caller
117/// who wants another try re-sends under a new `batch_id`. `classifier/v1`
118/// states no answer bound and never answers with this code.
119pub const ANSWER_TOO_LARGE: &str = "answer_too_large";
120
121/// The codes that say nothing about an item and stop the call, beyond
122/// [`AUTH_FAILED`] and [`MODEL_UNAVAILABLE`]: the Workers AI account codes.
123/// Neither is ever stored as an item's outcome.
124pub const BATCH_LEVEL_CODES: &[&str] = &[ACCOUNT_LIMIT_EXHAUSTED, ACCOUNT_NOT_ENTITLED];
125
126/// Every admission refusal code the vocabulary lists. A role answers with
127/// the subset its contract's table names (its own `ADMISSION_CODES`).
128pub const ADMISSION_VOCABULARY: &[&str] = &[
129    INVALID_PARAMS,
130    BATCH_ID_REUSE,
131    MODEL_UNKNOWN,
132    MODEL_NOT_CLASSIFIER,
133    MODEL_NOT_EXTRACTOR,
134    COST_EXCEEDED,
135    ACCOUNT_WALLED,
136    BATCH_IN_PROGRESS,
137    AUTH_FAILED,
138    MODEL_UNAVAILABLE,
139    ACCOUNT_LIMIT_EXHAUSTED,
140    ACCOUNT_NOT_ENTITLED,
141];
142
143/// Every per-item error code the vocabulary lists. A role answers with the
144/// subset its contract's table names (its own `ITEM_CODES`).
145pub const ITEM_VOCABULARY: &[&str] = &[
146    RATE_LIMITED,
147    PROVIDER_ERROR,
148    PROVIDER_REFUSED,
149    INVALID_ITEM,
150    COST_EXCEEDED,
151    AUTH_FAILED,
152    MODEL_UNAVAILABLE,
153    ACCOUNT_LIMIT_EXHAUSTED,
154    ACCOUNT_NOT_ENTITLED,
155    ANSWER_TOO_LARGE,
156];
157
158/// The class of an admission refusal code this crate lists, `None` for any
159/// other code.
160pub fn admission_class(code: &str) -> Option<ErrorClass> {
161    match code {
162        INVALID_PARAMS | BATCH_ID_REUSE | MODEL_UNKNOWN | MODEL_NOT_CLASSIFIER
163        | MODEL_NOT_EXTRACTOR | COST_EXCEEDED | AUTH_FAILED | MODEL_UNAVAILABLE
164        | ACCOUNT_NOT_ENTITLED => Some(ErrorClass::Permanent),
165        ACCOUNT_WALLED | BATCH_IN_PROGRESS | ACCOUNT_LIMIT_EXHAUSTED => Some(ErrorClass::Transient),
166        _ => None,
167    }
168}
169
170/// The class of a per-item error code this crate lists, `None` for any
171/// other code.
172pub fn item_class(code: &str) -> Option<ErrorClass> {
173    match code {
174        RATE_LIMITED | PROVIDER_ERROR | ACCOUNT_LIMIT_EXHAUSTED => Some(ErrorClass::Transient),
175        PROVIDER_REFUSED | INVALID_ITEM | COST_EXCEEDED | AUTH_FAILED | MODEL_UNAVAILABLE
176        | ACCOUNT_NOT_ENTITLED | ANSWER_TOO_LARGE => Some(ErrorClass::Permanent),
177        _ => None,
178    }
179}
180
181// ---- classes --------------------------------------------------------------
182
183/// Class names. The set is fixed for every `v1` role; a new class means a
184/// new role version.
185pub mod classes {
186    /// The same request may succeed later.
187    pub const TRANSIENT: &str = "transient";
188    /// The same request meets the same answer.
189    pub const PERMANENT: &str = "permanent";
190}
191
192/// An error class. Decodes open: a value other than [`classes`] is kept in
193/// [`ErrorClass::Other`]. Non-exhaustive, so a match needs a wildcard arm
194/// and adding a variant later is not a breaking change.
195#[derive(Clone, Debug, PartialEq, Eq, Hash)]
196#[non_exhaustive]
197pub enum ErrorClass {
198    Transient,
199    Permanent,
200    Other(String),
201}
202
203impl ErrorClass {
204    pub fn parse(class: &str) -> Self {
205        match class {
206            classes::TRANSIENT => Self::Transient,
207            classes::PERMANENT => Self::Permanent,
208            other => Self::Other(other.to_owned()),
209        }
210    }
211
212    pub fn as_str(&self) -> &str {
213        match self {
214            Self::Transient => classes::TRANSIENT,
215            Self::Permanent => classes::PERMANENT,
216            Self::Other(other) => other,
217        }
218    }
219
220    /// The class a caller acts on: an unknown class is treated as
221    /// permanent, so a caller never retries what it cannot classify.
222    pub fn effective(&self) -> Self {
223        match self {
224            Self::Transient => Self::Transient,
225            Self::Permanent | Self::Other(_) => Self::Permanent,
226        }
227    }
228
229    /// Whether the class, as a caller acts on it, is transient.
230    pub fn is_transient(&self) -> bool {
231        self.effective() == Self::Transient
232    }
233}
234
235impl fmt::Display for ErrorClass {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        f.write_str(self.as_str())
238    }
239}
240
241impl Serialize for ErrorClass {
242    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
243        serializer.serialize_str(self.as_str())
244    }
245}
246
247impl<'de> Deserialize<'de> for ErrorClass {
248    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
249        Ok(Self::parse(&String::deserialize(deserializer)?))
250    }
251}
252
253// ---- per-item error -------------------------------------------------------
254
255/// The optional `detail` of an item error. Only [`ANSWER_TOO_LARGE`] sends
256/// one today; every other item error leaves it out, so its wire form is
257/// `{code, class, message, retry_after_ms?}`.
258#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
259#[non_exhaustive]
260pub struct ItemErrorDetail {
261    /// The field the error is about, as the role names it in a request.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub field: Option<String>,
264    /// The bound in force.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub limit: Option<u64>,
267    /// The size that broke the bound, in bytes.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub size: Option<u64>,
270}
271
272/// An item's error in a batch reply: `{code, class, message,
273/// retry_after_ms?, detail?}`.
274///
275/// Non-exhaustive so later optional members are additive: use
276/// [`ItemError::new`] or [`ItemError::for_code`] and the `with_*` setters,
277/// or decode one.
278#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
279#[non_exhaustive]
280pub struct ItemError {
281    /// A per-item code this crate lists; any other value decodes as a
282    /// plain string.
283    pub code: String,
284    pub class: ErrorClass,
285    pub message: String,
286    /// When the provider said how long to wait.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub retry_after_ms: Option<u64>,
289    /// Present only on codes that define one ([`ANSWER_TOO_LARGE`]).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub detail: Option<ItemErrorDetail>,
292}
293
294impl ItemError {
295    pub fn new(code: impl Into<String>, class: ErrorClass, message: impl Into<String>) -> Self {
296        Self {
297            code: code.into(),
298            class,
299            message: message.into(),
300            retry_after_ms: None,
301            detail: None,
302        }
303    }
304
305    /// An error with the class the vocabulary gives `code`; a code this
306    /// crate does not list gets [`ErrorClass::Permanent`].
307    pub fn for_code(code: &str, message: impl Into<String>) -> Self {
308        Self::new(
309            code,
310            item_class(code).unwrap_or(ErrorClass::Permanent),
311            message,
312        )
313    }
314
315    /// `answer_too_large` for the answer to `field`, `size` bytes against a
316    /// bound of `limit`.
317    pub fn answer_too_large(field: impl Into<String>, limit: u64, size: u64) -> Self {
318        let field = field.into();
319        let mut error = Self::for_code(
320            ANSWER_TOO_LARGE,
321            format!("the answer to {field} is {size} bytes; at most {limit}"),
322        );
323        error.detail = Some(ItemErrorDetail {
324            field: Some(field),
325            limit: Some(limit),
326            size: Some(size),
327        });
328        error
329    }
330
331    pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
332        self.retry_after_ms = Some(retry_after_ms);
333        self
334    }
335
336    pub fn with_detail(mut self, detail: ItemErrorDetail) -> Self {
337        self.detail = Some(detail);
338        self
339    }
340
341    /// Whether a re-send of the batch asks the provider again for an item
342    /// whose stored outcome is this error. A stored transient error is
343    /// retried and its new outcome replaces it; a stored permanent one (and
344    /// one of an unknown class) is returned as stored, with no provider
345    /// call.
346    pub fn is_retried_on_resend(&self) -> bool {
347        self.class.is_transient()
348    }
349
350    /// Whether a runner records this error as the item's outcome. Every
351    /// error is, except the ones that say nothing about the item:
352    /// `auth_failed`, `model_unavailable`, `account_limit_exhausted` and
353    /// `account_not_entitled`. The item stays unanswered, and a re-send
354    /// (after a re-login, a catalog fix, the allocation's reset or an
355    /// operator's fix) asks the provider again.
356    ///
357    /// This answers for an item the provider actually answered with this
358    /// error. A `rate_limited` item is stored like any transient error; the
359    /// items after it that a rate limit stopped the call from sending carry
360    /// `rate_limited` in the reply but are never recorded, which is the
361    /// runner's rule for unsent items and not something this code decides.
362    pub fn is_stored(&self) -> bool {
363        !is_batch_level(&self.code)
364    }
365
366    /// Whether this error, once an item ends with it, stops the call: no
367    /// further item is sent to the provider in that call, and every item
368    /// the call leaves unanswered carries the same code. True for the
369    /// batch-level codes and `rate_limited`.
370    pub fn stops_the_call(&self) -> bool {
371        is_batch_level(&self.code) || self.code == RATE_LIMITED
372    }
373}
374
375/// Whether `code` says nothing about the item it came back for: never
376/// stored, stops the call, and refuses the call at admission when its first
377/// provider call meets it.
378pub fn is_batch_level(code: &str) -> bool {
379    matches!(
380        code,
381        AUTH_FAILED | MODEL_UNAVAILABLE | ACCOUNT_LIMIT_EXHAUSTED | ACCOUNT_NOT_ENTITLED
382    )
383}
384
385// ---- admission refusal ----------------------------------------------------
386
387/// The `detail` of an admission refusal. Which members are present depends
388/// on the code (see the role contract's table); `class` is always present,
389/// so a caller that meets an unknown code still knows whether to retry.
390#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
391#[non_exhaustive]
392pub struct RefusalDetail {
393    pub class: ErrorClass,
394    /// The offending or differing request field, as a path: a map key
395    /// joined with a dot, an array index in brackets (`items[2].state`,
396    /// `questions.urgent.type`). `params` names the request as a whole, and
397    /// is used only when no narrower path applies. Each role contract lists
398    /// its paths.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub field: Option<String>,
401    /// The limit the field exceeded, where one applies.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub limit: Option<u64>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub model: Option<String>,
406    /// The kind of a catalog row that is not the role's kind, when the row
407    /// states one.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub kind: Option<String>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub estimate_usd: Option<Number>,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub max_cost_usd: Option<Number>,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub resets_at_ms: Option<u64>,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub retry_after_ms: Option<u64>,
418    /// On `auth_failed`, the id of the credential the runner presented (for
419    /// example `apikey:cloudflare`), when the runner knows it, so an operator
420    /// can tell which login to fix. Always an id, never the secret itself.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub credential: Option<String>,
423}
424
425impl RefusalDetail {
426    pub fn new(class: ErrorClass) -> Self {
427        Self {
428            class,
429            field: None,
430            limit: None,
431            model: None,
432            kind: None,
433            estimate_usd: None,
434            max_cost_usd: None,
435            resets_at_ms: None,
436            retry_after_ms: None,
437            credential: None,
438        }
439    }
440
441    pub fn with_field(mut self, field: impl Into<String>) -> Self {
442        self.field = Some(field.into());
443        self
444    }
445
446    pub fn with_limit(mut self, limit: u64) -> Self {
447        self.limit = Some(limit);
448        self
449    }
450
451    pub fn with_model(mut self, model: impl Into<String>) -> Self {
452        self.model = Some(model.into());
453        self
454    }
455
456    pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
457        self.kind = Some(kind.into());
458        self
459    }
460
461    pub fn with_estimate_usd(mut self, estimate_usd: Number) -> Self {
462        self.estimate_usd = Some(estimate_usd);
463        self
464    }
465
466    pub fn with_max_cost_usd(mut self, max_cost_usd: Number) -> Self {
467        self.max_cost_usd = Some(max_cost_usd);
468        self
469    }
470
471    pub fn with_resets_at_ms(mut self, resets_at_ms: u64) -> Self {
472        self.resets_at_ms = Some(resets_at_ms);
473        self
474    }
475
476    pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
477        self.retry_after_ms = Some(retry_after_ms);
478        self
479    }
480
481    pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
482        self.credential = Some(credential.into());
483        self
484    }
485}
486
487/// An admission refusal: the body of the `ERROR` frame a batch call answers
488/// with, `{code, message, detail}`, the shape of `subc_protocol::ErrorBody`
489/// with a typed detail.
490#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
491#[non_exhaustive]
492pub struct Refusal {
493    /// An admission code this crate lists; any other value decodes as a
494    /// plain string.
495    pub code: String,
496    pub message: String,
497    /// Boxed so a `Result<_, Refusal>` stays small.
498    pub detail: Box<RefusalDetail>,
499}
500
501impl Refusal {
502    /// A refusal with `code`, carrying the class the vocabulary gives it; a
503    /// code this crate does not list gets [`ErrorClass::Permanent`].
504    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
505        let code = code.into();
506        let class = admission_class(&code).unwrap_or(ErrorClass::Permanent);
507        Self {
508            code,
509            message: message.into(),
510            detail: Box::new(RefusalDetail::new(class)),
511        }
512    }
513
514    pub fn with_detail(mut self, detail: RefusalDetail) -> Self {
515        self.detail = Box::new(detail);
516        self
517    }
518
519    fn edit(mut self, edit: impl FnOnce(RefusalDetail) -> RefusalDetail) -> Self {
520        let detail =
521            std::mem::replace(&mut *self.detail, RefusalDetail::new(ErrorClass::Permanent));
522        *self.detail = edit(detail);
523        self
524    }
525
526    /// `invalid_params` naming `field`.
527    pub fn invalid_params(field: impl Into<String>, message: impl Into<String>) -> Self {
528        Self::new(INVALID_PARAMS, message).edit(|detail| detail.with_field(field))
529    }
530
531    /// `invalid_params` naming `field` and the `limit` it exceeded.
532    pub fn over_limit(field: impl Into<String>, limit: u64, message: impl Into<String>) -> Self {
533        Self::invalid_params(field, message).edit(|detail| detail.with_limit(limit))
534    }
535
536    /// `batch_id_reuse` naming the first differing field.
537    pub fn batch_id_reuse(field: impl Into<String>) -> Self {
538        let field = field.into();
539        Self::new(
540            BATCH_ID_REUSE,
541            format!("batch_id names a recorded batch whose {field} differs"),
542        )
543        .edit(|detail| detail.with_field(field))
544    }
545
546    pub fn model_unknown(model: impl Into<String>) -> Self {
547        let model = model.into();
548        Self::new(MODEL_UNKNOWN, format!("{model} is not in the catalog"))
549            .edit(|detail| detail.with_model(model))
550    }
551
552    /// `classifier/v1`'s `model_not_classifier`.
553    pub fn model_not_classifier(model: impl Into<String>, kind: impl Into<String>) -> Self {
554        let (model, kind) = (model.into(), kind.into());
555        Self::new(
556            MODEL_NOT_CLASSIFIER,
557            format!("{model} is a {kind}, not a classifier"),
558        )
559        .edit(|detail| detail.with_model(model).with_kind(kind))
560    }
561
562    /// `extractor/v1`'s `model_not_extractor`. `kind` is the row's kind,
563    /// `None` when the row states none; the refusal never invents one.
564    pub fn model_not_extractor(model: impl Into<String>, kind: Option<&str>) -> Self {
565        let model = model.into();
566        let message = match kind {
567            Some(kind) => format!("{model} is a {kind}, not an extraction model"),
568            None => {
569                format!("{model} states no kind in the catalog, so it is not an extraction model")
570            }
571        };
572        let refusal =
573            Self::new(MODEL_NOT_EXTRACTOR, message).edit(|detail| detail.with_model(model));
574        match kind {
575            Some(kind) => refusal.edit(|detail| detail.with_kind(kind)),
576            None => refusal,
577        }
578    }
579
580    pub fn cost_exceeded(estimate_usd: Number, max_cost_usd: Number) -> Self {
581        Self::new(
582            COST_EXCEEDED,
583            format!("estimated cost {estimate_usd} USD is over max_cost_usd {max_cost_usd}"),
584        )
585        .edit(|detail| {
586            detail
587                .with_estimate_usd(estimate_usd)
588                .with_max_cost_usd(max_cost_usd)
589        })
590    }
591
592    pub fn account_walled() -> Self {
593        Self::new(ACCOUNT_WALLED, "the provider account is at its limit")
594    }
595
596    /// The call's first provider call met a 401 or 403.
597    pub fn auth_failed() -> Self {
598        Self::new(AUTH_FAILED, "the provider refused the module's credentials")
599    }
600
601    /// The call's first provider call met a 404 for `model`.
602    pub fn model_unavailable(model: impl Into<String>) -> Self {
603        let model = model.into();
604        Self::new(
605            MODEL_UNAVAILABLE,
606            format!("the provider does not serve {model}"),
607        )
608        .edit(|detail| detail.with_model(model))
609    }
610
611    /// The call's first provider call met Workers AI code 3036.
612    pub fn account_limit_exhausted() -> Self {
613        Self::new(
614            ACCOUNT_LIMIT_EXHAUSTED,
615            "the provider account used up its daily allocation",
616        )
617    }
618
619    /// The call's first provider call met Workers AI code 5035, 5016 or
620    /// 3023.
621    pub fn account_not_entitled() -> Self {
622        Self::new(
623            ACCOUNT_NOT_ENTITLED,
624            "the provider account may not use this model",
625        )
626    }
627
628    pub fn batch_in_progress(retry_after_ms: u64) -> Self {
629        Self::new(BATCH_IN_PROGRESS, "another call holds this batch_id")
630            .edit(|detail| detail.with_retry_after_ms(retry_after_ms))
631    }
632
633    /// The refused field, if the detail names one.
634    pub fn field(&self) -> Option<&str> {
635        self.detail.field.as_deref()
636    }
637}
638
639impl fmt::Display for Refusal {
640    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
641        match &self.detail.field {
642            Some(field) => write!(f, "{} ({field}): {}", self.code, self.message),
643            None => write!(f, "{}: {}", self.code, self.message),
644        }
645    }
646}
647
648impl std::error::Error for Refusal {}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn every_admission_code_has_a_class_and_the_account_limit_is_transient() {
656        for code in ADMISSION_VOCABULARY {
657            assert!(admission_class(code).is_some(), "{code}");
658        }
659        for code in ITEM_VOCABULARY {
660            assert!(item_class(code).is_some(), "{code}");
661        }
662        for code in [ACCOUNT_WALLED, BATCH_IN_PROGRESS, ACCOUNT_LIMIT_EXHAUSTED] {
663            assert_eq!(admission_class(code), Some(ErrorClass::Transient), "{code}");
664        }
665        assert_eq!(
666            admission_class(ACCOUNT_NOT_ENTITLED),
667            Some(ErrorClass::Permanent)
668        );
669        assert_eq!(admission_class("nope"), None);
670    }
671
672    #[test]
673    fn item_classes() {
674        for code in [RATE_LIMITED, PROVIDER_ERROR, ACCOUNT_LIMIT_EXHAUSTED] {
675            assert_eq!(item_class(code), Some(ErrorClass::Transient), "{code}");
676        }
677        for code in [
678            PROVIDER_REFUSED,
679            INVALID_ITEM,
680            COST_EXCEEDED,
681            AUTH_FAILED,
682            MODEL_UNAVAILABLE,
683            ACCOUNT_NOT_ENTITLED,
684            ANSWER_TOO_LARGE,
685        ] {
686            assert_eq!(item_class(code), Some(ErrorClass::Permanent), "{code}");
687        }
688    }
689
690    #[test]
691    fn batch_level_codes_are_never_stored_and_stop_the_call() {
692        for code in [
693            AUTH_FAILED,
694            MODEL_UNAVAILABLE,
695            ACCOUNT_LIMIT_EXHAUSTED,
696            ACCOUNT_NOT_ENTITLED,
697        ] {
698            let error = ItemError::for_code(code, "m");
699            assert!(!error.is_stored(), "{code}");
700            assert!(error.stops_the_call(), "{code}");
701        }
702        let limited = ItemError::for_code(RATE_LIMITED, "m");
703        assert!(limited.is_stored() && limited.stops_the_call());
704        for code in [
705            PROVIDER_ERROR,
706            PROVIDER_REFUSED,
707            INVALID_ITEM,
708            COST_EXCEEDED,
709            ANSWER_TOO_LARGE,
710        ] {
711            let error = ItemError::for_code(code, "m");
712            assert!(error.is_stored(), "{code}");
713            assert!(!error.stops_the_call(), "{code}");
714        }
715    }
716
717    #[test]
718    fn an_unknown_class_is_kept_and_acted_on_as_permanent() {
719        let error: ItemError = serde_json::from_str(
720            r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#,
721        )
722        .unwrap();
723        assert_eq!(error.class, ErrorClass::Other("degraded".into()));
724        assert!(!error.is_retried_on_resend());
725        assert_eq!(
726            serde_json::to_string(&error).unwrap(),
727            r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#
728        );
729    }
730
731    #[test]
732    fn answer_too_large_carries_field_limit_and_size() {
733        let error = ItemError::answer_too_large("fields.total", 4096, 5000);
734        assert_eq!(
735            serde_json::to_string(&error).unwrap(),
736            r#"{"code":"answer_too_large","class":"permanent","message":"the answer to fields.total is 5000 bytes; at most 4096","detail":{"field":"fields.total","limit":4096,"size":5000}}"#
737        );
738        assert!(error.is_stored());
739        assert!(!error.is_retried_on_resend());
740    }
741
742    #[test]
743    fn model_not_extractor_never_invents_a_kind() {
744        let kindless = Refusal::model_not_extractor("m", None);
745        assert_eq!(kindless.code, MODEL_NOT_EXTRACTOR);
746        assert_eq!(kindless.detail.model.as_deref(), Some("m"));
747        assert_eq!(kindless.detail.kind, None);
748        let chat = Refusal::model_not_extractor("m", Some("chat"));
749        assert_eq!(chat.detail.kind.as_deref(), Some("chat"));
750        assert_eq!(chat.detail.class, ErrorClass::Permanent);
751    }
752
753    #[test]
754    fn refusals_carry_their_class_and_field() {
755        let refusal = Refusal::over_limit("items", 100, "too many items");
756        assert_eq!(refusal.code, INVALID_PARAMS);
757        assert_eq!(refusal.field(), Some("items"));
758        assert_eq!(refusal.detail.limit, Some(100));
759        let busy = Refusal::batch_in_progress(250);
760        assert_eq!(busy.detail.class, ErrorClass::Transient);
761        assert_eq!(busy.detail.retry_after_ms, Some(250));
762        assert_eq!(
763            Refusal::account_limit_exhausted().detail.class,
764            ErrorClass::Transient
765        );
766    }
767}