1use crate::xrpc::EncodeError;
4use alloc::boxed::Box;
5use alloc::string::ToString;
6use bytes::Bytes;
7use smol_str::SmolStr;
8
9#[cfg(feature = "std")]
10use miette::Diagnostic;
11
12pub type BoxError = Box<dyn core::error::Error + Send + Sync + 'static>;
14
15#[derive(Debug, thiserror::Error)]
17#[cfg_attr(feature = "std", derive(Diagnostic))]
18#[error("{kind}")]
19pub struct ClientError {
20 #[cfg_attr(feature = "std", diagnostic_source)]
21 kind: ClientErrorKind,
22 #[source]
23 source: Option<BoxError>,
24 #[cfg_attr(feature = "std", help)]
25 help: Option<SmolStr>,
26 context: Option<SmolStr>,
27 url: Option<SmolStr>,
28 details: Option<SmolStr>,
29 location: Option<SmolStr>,
30}
31
32#[derive(Debug, thiserror::Error)]
34#[cfg_attr(feature = "std", derive(Diagnostic))]
35#[non_exhaustive]
36pub enum ClientErrorKind {
37 #[error("transport error")]
39 #[cfg_attr(feature = "std", diagnostic(code(jacquard::client::transport)))]
40 Transport,
41
42 #[error("invalid request: {0}")]
44 #[cfg_attr(
45 feature = "std",
46 diagnostic(
47 code(jacquard::client::invalid_request),
48 help("check request parameters and format")
49 )
50 )]
51 InvalidRequest(SmolStr),
52
53 #[error("encode error: {0}")]
55 #[cfg_attr(
56 feature = "std",
57 diagnostic(
58 code(jacquard::client::encode),
59 help("check request body format and encoding")
60 )
61 )]
62 Encode(SmolStr),
63
64 #[error("decode error: {0}")]
66 #[cfg_attr(
67 feature = "std",
68 diagnostic(
69 code(jacquard::client::decode),
70 help("check response format and encoding")
71 )
72 )]
73 Decode(SmolStr),
74
75 #[error("HTTP {status}")]
77 #[cfg_attr(feature = "std", diagnostic(code(jacquard::client::http)))]
78 Http {
79 status: http::StatusCode,
81 },
82
83 #[error("auth error: {0}")]
85 #[cfg_attr(feature = "std", diagnostic(code(jacquard::client::auth)))]
86 Auth(AuthError),
87
88 #[error("identity resolution failed")]
90 #[cfg_attr(
91 feature = "std",
92 diagnostic(
93 code(jacquard::client::identity_resolution),
94 help("check handle/DID is valid and network is accessible")
95 )
96 )]
97 IdentityResolution,
98
99 #[error("storage error")]
101 #[cfg_attr(
102 feature = "std",
103 diagnostic(
104 code(jacquard::client::storage),
105 help("check storage backend is accessible and has sufficient permissions")
106 )
107 )]
108 Storage,
109}
110
111impl ClientError {
112 pub fn new(kind: ClientErrorKind, source: Option<BoxError>) -> Self {
114 Self {
115 kind,
116 source,
117 help: None,
118 context: None,
119 url: None,
120 details: None,
121 location: None,
122 }
123 }
124
125 pub fn kind(&self) -> &ClientErrorKind {
127 &self.kind
128 }
129
130 pub fn source_err(&self) -> Option<&BoxError> {
132 self.source.as_ref()
133 }
134
135 pub fn status(&self) -> Option<http::StatusCode> {
137 match &self.kind {
138 ClientErrorKind::Http { status } => Some(*status),
139 _ => None,
140 }
141 }
142
143 pub fn is_auth(&self) -> bool {
145 matches!(self.kind, ClientErrorKind::Auth(_))
146 || self.status() == Some(http::StatusCode::UNAUTHORIZED)
147 }
148
149 pub fn is_not_found(&self) -> bool {
151 self.status() == Some(http::StatusCode::NOT_FOUND)
152 }
153
154 pub fn is_conflict(&self) -> bool {
156 self.status() == Some(http::StatusCode::CONFLICT)
157 }
158
159 pub fn context(&self) -> Option<&str> {
161 self.context.as_ref().map(|s| s.as_str())
162 }
163
164 pub fn url(&self) -> Option<&str> {
166 self.url.as_ref().map(|s| s.as_str())
167 }
168
169 pub fn details(&self) -> Option<&str> {
171 self.details.as_ref().map(|s| s.as_str())
172 }
173
174 pub fn location(&self) -> Option<&str> {
176 self.location.as_ref().map(|s| s.as_str())
177 }
178
179 pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self {
181 self.help = Some(help.into());
182 self
183 }
184
185 pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
187 self.context = Some(context.into());
188 self
189 }
190
191 pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
193 self.url = Some(url.into());
194 self
195 }
196
197 pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self {
199 self.details = Some(details.into());
200 self
201 }
202
203 pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self {
205 self.location = Some(location.into());
206 self
207 }
208
209 pub fn append_context(mut self, additional: impl AsRef<str>) -> Self {
214 self.context = Some(match self.context.take() {
215 Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()),
216 None => additional.as_ref().into(),
217 });
218 self
219 }
220
221 pub fn for_nsid(self, nsid: &str) -> Self {
225 self.append_context(smol_str::format_smolstr!("[{}]", nsid))
226 }
227
228 pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self {
232 self.append_context(smol_str::format_smolstr!(
233 "{} [{}]",
234 operation,
235 collection_nsid
236 ))
237 }
238
239 pub fn transport(source: impl core::error::Error + Send + Sync + 'static) -> Self {
243 Self::new(ClientErrorKind::Transport, Some(Box::new(source)))
244 }
245
246 pub fn invalid_request(msg: impl Into<SmolStr>) -> Self {
248 Self::new(ClientErrorKind::InvalidRequest(msg.into()), None)
249 }
250
251 pub fn encode(msg: impl Into<SmolStr>) -> Self {
253 Self::new(ClientErrorKind::Encode(msg.into()), None)
254 }
255
256 pub fn decode(msg: impl Into<SmolStr>) -> Self {
258 Self::new(ClientErrorKind::Decode(msg.into()), None)
259 }
260
261 pub fn http(status: http::StatusCode, body: Option<Bytes>) -> Self {
263 let http_err = HttpError { status, body };
264 Self::new(ClientErrorKind::Http { status }, Some(Box::new(http_err)))
265 }
266
267 pub fn auth(auth_error: AuthError) -> Self {
269 Self::new(ClientErrorKind::Auth(auth_error), None)
270 }
271
272 pub fn identity_resolution(source: impl core::error::Error + Send + Sync + 'static) -> Self {
274 Self::new(ClientErrorKind::IdentityResolution, Some(Box::new(source)))
275 }
276
277 pub fn storage(source: impl core::error::Error + Send + Sync + 'static) -> Self {
279 Self::new(ClientErrorKind::Storage, Some(Box::new(source)))
280 }
281}
282
283pub type XrpcResult<T> = Result<T, ClientError>;
285
286#[derive(Debug, thiserror::Error)]
295#[cfg_attr(feature = "std", derive(Diagnostic))]
296#[non_exhaustive]
297pub enum DecodeError {
298 #[error("Failed to deserialize JSON: {0}")]
300 Json(
301 #[from]
302 #[source]
303 serde_json::Error,
304 ),
305 #[cfg(feature = "std")]
307 #[error("Failed to deserialize CBOR: {0}")]
308 CborLocal(
309 #[from]
310 #[source]
311 serde_ipld_dagcbor::DecodeError<std::io::Error>,
312 ),
313 #[error("Failed to deserialize CBOR: {0}")]
315 CborRemote(
316 #[from]
317 #[source]
318 serde_ipld_dagcbor::DecodeError<HttpError>,
319 ),
320 #[error("Failed to deserialize DAG-CBOR: {0}")]
322 DagCborInfallible(
323 #[from]
324 #[source]
325 serde_ipld_dagcbor::DecodeError<core::convert::Infallible>,
326 ),
327 #[cfg(all(feature = "websocket", feature = "std"))]
329 #[error("Failed to deserialize cbor header: {0}")]
330 CborHeader(
331 #[from]
332 #[source]
333 ciborium::de::Error<std::io::Error>,
334 ),
335
336 #[cfg(all(feature = "websocket", not(feature = "std")))]
338 #[error("Failed to deserialize cbor header: {0}")]
339 CborHeader(
340 #[from]
341 #[source]
342 ciborium::de::Error<core::convert::Infallible>,
343 ),
344
345 #[cfg(feature = "websocket")]
347 #[error("Unknown event type: {0}")]
348 UnknownEventType(smol_str::SmolStr),
349}
350
351#[derive(Debug, thiserror::Error)]
353#[cfg_attr(feature = "std", derive(Diagnostic))]
354pub struct HttpError {
355 pub status: http::StatusCode,
357 pub body: Option<Bytes>,
359}
360
361impl core::fmt::Display for HttpError {
362 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
363 write!(f, "HTTP {}", self.status)?;
364 if let Some(body) = &self.body {
365 if let Ok(s) = core::str::from_utf8(body) {
366 write!(f, ":\n{}", s)?;
367 }
368 }
369 Ok(())
370 }
371}
372
373#[derive(Debug, thiserror::Error)]
375#[cfg_attr(feature = "std", derive(Diagnostic))]
376#[non_exhaustive]
377pub enum AuthError {
378 #[error("Access token expired")]
380 TokenExpired,
381
382 #[error("Invalid access token")]
384 InvalidToken,
385
386 #[error("Token refresh failed")]
388 RefreshFailed,
389
390 #[error("No authentication provided, but endpoint requires auth")]
392 NotAuthenticated,
393
394 #[error("DPoP proof construction failed")]
396 DpopProofFailed,
397
398 #[error("DPoP nonce negotiation failed")]
400 DpopNonceFailed,
401
402 #[error("Authentication error: {0:?}")]
404 Other(http::HeaderValue),
405}
406
407impl crate::IntoStatic for AuthError {
408 type Output = AuthError;
409
410 fn into_static(self) -> Self::Output {
411 match self {
412 AuthError::TokenExpired => AuthError::TokenExpired,
413 AuthError::InvalidToken => AuthError::InvalidToken,
414 AuthError::RefreshFailed => AuthError::RefreshFailed,
415 AuthError::NotAuthenticated => AuthError::NotAuthenticated,
416 AuthError::DpopProofFailed => AuthError::DpopProofFailed,
417 AuthError::DpopNonceFailed => AuthError::DpopNonceFailed,
418 AuthError::Other(header) => AuthError::Other(header),
419 }
420 }
421}
422
423impl From<DecodeError> for ClientError {
428 fn from(e: DecodeError) -> Self {
429 let msg = smol_str::format_smolstr!("{:?}", e);
430 Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
431 .with_context("response deserialization failed")
432 }
433}
434
435impl From<HttpError> for ClientError {
436 fn from(e: HttpError) -> Self {
437 Self::http(e.status, e.body)
438 }
439}
440
441impl From<AuthError> for ClientError {
442 fn from(e: AuthError) -> Self {
443 Self::auth(e)
444 }
445}
446
447impl From<EncodeError> for ClientError {
448 fn from(e: EncodeError) -> Self {
449 let msg = smol_str::format_smolstr!("{:?}", e);
450 Self::new(ClientErrorKind::Encode(msg), Some(Box::new(e)))
451 .with_context("request encoding failed")
452 }
453}
454
455#[cfg(feature = "reqwest-client")]
457impl From<reqwest::Error> for ClientError {
458 #[cfg(not(target_arch = "wasm32"))]
459 fn from(e: reqwest::Error) -> Self {
460 Self::transport(e)
461 }
462
463 #[cfg(target_arch = "wasm32")]
464 fn from(e: reqwest::Error) -> Self {
465 Self::transport(e)
466 }
467}
468
469impl From<serde_json::Error> for ClientError {
471 fn from(e: serde_json::Error) -> Self {
472 let msg = smol_str::format_smolstr!("{:?}", e);
473 Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
474 .with_context("JSON deserialization failed")
475 }
476}
477
478#[cfg(feature = "std")]
479impl From<serde_ipld_dagcbor::DecodeError<std::io::Error>> for ClientError {
480 fn from(e: serde_ipld_dagcbor::DecodeError<std::io::Error>) -> Self {
481 let msg = smol_str::format_smolstr!("{:?}", e);
482 Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
483 .with_context("DAG-CBOR deserialization failed (local I/O)")
484 }
485}
486
487impl From<serde_ipld_dagcbor::DecodeError<HttpError>> for ClientError {
488 fn from(e: serde_ipld_dagcbor::DecodeError<HttpError>) -> Self {
489 let msg = smol_str::format_smolstr!("{:?}", e);
490 Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
491 .with_context("DAG-CBOR deserialization failed (remote)")
492 }
493}
494
495impl From<serde_ipld_dagcbor::DecodeError<core::convert::Infallible>> for ClientError {
496 fn from(e: serde_ipld_dagcbor::DecodeError<core::convert::Infallible>) -> Self {
497 let msg = smol_str::format_smolstr!("{:?}", e);
498 Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
499 .with_context("DAG-CBOR deserialization failed (in-memory)")
500 }
501}
502
503#[cfg(all(feature = "websocket", feature = "std"))]
504impl From<ciborium::de::Error<std::io::Error>> for ClientError {
505 fn from(e: ciborium::de::Error<std::io::Error>) -> Self {
506 let msg = smol_str::format_smolstr!("{:?}", e);
507 Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
508 .with_context("CBOR header deserialization failed")
509 }
510}
511
512impl From<crate::session::SessionStoreError> for ClientError {
514 fn from(e: crate::session::SessionStoreError) -> Self {
515 Self::storage(e)
516 }
517}
518
519impl From<crate::deps::fluent_uri::ParseError> for ClientError {
521 fn from(e: crate::deps::fluent_uri::ParseError) -> Self {
522 Self::invalid_request(e.to_string())
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use http::StatusCode;
530
531 #[test]
532 fn client_error_status_from_http() {
533 let err = ClientError::http(StatusCode::CONFLICT, None);
534 assert_eq!(err.status(), Some(StatusCode::CONFLICT));
535 }
536
537 #[test]
538 fn client_error_status_none_for_non_http() {
539 let err = ClientError::invalid_request("bad");
540 assert_eq!(err.status(), None);
541 }
542
543 #[test]
544 fn client_error_is_auth_typed() {
545 let err = ClientError::auth(AuthError::TokenExpired);
546 assert!(err.is_auth());
547 }
548
549 #[test]
550 fn client_error_is_auth_http_401() {
551 let err = ClientError::http(StatusCode::UNAUTHORIZED, None);
552 assert!(err.is_auth());
553 }
554
555 #[test]
556 fn client_error_is_not_found() {
557 assert!(ClientError::http(StatusCode::NOT_FOUND, None).is_not_found());
558 assert!(!ClientError::http(StatusCode::BAD_REQUEST, None).is_not_found());
559 }
560
561 #[test]
562 fn client_error_is_conflict() {
563 assert!(ClientError::http(StatusCode::CONFLICT, None).is_conflict());
564 assert!(!ClientError::http(StatusCode::NOT_FOUND, None).is_conflict());
565 }
566}