use crate::engine::{ErrorClass, HandlerError};
pub const MAX_GUEST_MESSAGE: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
CallerInput,
GuestError,
BadCode,
RequestSize,
ResponseSize,
Permit,
Instances,
Fuel,
Memory,
Timeout,
Trap,
BadResult,
}
impl Category {
pub fn as_str(self) -> &'static str {
match self {
Self::CallerInput => "caller_input",
Self::GuestError => "guest_error",
Self::BadCode => "bad_code",
Self::RequestSize => "request_size",
Self::ResponseSize => "response_size",
Self::Permit => "permit",
Self::Instances => "instances",
Self::Fuel => "fuel",
Self::Memory => "memory",
Self::Timeout => "timeout",
Self::Trap => "trap",
Self::BadResult => "bad_result",
}
}
pub fn class(self) -> ErrorClass {
match self {
Self::CallerInput => ErrorClass::CallerInput,
Self::GuestError | Self::BadCode | Self::Trap | Self::BadResult => ErrorClass::Backend,
Self::RequestSize
| Self::ResponseSize
| Self::Permit
| Self::Instances
| Self::Fuel
| Self::Memory => ErrorClass::Limit,
Self::Timeout => ErrorClass::Timeout,
}
}
}
#[derive(Debug)]
pub struct Failure {
pub category: Category,
pub message: String,
pub detail: Option<String>,
}
impl Failure {
pub fn host(category: Category, message: impl Into<String>) -> Self {
Self {
category,
message: message.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl std::fmt::Display) -> Self {
self.detail = Some(detail.to_string());
self
}
pub fn guest(code: &str, caller_input: bool, message: &str) -> Self {
let message = cap(message);
if !super::manifest::is_valid_error_code(code) {
return Self {
category: Category::BadCode,
message: "the plugin returned an error code outside the ABI grammar \
(^[A-Z][A-Z0-9_]{0,63}$)"
.to_string(),
detail: Some(format!("code={code:?} message={message:?}")),
};
}
Self {
category: if caller_input {
Category::CallerInput
} else {
Category::GuestError
},
message: format!("{code}: {message}"),
detail: None,
}
}
pub fn from_wasmtime(err: &wasmtime::Error, memory_refused: bool) -> Self {
if let Some(trap) = err.downcast_ref::<wasmtime::Trap>() {
let category = match trap {
wasmtime::Trap::OutOfFuel => Category::Fuel,
wasmtime::Trap::Interrupt => Category::Timeout,
_ if memory_refused => Category::Memory,
_ => Category::Trap,
};
return Self {
category,
message: match category {
Category::Fuel => "the invocation exhausted its fuel backstop".to_string(),
Category::Timeout => "the invocation exceeded its deadline".to_string(),
Category::Memory => "the invocation exceeded its memory limit".to_string(),
_ => "the plugin trapped".to_string(),
},
detail: Some(format!("trap: {trap:?}")),
};
}
if memory_refused {
return Self::host(Category::Memory, "the invocation exceeded its memory limit")
.with_detail(err);
}
let text = err.to_string();
if text.contains("concurrent") && text.contains("limit") {
return Self::host(
Category::Instances,
"no plugin instance was available; the pool is full",
)
.with_detail(text);
}
Self::host(Category::Trap, "the plugin failed to run").with_detail(text)
}
pub fn into_handler_error(self) -> HandlerError {
let mut e = HandlerError::new(self.category.class(), self.message);
if let Some(detail) = self.detail {
e = e.with_detail(detail);
}
e
}
}
fn cap(message: &str) -> String {
let clean: String = message
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if clean.chars().count() <= MAX_GUEST_MESSAGE {
return clean;
}
let mut out: String = clean.chars().take(MAX_GUEST_MESSAGE).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_category_has_a_class_and_a_stable_label() {
let all = [
Category::CallerInput,
Category::GuestError,
Category::BadCode,
Category::RequestSize,
Category::ResponseSize,
Category::Permit,
Category::Instances,
Category::Fuel,
Category::Memory,
Category::Timeout,
Category::Trap,
Category::BadResult,
];
let mut labels: Vec<&str> = all.iter().map(|c| c.as_str()).collect();
labels.sort_unstable();
labels.dedup();
assert_eq!(labels.len(), all.len(), "labels must be distinct");
assert!(Category::Timeout.class().is_retryable());
for c in all {
if c != Category::Timeout {
assert!(!c.class().is_retryable(), "{c:?} must not be retryable");
}
}
}
#[test]
fn a_guest_error_keeps_its_code_and_caps_its_message() {
let f = Failure::guest("BAD_MESSAGE", true, "not ISO 8583");
assert_eq!(f.category, Category::CallerInput);
assert_eq!(f.message, "BAD_MESSAGE: not ISO 8583");
let long = "x".repeat(2000);
let f = Failure::guest("BOOM", false, &long);
assert_eq!(f.category, Category::GuestError);
assert!(f.message.chars().count() < 600);
assert!(f.message.ends_with('…'));
let f = Failure::guest("E", false, "a\nb\u{1b}c");
assert_eq!(f.message, "E: a b c");
}
#[test]
fn a_code_outside_the_grammar_is_the_guests_bug() {
let f = Failure::guest("bad code", true, "x");
assert_eq!(f.category, Category::BadCode);
assert_eq!(f.category.class(), ErrorClass::Backend);
assert!(!f.message.contains("bad code"), "{}", f.message);
assert!(f.detail.as_deref().is_some_and(|d| d.contains("bad code")));
}
#[test]
fn a_trap_after_a_refused_growth_is_a_limit() {
let err = wasmtime::Error::from(wasmtime::Trap::UnreachableCodeReached);
assert_eq!(
Failure::from_wasmtime(&err, true).category,
Category::Memory
);
assert_eq!(Failure::from_wasmtime(&err, false).category, Category::Trap);
let fuel = wasmtime::Error::from(wasmtime::Trap::OutOfFuel);
assert_eq!(
Failure::from_wasmtime(&fuel, false).category,
Category::Fuel
);
let epoch = wasmtime::Error::from(wasmtime::Trap::Interrupt);
assert_eq!(
Failure::from_wasmtime(&epoch, false).category,
Category::Timeout
);
let f = Failure::from_wasmtime(&err, false);
assert!(
f.detail
.as_deref()
.is_some_and(|d| d.contains("Unreachable"))
);
assert!(
!f.message.contains("Unreachable"),
"internals stay out of the client message"
);
}
}