use std::error::Error as StdError;
use std::fmt;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_with::{DeserializeFromStr, SerializeDisplay};
use thiserror::Error;
use crate::transport::ConsistencyError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorPayload {
pub code: TrustTaskCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
pub retryable: bool,
#[serde(
rename = "retryAfter",
default,
skip_serializing_if = "Option::is_none"
)]
pub retry_after: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl ErrorPayload {
pub fn new(code: impl Into<TrustTaskCode>) -> Self {
let code = code.into();
let retryable = match &code {
TrustTaskCode::Standard(c) => c.default_retryable(),
TrustTaskCode::Extended { .. } => false,
};
Self {
code,
message: None,
retryable,
retry_after: None,
details: None,
}
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
pub fn with_retryable(mut self, retryable: bool) -> Self {
self.retryable = retryable;
self
}
pub fn with_retry_after(mut self, when: DateTime<Utc>) -> Self {
self.retry_after = Some(when);
self
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
pub fn effective_code(&self) -> StandardCode {
match &self.code {
TrustTaskCode::Standard(c) => *c,
TrustTaskCode::Extended { .. } => StandardCode::TaskFailed,
}
}
pub fn should_retry_at(&self, now: DateTime<Utc>) -> bool {
if !self.retryable {
return false;
}
match self.retry_after {
None => true,
Some(t) => t <= now,
}
}
}
impl From<StandardCode> for ErrorPayload {
fn from(code: StandardCode) -> Self {
Self::new(code)
}
}
impl From<TrustTaskCode> for ErrorPayload {
fn from(code: TrustTaskCode) -> Self {
Self::new(code)
}
}
impl fmt::Display for ErrorPayload {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.message.as_deref() {
Some(msg) => write!(f, "{}: {}", self.code, msg),
None => write!(f, "{}", self.code),
}
}
}
impl StdError for ErrorPayload {}
#[derive(Debug, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
pub enum TrustTaskCode {
Standard(StandardCode),
Extended {
slug: String,
local: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StandardCode {
MalformedRequest,
UnsupportedType,
UnsupportedVersion,
Expired,
ProofRequired,
ProofInvalid,
PermissionDenied,
WrongRecipient,
IdentityMismatch,
TaskFailed,
Unavailable,
InternalError,
}
impl StandardCode {
pub fn default_retryable(self) -> bool {
matches!(
self,
StandardCode::Unavailable | StandardCode::InternalError
)
}
pub fn as_str(self) -> &'static str {
match self {
StandardCode::MalformedRequest => "malformed_request",
StandardCode::UnsupportedType => "unsupported_type",
StandardCode::UnsupportedVersion => "unsupported_version",
StandardCode::Expired => "expired",
StandardCode::ProofRequired => "proof_required",
StandardCode::ProofInvalid => "proof_invalid",
StandardCode::PermissionDenied => "permission_denied",
StandardCode::WrongRecipient => "wrong_recipient",
StandardCode::IdentityMismatch => "identity_mismatch",
StandardCode::TaskFailed => "task_failed",
StandardCode::Unavailable => "unavailable",
StandardCode::InternalError => "internal_error",
}
}
}
impl fmt::Display for StandardCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ParseCodeError {
#[error("error code is empty")]
Empty,
#[error("extension code namespace {0:?} is not a valid slug")]
InvalidNamespace(String),
#[error("extension code local part {0:?} is invalid")]
InvalidLocal(String),
}
impl FromStr for TrustTaskCode {
type Err = ParseCodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Err(ParseCodeError::Empty);
}
match s.split_once(':') {
Some((slug, local)) => {
validate_slug(slug)
.map_err(|_| ParseCodeError::InvalidNamespace(slug.to_string()))?;
validate_local(local)
.map_err(|_| ParseCodeError::InvalidLocal(local.to_string()))?;
Ok(TrustTaskCode::Extended {
slug: slug.to_string(),
local: local.to_string(),
})
}
None => Ok(TrustTaskCode::Standard(parse_standard(s)?)),
}
}
}
impl fmt::Display for TrustTaskCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TrustTaskCode::Standard(c) => f.write_str(c.as_str()),
TrustTaskCode::Extended { slug, local } => write!(f, "{slug}:{local}"),
}
}
}
impl From<StandardCode> for TrustTaskCode {
fn from(code: StandardCode) -> Self {
TrustTaskCode::Standard(code)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RejectReason {
#[error("malformed request: {reason}")]
MalformedRequest {
reason: String,
},
#[error("unsupported type: {type_uri}")]
UnsupportedType {
type_uri: String,
},
#[error("unsupported version: {type_uri}")]
UnsupportedVersion {
type_uri: String,
},
#[error("document expired at {expires_at}")]
Expired {
expires_at: DateTime<Utc>,
},
#[error("proof required but not present")]
ProofRequired,
#[error("proof verification failed: {reason}")]
ProofInvalid {
reason: String,
},
#[error("permission denied: {reason}")]
PermissionDenied {
reason: String,
},
#[error("wrong recipient: in-band {in_band:?}, expected {expected:?}")]
WrongRecipient {
in_band: String,
expected: String,
},
#[error(transparent)]
IdentityMismatch(#[from] ConsistencyError),
#[error("task failed: {reason}")]
TaskFailed {
reason: String,
details: Option<Value>,
},
#[error("temporarily unavailable")]
Unavailable {
retry_after: Option<DateTime<Utc>>,
},
#[error("internal error: {reason}")]
InternalError {
reason: String,
},
}
impl RejectReason {
pub fn code(&self) -> StandardCode {
match self {
RejectReason::MalformedRequest { .. } => StandardCode::MalformedRequest,
RejectReason::UnsupportedType { .. } => StandardCode::UnsupportedType,
RejectReason::UnsupportedVersion { .. } => StandardCode::UnsupportedVersion,
RejectReason::Expired { .. } => StandardCode::Expired,
RejectReason::ProofRequired => StandardCode::ProofRequired,
RejectReason::ProofInvalid { .. } => StandardCode::ProofInvalid,
RejectReason::PermissionDenied { .. } => StandardCode::PermissionDenied,
RejectReason::WrongRecipient { .. } => StandardCode::WrongRecipient,
RejectReason::IdentityMismatch(_) => StandardCode::IdentityMismatch,
RejectReason::TaskFailed { .. } => StandardCode::TaskFailed,
RejectReason::Unavailable { .. } => StandardCode::Unavailable,
RejectReason::InternalError { .. } => StandardCode::InternalError,
}
}
pub fn wire_message(&self) -> String {
match self {
RejectReason::IdentityMismatch(_) => {
"in-band identity does not match transport-derived identity".to_string()
}
RejectReason::WrongRecipient { .. } => {
"document recipient does not identify this consumer".to_string()
}
other => other.to_string(),
}
}
}
impl From<RejectReason> for ErrorPayload {
fn from(reason: RejectReason) -> Self {
let code = reason.code();
let mut payload = ErrorPayload::new(code).with_message(reason.wire_message());
match reason {
RejectReason::Unavailable {
retry_after: Some(when),
} => {
payload = payload.with_retry_after(when);
}
RejectReason::TaskFailed {
details: Some(d), ..
} => {
payload = payload.with_details(d);
}
_ => {}
}
payload
}
}
fn parse_standard(s: &str) -> Result<StandardCode, ParseCodeError> {
Ok(match s {
"malformed_request" => StandardCode::MalformedRequest,
"unsupported_type" => StandardCode::UnsupportedType,
"unsupported_version" => StandardCode::UnsupportedVersion,
"expired" => StandardCode::Expired,
"proof_required" => StandardCode::ProofRequired,
"proof_invalid" => StandardCode::ProofInvalid,
"permission_denied" => StandardCode::PermissionDenied,
"wrong_recipient" => StandardCode::WrongRecipient,
"identity_mismatch" => StandardCode::IdentityMismatch,
"task_failed" => StandardCode::TaskFailed,
"unavailable" => StandardCode::Unavailable,
"internal_error" => StandardCode::InternalError,
other => return Err(ParseCodeError::InvalidLocal(other.to_string())),
})
}
fn validate_slug(slug: &str) -> Result<(), ()> {
if slug.is_empty() {
return Err(());
}
for segment in slug.split('/') {
validate_segment(segment).ok_or(())?;
}
Ok(())
}
fn validate_segment(seg: &str) -> Option<()> {
let mut chars = seg.chars();
let first = chars.next()?;
if !first.is_ascii_lowercase() {
return None;
}
let mut prev_hyphen = false;
for c in chars {
match c {
'a'..='z' | '0'..='9' => prev_hyphen = false,
'-' => {
if prev_hyphen {
return None;
}
prev_hyphen = true;
}
_ => return None,
}
}
if prev_hyphen {
return None;
}
Some(())
}
fn validate_local(local: &str) -> Result<(), ()> {
let mut chars = local.chars();
let first = chars.next().ok_or(())?;
if !first.is_ascii_lowercase() {
return Err(());
}
for c in chars {
match c {
'a'..='z' | '0'..='9' | '_' => {}
_ => return Err(()),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_standard_codes() {
for code in [
StandardCode::MalformedRequest,
StandardCode::UnsupportedType,
StandardCode::UnsupportedVersion,
StandardCode::Expired,
StandardCode::ProofRequired,
StandardCode::ProofInvalid,
StandardCode::PermissionDenied,
StandardCode::WrongRecipient,
StandardCode::IdentityMismatch,
StandardCode::TaskFailed,
StandardCode::Unavailable,
StandardCode::InternalError,
] {
let wire = code.as_str();
let parsed: TrustTaskCode = wire.parse().unwrap();
assert_eq!(parsed, TrustTaskCode::Standard(code));
assert_eq!(parsed.to_string(), wire);
}
}
#[test]
fn parses_extension_code() {
let parsed: TrustTaskCode = "kyc-handoff:document_revoked".parse().unwrap();
assert_eq!(
parsed,
TrustTaskCode::Extended {
slug: "kyc-handoff".to_string(),
local: "document_revoked".to_string(),
}
);
assert_eq!(parsed.to_string(), "kyc-handoff:document_revoked");
}
#[test]
fn parses_hierarchical_extension_code() {
let parsed: TrustTaskCode = "acl/grant:permission_denied".parse().unwrap();
assert!(matches!(
parsed,
TrustTaskCode::Extended { ref slug, ref local }
if slug == "acl/grant" && local == "permission_denied"
));
}
#[test]
fn rejects_invalid_namespace() {
let err: ParseCodeError = "Bad:code".parse::<TrustTaskCode>().unwrap_err();
assert!(matches!(err, ParseCodeError::InvalidNamespace(s) if s == "Bad"));
}
#[test]
fn default_retryable_matches_spec_table() {
assert!(!StandardCode::MalformedRequest.default_retryable());
assert!(!StandardCode::Expired.default_retryable());
assert!(StandardCode::Unavailable.default_retryable());
assert!(StandardCode::InternalError.default_retryable());
}
#[test]
fn serializes_payload_as_json() {
let payload = ErrorPayload {
code: StandardCode::Expired.into(),
message: Some("expired".to_string()),
retryable: false,
retry_after: None,
details: None,
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(
json,
serde_json::json!({
"code": "expired",
"message": "expired",
"retryable": false
})
);
}
#[test]
fn new_payload_takes_default_retryable() {
let p = ErrorPayload::new(StandardCode::Expired);
assert!(!p.retryable);
let p = ErrorPayload::new(StandardCode::Unavailable);
assert!(p.retryable);
}
#[test]
fn builder_methods_compose() {
let when: DateTime<Utc> = "2026-05-17T00:00:00Z".parse().unwrap();
let payload = ErrorPayload::new(StandardCode::Unavailable)
.with_message("nodes draining")
.with_retry_after(when)
.with_details(serde_json::json!({ "drain_eta": "30s" }));
assert_eq!(payload.message.as_deref(), Some("nodes draining"));
assert_eq!(payload.retry_after, Some(when));
assert!(payload.retryable);
assert!(payload.details.is_some());
}
#[test]
fn effective_code_falls_back_for_extensions() {
let payload = ErrorPayload::new(TrustTaskCode::Extended {
slug: "kyc-handoff".into(),
local: "document_revoked".into(),
});
assert_eq!(payload.effective_code(), StandardCode::TaskFailed);
let payload = ErrorPayload::new(StandardCode::Expired);
assert_eq!(payload.effective_code(), StandardCode::Expired);
}
#[test]
fn should_retry_at_respects_retryable_flag() {
let now: DateTime<Utc> = "2026-05-17T12:00:00Z".parse().unwrap();
let p = ErrorPayload::new(StandardCode::Expired);
assert!(!p.should_retry_at(now));
}
#[test]
fn should_retry_at_waits_for_retry_after() {
let later: DateTime<Utc> = "2026-05-17T12:00:00Z".parse().unwrap();
let now: DateTime<Utc> = "2026-05-17T11:59:00Z".parse().unwrap();
let p = ErrorPayload::new(StandardCode::Unavailable).with_retry_after(later);
assert!(!p.should_retry_at(now));
assert!(p.should_retry_at(later));
}
#[test]
fn reject_reason_maps_to_correct_code() {
let cases: &[(RejectReason, StandardCode)] = &[
(
RejectReason::MalformedRequest { reason: "x".into() },
StandardCode::MalformedRequest,
),
(RejectReason::ProofRequired, StandardCode::ProofRequired),
(
RejectReason::Unavailable { retry_after: None },
StandardCode::Unavailable,
),
];
for (reason, expected) in cases {
assert_eq!(reason.code(), *expected);
}
}
#[test]
fn reject_reason_into_payload_carries_message_and_details() {
let payload: ErrorPayload = RejectReason::Expired {
expires_at: "2026-04-12T09:31:00Z".parse().unwrap(),
}
.into();
assert_eq!(payload.code, StandardCode::Expired.into());
assert!(payload.message.as_deref().unwrap().contains("2026-04-12"));
assert!(!payload.retryable);
let payload: ErrorPayload = RejectReason::TaskFailed {
reason: "downstream rejected".into(),
details: Some(serde_json::json!({ "trace": "abc" })),
}
.into();
assert!(payload.details.is_some());
}
#[test]
fn consistency_error_flows_into_reject_reason_via_question_mark() {
fn go() -> Result<(), RejectReason> {
Err(ConsistencyError::IssuerMismatch {
in_band: "did:web:a".into(),
transport: "did:web:b".into(),
})?;
Ok(())
}
let err = go().unwrap_err();
assert_eq!(err.code(), StandardCode::IdentityMismatch);
}
}