1use core::fmt;
2
3use serde::{Deserialize, Serialize};
4
5pub const CLOUDKIT_ERROR_DOMAIN: &str = "CKErrorDomain";
7pub const CLOUDKIT_BRIDGE_ERROR_DOMAIN: &str = "CloudKitBridge";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[non_exhaustive]
13pub enum CloudKitErrorCode {
14 BridgeInvalidArgument,
16 BridgeFailure,
18 BridgeTimedOut,
20 BridgeDefaultContainerUnavailable,
22 InternalError,
24 PartialFailure,
26 NetworkUnavailable,
28 NetworkFailure,
30 BadContainer,
32 ServiceUnavailable,
34 RequestRateLimited,
36 MissingEntitlement,
38 NotAuthenticated,
40 PermissionFailure,
42 UnknownItem,
44 InvalidArguments,
46 ServerRecordChanged,
48 OperationCancelled,
50 BadDatabase,
52 ZoneNotFound,
54 LimitExceeded,
56 Unknown(i64),
58}
59
60#[derive(Debug, Clone, PartialEq)]
62pub struct CloudKitError {
63 pub domain: String,
65 pub code: i64,
67 pub message: String,
69 pub retry_after_seconds: Option<f64>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub(crate) struct ErrorPayload {
76 pub domain: String,
77 pub code: i64,
78 pub message: String,
79 pub retry_after_seconds: Option<f64>,
80}
81
82impl CloudKitError {
83 pub(crate) fn from_payload(payload: ErrorPayload) -> Self {
84 Self {
85 domain: payload.domain,
86 code: payload.code,
87 message: payload.message,
88 retry_after_seconds: payload.retry_after_seconds,
89 }
90 }
91
92 pub(crate) fn bridge(code: i64, message: impl Into<String>) -> Self {
93 Self {
94 domain: CLOUDKIT_BRIDGE_ERROR_DOMAIN.into(),
95 code,
96 message: message.into(),
97 retry_after_seconds: None,
98 }
99 }
100
101 #[must_use]
103 pub fn kind(&self) -> CloudKitErrorCode {
104 if self.domain == CLOUDKIT_BRIDGE_ERROR_DOMAIN {
105 return match self.code {
106 -1 => CloudKitErrorCode::BridgeInvalidArgument,
107 -2 => CloudKitErrorCode::BridgeFailure,
108 -3 => CloudKitErrorCode::BridgeTimedOut,
109 -4 => CloudKitErrorCode::BridgeDefaultContainerUnavailable,
110 other => CloudKitErrorCode::Unknown(other),
111 };
112 }
113
114 if self.domain != CLOUDKIT_ERROR_DOMAIN {
115 return CloudKitErrorCode::Unknown(self.code);
116 }
117
118 match self.code {
119 1 => CloudKitErrorCode::InternalError,
120 2 => CloudKitErrorCode::PartialFailure,
121 3 => CloudKitErrorCode::NetworkUnavailable,
122 4 => CloudKitErrorCode::NetworkFailure,
123 5 => CloudKitErrorCode::BadContainer,
124 6 => CloudKitErrorCode::ServiceUnavailable,
125 7 => CloudKitErrorCode::RequestRateLimited,
126 8 => CloudKitErrorCode::MissingEntitlement,
127 9 => CloudKitErrorCode::NotAuthenticated,
128 10 => CloudKitErrorCode::PermissionFailure,
129 11 => CloudKitErrorCode::UnknownItem,
130 12 => CloudKitErrorCode::InvalidArguments,
131 14 => CloudKitErrorCode::ServerRecordChanged,
132 20 => CloudKitErrorCode::OperationCancelled,
133 24 => CloudKitErrorCode::BadDatabase,
134 26 => CloudKitErrorCode::ZoneNotFound,
135 27 => CloudKitErrorCode::LimitExceeded,
136 other => CloudKitErrorCode::Unknown(other),
137 }
138 }
139
140 #[must_use]
142 pub fn is_entitlement_or_account_issue(&self) -> bool {
143 matches!(
144 self.kind(),
145 CloudKitErrorCode::BridgeDefaultContainerUnavailable
146 | CloudKitErrorCode::BadContainer
147 | CloudKitErrorCode::MissingEntitlement
148 | CloudKitErrorCode::NotAuthenticated
149 | CloudKitErrorCode::PermissionFailure
150 )
151 }
152
153 #[must_use]
155 pub fn is_retryable(&self) -> bool {
156 matches!(
157 self.kind(),
158 CloudKitErrorCode::NetworkUnavailable
159 | CloudKitErrorCode::NetworkFailure
160 | CloudKitErrorCode::ServiceUnavailable
161 | CloudKitErrorCode::RequestRateLimited
162 )
163 }
164
165 #[must_use]
167 pub fn is_missing_entitlement(&self) -> bool {
168 matches!(self.kind(), CloudKitErrorCode::MissingEntitlement)
169 }
170
171 #[must_use]
173 pub fn is_not_authenticated(&self) -> bool {
174 matches!(self.kind(), CloudKitErrorCode::NotAuthenticated)
175 }
176}
177
178impl fmt::Display for CloudKitError {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 write!(f, "{} ({}) [{}]", self.message, self.code, self.domain)
181 }
182}
183
184impl std::error::Error for CloudKitError {}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn bridge_kind_maps_known_codes() {
192 let cases = [
193 (-1, CloudKitErrorCode::BridgeInvalidArgument),
194 (-2, CloudKitErrorCode::BridgeFailure),
195 (-3, CloudKitErrorCode::BridgeTimedOut),
196 (-4, CloudKitErrorCode::BridgeDefaultContainerUnavailable),
197 ];
198
199 for (code, expected) in cases {
200 let error = CloudKitError::bridge(code, "bridge failure");
201 assert_eq!(error.kind(), expected);
202 }
203 }
204
205 #[test]
206 fn framework_kind_maps_known_codes() {
207 let cases = [
208 (1, CloudKitErrorCode::InternalError),
209 (8, CloudKitErrorCode::MissingEntitlement),
210 (9, CloudKitErrorCode::NotAuthenticated),
211 (27, CloudKitErrorCode::LimitExceeded),
212 ];
213
214 for (code, expected) in cases {
215 let error = CloudKitError {
216 domain: CLOUDKIT_ERROR_DOMAIN.into(),
217 code,
218 message: "framework failure".into(),
219 retry_after_seconds: Some(1.5),
220 };
221 assert_eq!(error.kind(), expected);
222 }
223 }
224
225 #[test]
226 fn unknown_domain_and_codes_stay_unknown() {
227 let foreign_error = CloudKitError {
228 domain: "OtherDomain".into(),
229 code: 999,
230 message: "foreign".into(),
231 retry_after_seconds: None,
232 };
233 let bridge_error = CloudKitError::bridge(-99, "unknown bridge code");
234
235 assert_eq!(foreign_error.kind(), CloudKitErrorCode::Unknown(999));
236 assert_eq!(bridge_error.kind(), CloudKitErrorCode::Unknown(-99));
237 }
238
239 #[test]
240 fn convenience_predicates_match_expected_kinds() {
241 let missing_entitlement = CloudKitError {
242 domain: CLOUDKIT_ERROR_DOMAIN.into(),
243 code: 8,
244 message: "missing entitlement".into(),
245 retry_after_seconds: None,
246 };
247 let not_authenticated = CloudKitError {
248 domain: CLOUDKIT_ERROR_DOMAIN.into(),
249 code: 9,
250 message: "not authenticated".into(),
251 retry_after_seconds: None,
252 };
253 let retryable = CloudKitError {
254 domain: CLOUDKIT_ERROR_DOMAIN.into(),
255 code: 7,
256 message: "slow down".into(),
257 retry_after_seconds: Some(2.5),
258 };
259
260 assert!(missing_entitlement.is_entitlement_or_account_issue());
261 assert!(missing_entitlement.is_missing_entitlement());
262 assert!(!missing_entitlement.is_not_authenticated());
263 assert!(not_authenticated.is_entitlement_or_account_issue());
264 assert!(not_authenticated.is_not_authenticated());
265 assert!(retryable.is_retryable());
266 }
267
268 #[test]
269 fn from_payload_and_display_preserve_fields() {
270 let error = CloudKitError::from_payload(ErrorPayload {
271 domain: CLOUDKIT_ERROR_DOMAIN.into(),
272 code: 7,
273 message: "retry later".into(),
274 retry_after_seconds: Some(2.5),
275 });
276
277 assert_eq!(error.retry_after_seconds, Some(2.5));
278 assert_eq!(error.to_string(), "retry later (7) [CKErrorDomain]");
279 }
280}