use serde::Serialize;
use serde_json::Value;
#[path = "service_error_codes.rs"]
mod codes;
pub use codes::{ERROR_DOCUMENTATION_URL, SERVICE_ERROR_CODES};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceError {
pub code: String,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reset_at: Option<u64>,
pub documentation_url: &'static str,
}
impl ServiceError {
#[cfg(not(target_arch = "wasm32"))]
pub fn from_error(error: &anyhow::Error) -> Option<&Self> {
error.chain().find_map(|source| {
source.downcast_ref::<crate::native::ControlPlaneHttpError>()
.and_then(|error| error.service_error.as_ref())
.or_else(|| source.downcast_ref::<crate::native_coordination_gateway::GatewayConnectError>()
.and_then(|error| error.service_error.as_ref()))
})
}
pub fn from_value(value: &Value) -> Option<Self> {
let code = value.get("code")?.as_str()?;
let normalized = code.trim().to_ascii_lowercase();
let alias = codes::LEGACY_SERVICE_ERROR_ALIASES.iter()
.find(|(legacy, _, _)| *legacy == normalized);
if !SERVICE_ERROR_CODES.contains(&code) && alias.is_none() {
return None;
}
let bounded = |key: &str, maximum: usize, valid: fn(u8) -> bool| {
value
.get(key)
.and_then(Value::as_str)
.filter(|v| !v.is_empty() && v.len() <= maximum && v.bytes().all(valid))
.map(str::to_owned)
};
let timing = |key: &str| {
value
.get(key)
.and_then(Value::as_u64)
.filter(|v| *v <= 9_007_199_254_740_991)
};
Some(Self {
code: alias.map_or(code, |(_, canonical, _)| canonical).to_owned(),
retryable: value
.get("retryable")
.and_then(Value::as_bool)
.unwrap_or(alias.is_some()),
scope: alias.map(|(_, _, scope)| (*scope).to_owned()).or_else(|| value
.get("scope")
.and_then(Value::as_str)
.filter(|v| ["account", "app", "principal", "ip", "avenue", "platform"].contains(v))
.map(str::to_owned)),
operation: bounded("operation", 100, |b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b".-".contains(&b)
}),
request_id: bounded("requestId", 160, |b| {
b.is_ascii_alphanumeric() || b"_.:-".contains(&b)
}),
retry_after_ms: timing("retryAfterMs"),
reset_at: timing("resetAt"),
documentation_url: ERROR_DOCUMENTATION_URL,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn legacy_edge_denials_keep_canonical_scope_and_recovery_metadata() {
for (legacy, canonical, scope) in codes::LEGACY_SERVICE_ERROR_ALIASES {
let minimal = ServiceError::from_value(&json!({"code": legacy})).unwrap();
assert_eq!(minimal.code, *canonical);
assert_eq!(minimal.scope.as_deref(), Some(*scope));
assert!(minimal.retryable);
assert_eq!(minimal.retry_after_ms, None);
assert_eq!(minimal.reset_at, None);
let error = ServiceError::from_value(&json!({"code": legacy, "retryable": false,
"scope": "app", "operation": "gateway.grant.issue", "requestId": "legacy-1",
"retryAfterMs": 60000, "resetAt": 1800000000000_u64,
"documentationUrl": "https://untrusted.invalid", "providerCost": 99})).unwrap();
let serialized = serde_json::to_value(error).unwrap();
assert_eq!(serialized["code"], *canonical);
assert_eq!(serialized["scope"], *scope);
assert_eq!(serialized["retryable"], false);
assert_eq!(serialized["retryAfterMs"], 60000);
assert_eq!(serialized["resetAt"], 1800000000000_u64);
assert_eq!(serialized["requestId"], "legacy-1");
assert_eq!(serialized["documentationUrl"], ERROR_DOCUMENTATION_URL);
assert!(serialized.get("providerCost").is_none());
}
}
#[test]
fn service_error_metadata_is_bounded_and_preserves_all_codes() {
for code in SERVICE_ERROR_CODES {
let error = ServiceError::from_value(&json!({"code": code, "retryable": true,
"scope": "app", "operation": "gateway.grant.issue", "requestId": "request-1",
"retryAfterMs": 2300, "resetAt": 1800000000000_u64,
"documentationUrl": "https://untrusted.invalid", "providerCost": 99}))
.unwrap();
assert_eq!(error.code, *code);
assert!(error.retryable);
assert_eq!(error.retry_after_ms, Some(2300));
assert_eq!(error.reset_at, Some(1800000000000));
let serialized = serde_json::to_value(error).unwrap();
assert_eq!(serialized["documentationUrl"], ERROR_DOCUMENTATION_URL);
assert!(serialized.get("providerCost").is_none());
}
let invalid =
ServiceError::from_value(&json!({"code": "app-rate-limited", "scope": "private",
"operation": "bad\nvalue", "requestId": "bad token", "retryAfterMs": -1,
"resetAt": 9007199254740992_u64}))
.unwrap();
assert!(!invalid.retryable);
assert!(
invalid.scope.is_none() && invalid.operation.is_none() && invalid.request_id.is_none()
);
assert!(invalid.retry_after_ms.is_none() && invalid.reset_at.is_none());
assert!(ServiceError::from_value(&json!({"code": "unknown"})).is_none());
}
}