Skip to main content

openrtc/
service_errors.rs

1//! Public service denial metadata. This describes recovery; it never schedules it.
2use 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    /// UTC Unix milliseconds, compatible with the browser's safe integer range.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub reset_at: Option<u64>,
25    pub documentation_url: &'static str,
26}
27
28impl ServiceError {
29    /// Find recognized service metadata through native runtime context layers.
30    /// The returned view contains no response body, credentials, or provider costs.
31    #[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    /// Ignore unknown service codes and do not retain arbitrary response fields.
42    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            // Canonical denials without an explicit retry grant are terminal.
66            // Known legacy throttles retain their old retryable behavior.
67            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}