use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Number;
pub const INVALID_PARAMS: &str = "invalid_params";
pub const BATCH_ID_REUSE: &str = "batch_id_reuse";
pub const MODEL_UNKNOWN: &str = "model_unknown";
pub const MODEL_NOT_CLASSIFIER: &str = "model_not_classifier";
pub const MODEL_NOT_EXTRACTOR: &str = "model_not_extractor";
pub const COST_EXCEEDED: &str = "cost_exceeded";
pub const ACCOUNT_WALLED: &str = "account_walled";
pub const BATCH_IN_PROGRESS: &str = "batch_in_progress";
pub const AUTH_FAILED: &str = "auth_failed";
pub const MODEL_UNAVAILABLE: &str = "model_unavailable";
pub const ACCOUNT_LIMIT_EXHAUSTED: &str = "account_limit_exhausted";
pub const ACCOUNT_NOT_ENTITLED: &str = "account_not_entitled";
pub const RATE_LIMITED: &str = "rate_limited";
pub const PROVIDER_ERROR: &str = "provider_error";
pub const PROVIDER_REFUSED: &str = "provider_refused";
pub const INVALID_ITEM: &str = "invalid_item";
pub const ANSWER_TOO_LARGE: &str = "answer_too_large";
pub const BATCH_LEVEL_CODES: &[&str] = &[ACCOUNT_LIMIT_EXHAUSTED, ACCOUNT_NOT_ENTITLED];
pub const ADMISSION_VOCABULARY: &[&str] = &[
INVALID_PARAMS,
BATCH_ID_REUSE,
MODEL_UNKNOWN,
MODEL_NOT_CLASSIFIER,
MODEL_NOT_EXTRACTOR,
COST_EXCEEDED,
ACCOUNT_WALLED,
BATCH_IN_PROGRESS,
AUTH_FAILED,
MODEL_UNAVAILABLE,
ACCOUNT_LIMIT_EXHAUSTED,
ACCOUNT_NOT_ENTITLED,
];
pub const ITEM_VOCABULARY: &[&str] = &[
RATE_LIMITED,
PROVIDER_ERROR,
PROVIDER_REFUSED,
INVALID_ITEM,
COST_EXCEEDED,
AUTH_FAILED,
MODEL_UNAVAILABLE,
ACCOUNT_LIMIT_EXHAUSTED,
ACCOUNT_NOT_ENTITLED,
ANSWER_TOO_LARGE,
];
pub fn admission_class(code: &str) -> Option<ErrorClass> {
match code {
INVALID_PARAMS | BATCH_ID_REUSE | MODEL_UNKNOWN | MODEL_NOT_CLASSIFIER
| MODEL_NOT_EXTRACTOR | COST_EXCEEDED | AUTH_FAILED | MODEL_UNAVAILABLE
| ACCOUNT_NOT_ENTITLED => Some(ErrorClass::Permanent),
ACCOUNT_WALLED | BATCH_IN_PROGRESS | ACCOUNT_LIMIT_EXHAUSTED => Some(ErrorClass::Transient),
_ => None,
}
}
pub fn item_class(code: &str) -> Option<ErrorClass> {
match code {
RATE_LIMITED | PROVIDER_ERROR | ACCOUNT_LIMIT_EXHAUSTED => Some(ErrorClass::Transient),
PROVIDER_REFUSED | INVALID_ITEM | COST_EXCEEDED | AUTH_FAILED | MODEL_UNAVAILABLE
| ACCOUNT_NOT_ENTITLED | ANSWER_TOO_LARGE => Some(ErrorClass::Permanent),
_ => None,
}
}
pub mod classes {
pub const TRANSIENT: &str = "transient";
pub const PERMANENT: &str = "permanent";
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorClass {
Transient,
Permanent,
Other(String),
}
impl ErrorClass {
pub fn parse(class: &str) -> Self {
match class {
classes::TRANSIENT => Self::Transient,
classes::PERMANENT => Self::Permanent,
other => Self::Other(other.to_owned()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Transient => classes::TRANSIENT,
Self::Permanent => classes::PERMANENT,
Self::Other(other) => other,
}
}
pub fn effective(&self) -> Self {
match self {
Self::Transient => Self::Transient,
Self::Permanent | Self::Other(_) => Self::Permanent,
}
}
pub fn is_transient(&self) -> bool {
self.effective() == Self::Transient
}
}
impl fmt::Display for ErrorClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for ErrorClass {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ErrorClass {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self::parse(&String::deserialize(deserializer)?))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ItemErrorDetail {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ItemError {
pub code: String,
pub class: ErrorClass,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_after_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<ItemErrorDetail>,
}
impl ItemError {
pub fn new(code: impl Into<String>, class: ErrorClass, message: impl Into<String>) -> Self {
Self {
code: code.into(),
class,
message: message.into(),
retry_after_ms: None,
detail: None,
}
}
pub fn for_code(code: &str, message: impl Into<String>) -> Self {
Self::new(
code,
item_class(code).unwrap_or(ErrorClass::Permanent),
message,
)
}
pub fn answer_too_large(field: impl Into<String>, limit: u64, size: u64) -> Self {
let field = field.into();
let mut error = Self::for_code(
ANSWER_TOO_LARGE,
format!("the answer to {field} is {size} bytes; at most {limit}"),
);
error.detail = Some(ItemErrorDetail {
field: Some(field),
limit: Some(limit),
size: Some(size),
});
error
}
pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
self.retry_after_ms = Some(retry_after_ms);
self
}
pub fn with_detail(mut self, detail: ItemErrorDetail) -> Self {
self.detail = Some(detail);
self
}
pub fn is_retried_on_resend(&self) -> bool {
self.class.is_transient()
}
pub fn is_stored(&self) -> bool {
!is_batch_level(&self.code)
}
pub fn stops_the_call(&self) -> bool {
is_batch_level(&self.code) || self.code == RATE_LIMITED
}
}
pub fn is_batch_level(code: &str) -> bool {
matches!(
code,
AUTH_FAILED | MODEL_UNAVAILABLE | ACCOUNT_LIMIT_EXHAUSTED | ACCOUNT_NOT_ENTITLED
)
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct RefusalDetail {
pub class: ErrorClass,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimate_usd: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cost_usd: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resets_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_after_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential: Option<String>,
}
impl RefusalDetail {
pub fn new(class: ErrorClass) -> Self {
Self {
class,
field: None,
limit: None,
model: None,
kind: None,
estimate_usd: None,
max_cost_usd: None,
resets_at_ms: None,
retry_after_ms: None,
credential: None,
}
}
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
pub fn with_limit(mut self, limit: u64) -> Self {
self.limit = Some(limit);
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
self.kind = Some(kind.into());
self
}
pub fn with_estimate_usd(mut self, estimate_usd: Number) -> Self {
self.estimate_usd = Some(estimate_usd);
self
}
pub fn with_max_cost_usd(mut self, max_cost_usd: Number) -> Self {
self.max_cost_usd = Some(max_cost_usd);
self
}
pub fn with_resets_at_ms(mut self, resets_at_ms: u64) -> Self {
self.resets_at_ms = Some(resets_at_ms);
self
}
pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
self.retry_after_ms = Some(retry_after_ms);
self
}
pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
self.credential = Some(credential.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Refusal {
pub code: String,
pub message: String,
pub detail: Box<RefusalDetail>,
}
impl Refusal {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
let code = code.into();
let class = admission_class(&code).unwrap_or(ErrorClass::Permanent);
Self {
code,
message: message.into(),
detail: Box::new(RefusalDetail::new(class)),
}
}
pub fn with_detail(mut self, detail: RefusalDetail) -> Self {
self.detail = Box::new(detail);
self
}
fn edit(mut self, edit: impl FnOnce(RefusalDetail) -> RefusalDetail) -> Self {
let detail =
std::mem::replace(&mut *self.detail, RefusalDetail::new(ErrorClass::Permanent));
*self.detail = edit(detail);
self
}
pub fn invalid_params(field: impl Into<String>, message: impl Into<String>) -> Self {
Self::new(INVALID_PARAMS, message).edit(|detail| detail.with_field(field))
}
pub fn over_limit(field: impl Into<String>, limit: u64, message: impl Into<String>) -> Self {
Self::invalid_params(field, message).edit(|detail| detail.with_limit(limit))
}
pub fn batch_id_reuse(field: impl Into<String>) -> Self {
let field = field.into();
Self::new(
BATCH_ID_REUSE,
format!("batch_id names a recorded batch whose {field} differs"),
)
.edit(|detail| detail.with_field(field))
}
pub fn model_unknown(model: impl Into<String>) -> Self {
let model = model.into();
Self::new(MODEL_UNKNOWN, format!("{model} is not in the catalog"))
.edit(|detail| detail.with_model(model))
}
pub fn model_not_classifier(model: impl Into<String>, kind: impl Into<String>) -> Self {
let (model, kind) = (model.into(), kind.into());
Self::new(
MODEL_NOT_CLASSIFIER,
format!("{model} is a {kind}, not a classifier"),
)
.edit(|detail| detail.with_model(model).with_kind(kind))
}
pub fn model_not_extractor(model: impl Into<String>, kind: Option<&str>) -> Self {
let model = model.into();
let message = match kind {
Some(kind) => format!("{model} is a {kind}, not an extraction model"),
None => {
format!("{model} states no kind in the catalog, so it is not an extraction model")
}
};
let refusal =
Self::new(MODEL_NOT_EXTRACTOR, message).edit(|detail| detail.with_model(model));
match kind {
Some(kind) => refusal.edit(|detail| detail.with_kind(kind)),
None => refusal,
}
}
pub fn cost_exceeded(estimate_usd: Number, max_cost_usd: Number) -> Self {
Self::new(
COST_EXCEEDED,
format!("estimated cost {estimate_usd} USD is over max_cost_usd {max_cost_usd}"),
)
.edit(|detail| {
detail
.with_estimate_usd(estimate_usd)
.with_max_cost_usd(max_cost_usd)
})
}
pub fn account_walled() -> Self {
Self::new(ACCOUNT_WALLED, "the provider account is at its limit")
}
pub fn auth_failed() -> Self {
Self::new(AUTH_FAILED, "the provider refused the module's credentials")
}
pub fn model_unavailable(model: impl Into<String>) -> Self {
let model = model.into();
Self::new(
MODEL_UNAVAILABLE,
format!("the provider does not serve {model}"),
)
.edit(|detail| detail.with_model(model))
}
pub fn account_limit_exhausted() -> Self {
Self::new(
ACCOUNT_LIMIT_EXHAUSTED,
"the provider account used up its daily allocation",
)
}
pub fn account_not_entitled() -> Self {
Self::new(
ACCOUNT_NOT_ENTITLED,
"the provider account may not use this model",
)
}
pub fn batch_in_progress(retry_after_ms: u64) -> Self {
Self::new(BATCH_IN_PROGRESS, "another call holds this batch_id")
.edit(|detail| detail.with_retry_after_ms(retry_after_ms))
}
pub fn field(&self) -> Option<&str> {
self.detail.field.as_deref()
}
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.detail.field {
Some(field) => write!(f, "{} ({field}): {}", self.code, self.message),
None => write!(f, "{}: {}", self.code, self.message),
}
}
}
impl std::error::Error for Refusal {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_admission_code_has_a_class_and_the_account_limit_is_transient() {
for code in ADMISSION_VOCABULARY {
assert!(admission_class(code).is_some(), "{code}");
}
for code in ITEM_VOCABULARY {
assert!(item_class(code).is_some(), "{code}");
}
for code in [ACCOUNT_WALLED, BATCH_IN_PROGRESS, ACCOUNT_LIMIT_EXHAUSTED] {
assert_eq!(admission_class(code), Some(ErrorClass::Transient), "{code}");
}
assert_eq!(
admission_class(ACCOUNT_NOT_ENTITLED),
Some(ErrorClass::Permanent)
);
assert_eq!(admission_class("nope"), None);
}
#[test]
fn item_classes() {
for code in [RATE_LIMITED, PROVIDER_ERROR, ACCOUNT_LIMIT_EXHAUSTED] {
assert_eq!(item_class(code), Some(ErrorClass::Transient), "{code}");
}
for code in [
PROVIDER_REFUSED,
INVALID_ITEM,
COST_EXCEEDED,
AUTH_FAILED,
MODEL_UNAVAILABLE,
ACCOUNT_NOT_ENTITLED,
ANSWER_TOO_LARGE,
] {
assert_eq!(item_class(code), Some(ErrorClass::Permanent), "{code}");
}
}
#[test]
fn batch_level_codes_are_never_stored_and_stop_the_call() {
for code in [
AUTH_FAILED,
MODEL_UNAVAILABLE,
ACCOUNT_LIMIT_EXHAUSTED,
ACCOUNT_NOT_ENTITLED,
] {
let error = ItemError::for_code(code, "m");
assert!(!error.is_stored(), "{code}");
assert!(error.stops_the_call(), "{code}");
}
let limited = ItemError::for_code(RATE_LIMITED, "m");
assert!(limited.is_stored() && limited.stops_the_call());
for code in [
PROVIDER_ERROR,
PROVIDER_REFUSED,
INVALID_ITEM,
COST_EXCEEDED,
ANSWER_TOO_LARGE,
] {
let error = ItemError::for_code(code, "m");
assert!(error.is_stored(), "{code}");
assert!(!error.stops_the_call(), "{code}");
}
}
#[test]
fn an_unknown_class_is_kept_and_acted_on_as_permanent() {
let error: ItemError = serde_json::from_str(
r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#,
)
.unwrap();
assert_eq!(error.class, ErrorClass::Other("degraded".into()));
assert!(!error.is_retried_on_resend());
assert_eq!(
serde_json::to_string(&error).unwrap(),
r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#
);
}
#[test]
fn answer_too_large_carries_field_limit_and_size() {
let error = ItemError::answer_too_large("fields.total", 4096, 5000);
assert_eq!(
serde_json::to_string(&error).unwrap(),
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}}"#
);
assert!(error.is_stored());
assert!(!error.is_retried_on_resend());
}
#[test]
fn model_not_extractor_never_invents_a_kind() {
let kindless = Refusal::model_not_extractor("m", None);
assert_eq!(kindless.code, MODEL_NOT_EXTRACTOR);
assert_eq!(kindless.detail.model.as_deref(), Some("m"));
assert_eq!(kindless.detail.kind, None);
let chat = Refusal::model_not_extractor("m", Some("chat"));
assert_eq!(chat.detail.kind.as_deref(), Some("chat"));
assert_eq!(chat.detail.class, ErrorClass::Permanent);
}
#[test]
fn refusals_carry_their_class_and_field() {
let refusal = Refusal::over_limit("items", 100, "too many items");
assert_eq!(refusal.code, INVALID_PARAMS);
assert_eq!(refusal.field(), Some("items"));
assert_eq!(refusal.detail.limit, Some(100));
let busy = Refusal::batch_in_progress(250);
assert_eq!(busy.detail.class, ErrorClass::Transient);
assert_eq!(busy.detail.retry_after_ms, Some(250));
assert_eq!(
Refusal::account_limit_exhausted().detail.class,
ErrorClass::Transient
);
}
}