openrtc/
service_errors.rs1use serde::Serialize;
3use serde_json::Value;
4
5#[path = "service_error_codes.rs"]
6mod codes;
7pub use codes::{ERROR_DOCUMENTATION_URL, SERVICE_ERROR_CODES};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ServiceError {
12 pub code: String,
13 pub retryable: bool,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub scope: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub operation: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub request_id: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub retry_after_ms: Option<u64>,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub reset_at: Option<u64>,
25 pub documentation_url: &'static str,
26}
27
28impl ServiceError {
29 #[cfg(not(target_arch = "wasm32"))]
32 pub fn from_error(error: &anyhow::Error) -> Option<&Self> {
33 error.chain().find_map(|source| {
34 source.downcast_ref::<crate::native::ControlPlaneHttpError>()
35 .and_then(|error| error.service_error.as_ref())
36 .or_else(|| source.downcast_ref::<crate::native_coordination_gateway::GatewayConnectError>()
37 .and_then(|error| error.service_error.as_ref()))
38 })
39 }
40
41 pub fn from_value(value: &Value) -> Option<Self> {
43 let code = value.get("code")?.as_str()?;
44 let normalized = code.trim().to_ascii_lowercase();
45 let alias = codes::LEGACY_SERVICE_ERROR_ALIASES.iter()
46 .find(|(legacy, _, _)| *legacy == normalized);
47 if !SERVICE_ERROR_CODES.contains(&code) && alias.is_none() {
48 return None;
49 }
50 let bounded = |key: &str, maximum: usize, valid: fn(u8) -> bool| {
51 value
52 .get(key)
53 .and_then(Value::as_str)
54 .filter(|v| !v.is_empty() && v.len() <= maximum && v.bytes().all(valid))
55 .map(str::to_owned)
56 };
57 let timing = |key: &str| {
58 value
59 .get(key)
60 .and_then(Value::as_u64)
61 .filter(|v| *v <= 9_007_199_254_740_991)
62 };
63 Some(Self {
64 code: alias.map_or(code, |(_, canonical, _)| canonical).to_owned(),
65 retryable: value
68 .get("retryable")
69 .and_then(Value::as_bool)
70 .unwrap_or(alias.is_some()),
71 scope: alias.map(|(_, _, scope)| (*scope).to_owned()).or_else(|| value
72 .get("scope")
73 .and_then(Value::as_str)
74 .filter(|v| ["account", "app", "principal", "ip", "avenue", "platform"].contains(v))
75 .map(str::to_owned)),
76 operation: bounded("operation", 100, |b| {
77 b.is_ascii_lowercase() || b.is_ascii_digit() || b".-".contains(&b)
78 }),
79 request_id: bounded("requestId", 160, |b| {
80 b.is_ascii_alphanumeric() || b"_.:-".contains(&b)
81 }),
82 retry_after_ms: timing("retryAfterMs"),
83 reset_at: timing("resetAt"),
84 documentation_url: ERROR_DOCUMENTATION_URL,
85 })
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use serde_json::json;
93
94 #[test]
95 fn legacy_edge_denials_keep_canonical_scope_and_recovery_metadata() {
96 for (legacy, canonical, scope) in codes::LEGACY_SERVICE_ERROR_ALIASES {
97 let minimal = ServiceError::from_value(&json!({"code": legacy})).unwrap();
98 assert_eq!(minimal.code, *canonical);
99 assert_eq!(minimal.scope.as_deref(), Some(*scope));
100 assert!(minimal.retryable);
101 assert_eq!(minimal.retry_after_ms, None);
102 assert_eq!(minimal.reset_at, None);
103 let error = ServiceError::from_value(&json!({"code": legacy, "retryable": false,
104 "scope": "app", "operation": "gateway.grant.issue", "requestId": "legacy-1",
105 "retryAfterMs": 60000, "resetAt": 1800000000000_u64,
106 "documentationUrl": "https://untrusted.invalid", "providerCost": 99})).unwrap();
107 let serialized = serde_json::to_value(error).unwrap();
108 assert_eq!(serialized["code"], *canonical);
109 assert_eq!(serialized["scope"], *scope);
110 assert_eq!(serialized["retryable"], false);
111 assert_eq!(serialized["retryAfterMs"], 60000);
112 assert_eq!(serialized["resetAt"], 1800000000000_u64);
113 assert_eq!(serialized["requestId"], "legacy-1");
114 assert_eq!(serialized["documentationUrl"], ERROR_DOCUMENTATION_URL);
115 assert!(serialized.get("providerCost").is_none());
116 }
117 }
118
119 #[test]
120 fn service_error_metadata_is_bounded_and_preserves_all_codes() {
121 for code in SERVICE_ERROR_CODES {
122 let error = ServiceError::from_value(&json!({"code": code, "retryable": true,
123 "scope": "app", "operation": "gateway.grant.issue", "requestId": "request-1",
124 "retryAfterMs": 2300, "resetAt": 1800000000000_u64,
125 "documentationUrl": "https://untrusted.invalid", "providerCost": 99}))
126 .unwrap();
127 assert_eq!(error.code, *code);
128 assert!(error.retryable);
129 assert_eq!(error.retry_after_ms, Some(2300));
130 assert_eq!(error.reset_at, Some(1800000000000));
131 let serialized = serde_json::to_value(error).unwrap();
132 assert_eq!(serialized["documentationUrl"], ERROR_DOCUMENTATION_URL);
133 assert!(serialized.get("providerCost").is_none());
134 }
135 let invalid =
136 ServiceError::from_value(&json!({"code": "app-rate-limited", "scope": "private",
137 "operation": "bad\nvalue", "requestId": "bad token", "retryAfterMs": -1,
138 "resetAt": 9007199254740992_u64}))
139 .unwrap();
140 assert!(!invalid.retryable);
141 assert!(
142 invalid.scope.is_none() && invalid.operation.is_none() && invalid.request_id.is_none()
143 );
144 assert!(invalid.retry_after_ms.is_none() && invalid.reset_at.is_none());
145 assert!(ServiceError::from_value(&json!({"code": "unknown"})).is_none());
146 }
147}