Skip to main content

cortexkit_role_classifier/
errors.rs

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