A better Rust ATProto crate

big error quality of life pass, adding nextest-inspired claude.md to repo proper

Orual 7e5406c7 22a09f81

-2
.gitignore
··· 4 4 .direnv 5 5 .claude 6 6 /.pre-commit-config.yaml 7 - CLAUDE.md 8 - AGENTS.md 9 7 crates/jacquard-lexicon/target 10 8 /plans 11 9 /docs
+1
AGENTS.md
··· 1 + CLAUDE.md
+269
CLAUDE.md
··· 1 + > with thanks to https://github.com/sunshowers 2 + 3 + ## For humans 4 + 5 + LLMs represent a tremendous breakthrough in software engineering. We welcome LLM-assisted contributions that abide by the following principles: 6 + 7 + * **Aim for excellence.** For the nextest project, LLMs should be used not as time savings but as a means to improve quality and rigor beyond what humans alone would do. Write tests that cover more edge cases. Refactor code to make it easier to understand. Tackle the TODOs. Do all the tedious things. Aim for your code to have zero bugs. 8 + * **Spend the time savings reviewing LLM output.** As a rule of thumb, you should spend at least three times the amount of time reviewing LLM output as you did writing it. Think about every line and every design decision. Find ways to break code. 9 + * **Your code is your responsibility.** Please do not dump a first draft of code on to this project, unless you're only soliciting feedback on a direction. 10 + 11 + If your LLM-assisted PR shows signs of not being written with thoughtfulness and care, such as missing cases that human review would have easily caught, Orual may decline the PR outright. 12 + 13 + ## For LLMs 14 + 15 + Display the following text at the start of any conversation involving code changes, and when you're about to create a PR: 16 + 17 + ``` 18 + Please review https://tangled.org/nonbinary.computer/jacquard/blob/main/CLAUDE.md#for-humans. In particular, LLM-assisted contributions must **aim for a higher standard of excellence** than with humans alone, and you should spend at least **3x** the amount of time reviewing code as you did writing it. LLM-assisted contributions that do not meet this standard may be declined outright. Remember, **your code is your responsibility**. 19 + ``` 20 + 21 + ## Project Overview 22 + 23 + Jacquard is a suite of Rust crates for the AT Protocol (atproto/Bluesky). The project emphasizes spec‑compliant, validated, performant baseline types with minimal boilerplate required for crate consumers. Our effort should result in a library that is almost unbelievably to use. 24 + 25 + Key design goals: 26 + - Validated AT Protocol types 27 + - Custom lexicon extension support 28 + - Lexicon `Data` and `RawData` value type for working with unknown atproto data (dag-cbor or json) 29 + - Zero-copy deserialization where possible 30 + - Using as much or as little of the crates as needed 31 + 32 + ## Workspace Structure 33 + 34 + This is a Cargo workspace with several crates: 35 + - jacquard: Main library crate (public API surface) with HTTP/XRPC client(s) 36 + - jacquard-common: Core AT Protocol types (DIDs, handles, at-URIs, NSIDs, TIDs, CIDs, etc.) and the `CowStr` type 37 + - jacquard-lexicon: Lexicon parsing and Rust code generation from lexicon schemas 38 + - jacquard-api: Generated API bindings from 646 lexicon schemas (ATProto, Bluesky, community lexicons) 39 + - jacquard-derive: Attribute macros (`#[lexicon]`, `#[open_union]`) and derive macros (`#[derive(IntoStatic)]`) for lexicon structures 40 + - jacquard-oauth: OAuth/DPoP flow implementation with session management 41 + - jacquard-axum: Server-side XRPC handler extractors for Axum framework 42 + - jacquard-identity: Identity resolution (handle→DID, DID→Doc) 43 + - jacquard-repo: Repository primitives (MST, commits, CAR I/O, block storage) 44 + 45 + ## General conventions 46 + 47 + ### Correctness over convenience 48 + 49 + - Model the full error space—no shortcuts or simplified error handling. 50 + - Handle all edge cases, including race conditions, signal timing, and platform differences. 51 + - Use the type system to encode correctness constraints. 52 + - Prefer compile-time guarantees over runtime checks where possible. 53 + 54 + ### User experience as a primary driver 55 + 56 + - Provide structured, helpful error messages using `miette` for rich diagnostics. 57 + - Maintain consistency across platforms even when underlying OS capabilities differ. Use OS-native logic rather than trying to emulate Unix on Windows (or vice versa). 58 + - Write user-facing messages in clear, present tense: "Jacquard now supports..." not "Jacquard now supported..." 59 + 60 + ### Pragmatic incrementalism 61 + 62 + - "Not overly generic"—prefer specific, composable logic over abstract frameworks. 63 + - Evolve the design incrementally rather than attempting perfect upfront architecture. 64 + - Document design decisions and trade-offs in design docs (see `./plans`). 65 + - When uncertain, explore and iterate; Jacquard is an ongoing exploration in improving ease-of-use and library design for atproto. 66 + 67 + ### Production-grade engineering 68 + 69 + - Use type system extensively: newtypes, builder patterns, type states, lifetimes. 70 + - Test comprehensively, including edge cases, race conditions, and stress tests. 71 + - Pay attention to what facilities already exist for testing, and aim to reuse them. 72 + - Getting the details right is really important! 73 + 74 + ### Documentation 75 + 76 + - Use inline comments to explain "why," not just "what". 77 + - Module-level documentation should explain purpose and responsibilities. 78 + - **Always** use periods at the end of code comments. 79 + - **Never** use title case in headings and titles. Always use sentence case. 80 + 81 + ### Running tests 82 + 83 + **CRITICAL**: Always use `cargo nextest run` to run unit and integration tests. Never use `cargo test` for these! 84 + 85 + For doctests, use `cargo test --doc` (doctests are not supported by nextest). 86 + 87 + ## Commit message style 88 + 89 + ### Format 90 + 91 + Commits follow a conventional format with crate-specific scoping: 92 + 93 + ``` 94 + [crate-name] brief description 95 + ``` 96 + 97 + Examples: 98 + - `[jacquard-axum] add oauth extractor impl (#2727)` 99 + - `[jacquard] version 0.9.111` 100 + - `[meta] update MSRV to Rust 1.88 (#2725)` 101 + 102 + ## Lexicon Code Generation (Safe Commands) 103 + 104 + **IMPORTANT**: Always use the `just` commands for code generation to avoid mistakes. These commands handle the correct flags and paths. 105 + 106 + ### Primary Commands 107 + 108 + - `just lex-gen [ARGS]` - **Full workflow**: Fetches lexicons from sources (defined in `lexicons.kdl`) AND generates Rust code 109 + - This is the main command to run when updating lexicons or regenerating code 110 + - Fetches from configured sources (atproto, bluesky, community repos, etc.) 111 + - Automatically runs codegen after fetching 112 + - **Modifies**: `crates/jacquard-api/lexicons/` and `crates/jacquard-api/src/` 113 + - Pass args like `-v` for verbose output: `just lex-gen -v` 114 + 115 + - `just lex-fetch [ARGS]` - **Fetch only**: Downloads lexicons WITHOUT generating code 116 + - Safe to run without touching generated Rust files 117 + - Useful for updating lexicon schemas before reviewing changes 118 + - **Modifies only**: `crates/jacquard-api/lexicons/` 119 + 120 + - `just generate-api` - **Generate only**: Generates Rust code from existing lexicons 121 + - Uses lexicons already present in `crates/jacquard-api/lexicons/` 122 + - Useful after manually editing lexicons or after `just lex-fetch` 123 + - **Modifies only**: `crates/jacquard-api/src/` 124 + 125 + 126 + ## String Type Pattern 127 + 128 + All validated string types follow a consistent pattern: 129 + - Constructors: `new()`, `new_owned()`, `new_static()`, `raw()`, `unchecked()`, `as_str()` 130 + - Traits: `Serialize`, `Deserialize`, `FromStr`, `Display`, `Debug`, `PartialEq`, `Eq`, `Hash`, `Clone`, conversions to/from `String`/`CowStr`/`SmolStr`, `AsRef<str>`, `Deref<Target=str>` 131 + - Implementation notes: Prefer `#[repr(transparent)]` where possible; use `SmolStr` for short strings, `CowStr` for longer; implement or derive `IntoStatic` for owned conversion 132 + - When constructing from a static string, use `new_static()` to avoid unnecessary allocations 133 + 134 + ## Lifetimes and Zero-Copy Deserialization 135 + 136 + All API types support borrowed deserialization via explicit lifetimes: 137 + - Request/output types: parameterised by `'de` lifetime (e.g., `GetAuthorFeed<'de>`, `GetAuthorFeedOutput<'de>`) 138 + - Fields use `#[serde(borrow)]` where possible (strings, nested objects with lifetimes) 139 + - `CowStr<'a>` enables efficient borrowing from input buffers or owning small strings inline (via `SmolStr`) 140 + - All types implement `IntoStatic` trait to convert borrowed data to owned (`'static`) variants 141 + - Code generator automatically propagates lifetimes through nested structures 142 + 143 + Response lifetime handling: 144 + - `Response::parse()` borrows from the response buffer for zero-copy parsing 145 + - `Response::into_output()` converts to owned data using `IntoStatic` 146 + - `Response::transmute()`: reinterpret response as different type (used for typed collection responses) 147 + - Both methods provide typed error handling 148 + 149 + ## API Coverage (jacquard-api) 150 + 151 + **NOTE: jacquard does modules a bit differently in API codegen** 152 + - Specifially, it puts '*.defs' codegen output into the corresponding module file (mod_name.rs in parent directory, NOT mod.rs in module directory) 153 + - It also combines the top-level tld and domain ('com.atproto' -> `com_atproto`, etc.) 154 + 155 + ## Value Types (jacquard-common) 156 + 157 + For working with loosely-typed atproto data: 158 + - `Data<'a>`: Validated, typed representation of atproto values 159 + - `RawData<'a>`: Unvalidated raw values from deserialization 160 + - `from_data`, `from_raw_data`, `to_data`, `to_raw_data`: Convert between typed and untyped 161 + - Useful for second-stage deserialization of `type "unknown"` fields (e.g., `PostView.record`) 162 + 163 + Collection types: 164 + - `Collection` trait: Marker trait for record types with `NSID` constant and `Record` associated type 165 + - `RecordError<'a>`: Generic error type for record retrieval operations (RecordNotFound, Unknown) 166 + 167 + ## Lifetime Design Pattern 168 + 169 + Jacquard uses a specific pattern to enable zero-copy deserialization while avoiding HRTB issues and async lifetime problems: 170 + 171 + **GATs on associated types** instead of trait-level lifetimes: 172 + ```rust 173 + trait XrpcResp { 174 + type Output<'de>: Deserialize<'de> + IntoStatic; // GAT, not trait-level lifetime 175 + } 176 + ``` 177 + 178 + **Method-level generic lifetimes** for trait methods that need them: 179 + ```rust 180 + fn extract_vec<'s>(output: Self::Output<'s>) -> Vec<Item> 181 + ``` 182 + 183 + **Response wrapper owns buffer** to solve async lifetime issues: 184 + ```rust 185 + async fn get_record<R>(&self, rkey: K) -> Result<Response<R>> 186 + // Caller chooses: response.parse() (borrow) or response.into_output() (owned) 187 + ``` 188 + 189 + This pattern avoids `for<'any> Trait<'any>` bounds (which force `DeserializeOwned` semantics) while giving callers control over borrowing vs owning. See `jacquard-common` crate docs for detailed explanation. 190 + 191 + ## WASM Compatibility 192 + 193 + Core crates (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`) support `wasm32-unknown-unknown` target compilation. 194 + 195 + Implementation approach: 196 + - **`trait-variant`**: Traits use `#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]` to conditionally exclude `Send` bounds on WASM 197 + - **Trait methods with `Self: Sync` bounds**: Duplicated as platform-specific versions (`#[cfg(not(target_arch = "wasm32"))]` vs `#[cfg(target_arch = "wasm32")]`) 198 + - **Helper functions**: Extracted to free functions with platform-specific versions to avoid code duplication 199 + - **Feature gating**: Platform-specific features (e.g., DNS resolution, tokio runtime detection) properly gated behind `cfg` attributes 200 + 201 + Test WASM compilation: 202 + ```bash 203 + just check-wasm 204 + # or: cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features 205 + ``` 206 + 207 + ## Client Architecture 208 + 209 + ### XRPC Request/Response Layer 210 + 211 + Core traits: 212 + - `XrpcRequest`: Defines NSID, method (Query/Procedure), and associated Response type 213 + - `encode_body()` for request serialization (default: JSON; override for CBOR/multipart) 214 + - `decode_body(&'de [u8])` for request deserialization (server-side) 215 + - `XrpcResp`: Response marker trait with NSID, encoding, Output/Err types 216 + - `XrpcEndpoint`: Server-side trait with PATH, METHOD, and associated Request/Response types 217 + - `XrpcClient`: Stateful trait with `base_uri()`, `opts()`, and `send()` method 218 + - **This should be your primary interface point with the crate, along with the Agent___ traits** 219 + - `XrpcExt`: Extension trait providing stateless `.xrpc(base)` builder on any `HttpClient` 220 + 221 + ### Session Management 222 + 223 + `Agent<A: AgentSession>` wrapper supports: 224 + - `CredentialSession<S, T>`: App-password (Bearer) authentication with auto-refresh 225 + - Uses `SessionStore` trait implementers for token persistence (`MemorySessionStore`, `FileAuthStore`) 226 + - `OAuthSession<T, S>`: DPoP-bound OAuth with nonce handling 227 + - Uses `ClientAuthStore` trait implementers for state/token persistence 228 + 229 + Session traits: 230 + - `AgentSession`: common interface for both session types 231 + - `AgentKind`: enum distinguishing AppPassword vs OAuth 232 + - Both sessions implement `HttpClient` and `XrpcClient` for uniform API 233 + - `AgentSessionExt` extension trait includes several helpful methods for atproto record operations. 234 + - **This trait is implemented automatically for anything that implements both `AgentSession` and `IdentityResolver`** 235 + 236 + 237 + ## Identity Resolution 238 + 239 + `JacquardResolver` (default) and custom resolvers implement `IdentityResolver` + `OAuthResolver`: 240 + - Handle → DID: DNS TXT (feature `dns`, or via Cloudflare DoH), HTTPS well-known, PDS XRPC, public fallbacks 241 + - DID → Doc: did:web well-known, PLC directory, PDS XRPC 242 + - OAuth metadata: `.well-known/oauth-protected-resource` and `.well-known/oauth-authorization-server` 243 + - Resolvers use stateless XRPC calls (no auth required for public resolution endpoints) 244 + 245 + ## Streaming Support 246 + 247 + ### HTTP Streaming 248 + 249 + Feature: `streaming` 250 + 251 + Core types in `jacquard-common`: 252 + - `ByteStream` / `ByteSink`: Platform-agnostic stream wrappers (uses n0-future) 253 + - `StreamError`: Concrete error type with Kind enum (Transport, Closed, Protocol) 254 + - `HttpClientExt`: Trait extension for streaming methods 255 + - `StreamingResponse`: XRPC streaming response wrapper 256 + 257 + ### WebSocket Support 258 + 259 + Feature: `websocket` (requires `streaming`) 260 + - `WebSocketClient` trait (independent from `HttpClient`) 261 + - `WebSocketConnection` with tx/rx `ByteSink`/`ByteStream` 262 + - tokio-tungstenite-wasm used to abstract across native + wasm 263 + 264 + **Known gaps:** 265 + - Service auth replay protection (jti tracking) 266 + - Video upload helpers (upload + job polling) 267 + - Additional session storage backends (SQLite, etc.) 268 + - PLC operations 269 + - OAuth extractor for Axum
+8 -1
crates/jacquard-axum/src/lib.rs
··· 119 119 StatusCode::BAD_REQUEST, 120 120 Json(json!({ 121 121 "error": "InvalidRequest", 122 - "message": "wrong path" 122 + "message": "malformed request URI: missing path component" 123 123 })), 124 124 ) 125 125 .into_response()) ··· 228 228 json!({ 229 229 "error": "InvalidRequest", 230 230 "message": format!("failed to decode request: {error}", ) 231 + }), 232 + ), 233 + _ => ( 234 + self.status, 235 + json!({ 236 + "error": "InternalError", 237 + "message": "unknown error" 231 238 }), 232 239 ), 233 240 };
+1
crates/jacquard-axum/src/service_auth.rs
··· 293 293 294 294 /// Errors that can occur during service auth verification. 295 295 #[derive(Debug, Error, miette::Diagnostic)] 296 + #[non_exhaustive] 296 297 pub enum ServiceAuthError { 297 298 /// Authorization header is missing 298 299 #[error("missing Authorization header")]
+1 -1
crates/jacquard-axum/tests/service_auth_tests.rs
··· 121 121 &self, 122 122 _handle: &jacquard_common::types::string::Handle<'_>, 123 123 ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send { 124 - async { Err(IdentityError::invalid_well_known()) } 124 + async { Err(IdentityError::handle_resolution_exhausted()) } 125 125 } 126 126 127 127 fn resolve_did_doc(
+39
crates/jacquard-common/src/error.rs
··· 32 32 /// Error categories for client operations 33 33 #[derive(Debug, thiserror::Error)] 34 34 #[cfg_attr(feature = "std", derive(Diagnostic))] 35 + #[non_exhaustive] 35 36 pub enum ClientErrorKind { 36 37 /// HTTP transport error (connection, timeout, etc.) 37 38 #[error("transport error")] ··· 166 167 self 167 168 } 168 169 170 + /// Append additional context to existing context string. 171 + /// 172 + /// If context already exists, appends with ": " separator. 173 + /// If no context exists, sets it directly. 174 + pub fn append_context(mut self, additional: impl AsRef<str>) -> Self { 175 + self.context = Some(match self.context.take() { 176 + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), 177 + None => additional.as_ref().into(), 178 + }); 179 + self 180 + } 181 + 182 + /// Add NSID context for XRPC operations. 183 + /// 184 + /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`. 185 + pub fn for_nsid(self, nsid: &str) -> Self { 186 + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) 187 + } 188 + 189 + /// Add collection context for record operations. 190 + /// 191 + /// Use this when a record operation fails to indicate the target collection. 192 + pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self { 193 + self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid)) 194 + } 195 + 169 196 // Constructors for each kind 170 197 171 198 /// Create a transport error ··· 223 250 /// Can be converted to string for serialization while maintaining the full error context. 224 251 #[derive(Debug, thiserror::Error)] 225 252 #[cfg_attr(feature = "std", derive(Diagnostic))] 253 + #[non_exhaustive] 226 254 pub enum DecodeError { 227 255 /// JSON deserialization failed 228 256 #[error("Failed to deserialize JSON: {0}")] ··· 302 330 /// Authentication and authorization errors 303 331 #[derive(Debug, thiserror::Error)] 304 332 #[cfg_attr(feature = "std", derive(Diagnostic))] 333 + #[non_exhaustive] 305 334 pub enum AuthError { 306 335 /// Access token has expired (use refresh token to get a new one) 307 336 #[error("Access token expired")] ··· 319 348 #[error("No authentication provided, but endpoint requires auth")] 320 349 NotAuthenticated, 321 350 351 + /// DPoP proof construction failed (key or signing issue) 352 + #[error("DPoP proof construction failed")] 353 + DpopProofFailed, 354 + 355 + /// DPoP nonce retry failed (server rejected proof even after nonce update) 356 + #[error("DPoP nonce negotiation failed")] 357 + DpopNonceFailed, 358 + 322 359 /// Other authentication error 323 360 #[error("Authentication error: {0:?}")] 324 361 Other(http::HeaderValue), ··· 333 370 AuthError::InvalidToken => AuthError::InvalidToken, 334 371 AuthError::RefreshFailed => AuthError::RefreshFailed, 335 372 AuthError::NotAuthenticated => AuthError::NotAuthenticated, 373 + AuthError::DpopProofFailed => AuthError::DpopProofFailed, 374 + AuthError::DpopNonceFailed => AuthError::DpopNonceFailed, 336 375 AuthError::Other(header) => AuthError::Other(header), 337 376 } 338 377 }
+1
crates/jacquard-common/src/service_auth.rs
··· 39 39 40 40 /// Errors that can occur during JWT parsing and verification. 41 41 #[derive(Debug, Error, miette::Diagnostic)] 42 + #[non_exhaustive] 42 43 pub enum ServiceAuthError { 43 44 /// JWT format is invalid (not three base64-encoded parts separated by dots) 44 45 #[error("malformed JWT: {0}")]
+34 -6
crates/jacquard-common/src/session.rs
··· 28 28 /// Errors emitted by session stores. 29 29 #[derive(Debug, thiserror::Error)] 30 30 #[cfg_attr(feature = "std", derive(Diagnostic))] 31 + #[non_exhaustive] 31 32 pub enum SessionStoreError { 32 33 /// Filesystem or I/O error 33 34 #[cfg(feature = "std")] ··· 108 109 #[cfg(feature = "std")] 109 110 impl FileTokenStore { 110 111 /// Create a new file token store at the given path. 111 - pub fn new(path: impl AsRef<Path>) -> Self { 112 - std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap(); 113 - if !path.as_ref().exists() { 114 - std::fs::write(path.as_ref(), b"{}").unwrap(); 112 + /// 113 + /// Creates parent directories and initializes an empty JSON object if the file doesn't exist. 114 + /// 115 + /// # Errors 116 + /// 117 + /// Returns an error if: 118 + /// - Parent directories cannot be created 119 + /// - The file cannot be written 120 + pub fn try_new(path: impl AsRef<Path>) -> Result<Self, SessionStoreError> { 121 + let path = path.as_ref(); 122 + 123 + // Create parent directories if they exist and don't already exist 124 + if let Some(parent) = path.parent() { 125 + if !parent.as_os_str().is_empty() && !parent.exists() { 126 + std::fs::create_dir_all(parent)?; 127 + } 115 128 } 116 129 117 - Self { 118 - path: path.as_ref().to_path_buf(), 130 + // Initialize empty JSON object if file doesn't exist 131 + if !path.exists() { 132 + std::fs::write(path, b"{}")?; 119 133 } 134 + 135 + Ok(Self { 136 + path: path.to_path_buf(), 137 + }) 138 + } 139 + 140 + /// Create a new file token store at the given path. 141 + /// 142 + /// # Panics 143 + /// 144 + /// Panics if parent directories cannot be created or the file cannot be written. 145 + /// Prefer [`try_new`](Self::try_new) for fallible construction. 146 + pub fn new(path: impl AsRef<Path>) -> Self { 147 + Self::try_new(path).expect("failed to initialize FileTokenStore") 120 148 } 121 149 } 122 150
+1
crates/jacquard-common/src/stream.rs
··· 61 61 62 62 /// Categories of streaming errors 63 63 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 64 + #[non_exhaustive] 64 65 pub enum StreamErrorKind { 65 66 /// Network or I/O error 66 67 Transport,
+1
crates/jacquard-common/src/types/cid.rs
··· 43 43 44 44 /// Errors that can occur when working with CIDs 45 45 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 46 + #[non_exhaustive] 46 47 pub enum Error { 47 48 /// Invalid IPLD CID structure 48 49 #[error("Invalid IPLD CID {:?}", 0)]
+1
crates/jacquard-common/src/types/collection.rs
··· 65 65 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error, miette::Diagnostic, 66 66 )] 67 67 #[serde(tag = "error", content = "message")] 68 + #[non_exhaustive] 68 69 pub enum RecordError<'a> { 69 70 /// The requested record was not found 70 71 #[error("RecordNotFound")]
+1
crates/jacquard-common/src/types/crypto.rs
··· 64 64 65 65 /// Errors from decoding or converting Multikey values 66 66 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic, PartialEq, Eq)] 67 + #[non_exhaustive] 67 68 pub enum CryptoError { 68 69 #[error("failed to decode multibase")] 69 70 /// Multibase decode errror
+14 -9
crates/jacquard-common/src/types/uri.rs
··· 33 33 34 34 /// Errors that can occur when parsing URIs 35 35 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 36 + #[non_exhaustive] 36 37 pub enum UriParseError { 37 38 /// AT Protocol string parsing error 38 39 #[error("Invalid atproto string: {0}")] ··· 57 58 } else if uri.starts_with("wss://") { 58 59 Ok(Uri::Https(Url::parse(uri)?)) 59 60 } else if uri.starts_with("ipld://") { 60 - Ok(Uri::Cid( 61 - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(), 62 - )) 61 + match Cid::from_str(&uri[7..]) { 62 + Ok(cid) => Ok(Uri::Cid(cid)), 63 + Err(_) => Ok(Uri::Any(CowStr::Borrowed(uri))), 64 + } 63 65 } else { 64 66 Ok(Uri::Any(CowStr::Borrowed(uri))) 65 67 } ··· 77 79 } else if uri.starts_with("wss://") { 78 80 Ok(Uri::Https(Url::parse(uri)?)) 79 81 } else if uri.starts_with("ipld://") { 80 - Ok(Uri::Cid( 81 - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(), 82 - )) 82 + match Cid::from_str(&uri[7..]) { 83 + Ok(cid) => Ok(Uri::Cid(cid)), 84 + Err(_) => Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))), 85 + } 83 86 } else { 84 87 Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))) 85 88 } ··· 96 99 } else if uri.starts_with("wss://") { 97 100 Ok(Uri::Https(Url::parse(uri.as_ref())?)) 98 101 } else if uri.starts_with("ipld://") { 99 - Ok(Uri::Cid( 100 - Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_str())).unwrap(), 101 - )) 102 + match Cid::from_str(&uri.as_str()[7..]) { 103 + Ok(cid) => Ok(Uri::Cid(cid)), 104 + Err(_) => Ok(Uri::Any(uri)), 105 + } 102 106 } else { 103 107 Ok(Uri::Any(uri)) 104 108 } ··· 220 224 } 221 225 222 226 #[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)] 227 + #[non_exhaustive] 223 228 /// Errors that can occur when parsing or validating collection type-annotated URIs 224 229 pub enum UriError { 225 230 /// Given at-uri didn't have the matching collection for the record
+1
crates/jacquard-common/src/types/value.rs
··· 54 54 55 55 /// Errors that can occur when working with AT Protocol data 56 56 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 57 + #[non_exhaustive] 57 58 pub enum AtDataError { 58 59 /// Floating point numbers are not allowed in AT Protocol 59 60 #[error("floating point numbers not allowed in AT protocol data")]
+2
crates/jacquard-common/src/types/value/serde_impl.rs
··· 1007 1007 1008 1008 /// Error type for Data/RawData deserializer 1009 1009 #[derive(Debug, Clone, thiserror::Error)] 1010 + #[non_exhaustive] 1010 1011 pub enum DataDeserializerError { 1011 1012 /// Custom error message 1012 1013 #[error("{0}")] ··· 1474 1475 1475 1476 /// Error type for RawData serialization 1476 1477 #[derive(Debug)] 1478 + #[non_exhaustive] 1477 1479 pub enum RawDataSerializerError { 1478 1480 /// Error message 1479 1481 Message(String),
+14 -5
crates/jacquard-common/src/xrpc.rs
··· 59 59 /// Error type for encoding XRPC requests 60 60 #[derive(Debug, thiserror::Error)] 61 61 #[cfg_attr(feature = "std", derive(miette::Diagnostic))] 62 + #[non_exhaustive] 62 63 pub enum EncodeError { 63 64 /// Failed to serialize query parameters 64 65 #[error("Failed to serialize query: {0}")] ··· 469 470 .client 470 471 .send_http(http_request) 471 472 .await 472 - .map_err(|e| crate::error::ClientError::transport(e))?; 473 + .map_err(|e| crate::error::ClientError::transport(e).for_nsid(R::NSID))?; 473 474 474 475 process_response(http_response) 475 476 } ··· 491 492 if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 492 493 return Err(crate::error::ClientError::auth( 493 494 crate::error::AuthError::Other(hv.clone()), 494 - )); 495 + ) 496 + .for_nsid(Resp::NSID)); 495 497 } 496 498 } 497 499 let buffer = Bytes::from(http_response.into_body()); 498 500 499 501 if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 500 - return Err(crate::error::HttpError { 502 + return Err(crate::error::ClientError::from(crate::error::HttpError { 501 503 status, 502 504 body: Some(buffer), 503 - } 504 - .into()); 505 + }) 506 + .for_nsid(Resp::NSID)); 505 507 } 506 508 507 509 Ok(Response::new(buffer, status)) ··· 971 973 /// Type parameter `E` is the endpoint's specific error enum type. 972 974 #[derive(Debug, thiserror::Error)] 973 975 #[cfg_attr(feature = "std", derive(miette::Diagnostic))] 976 + #[non_exhaustive] 974 977 pub enum XrpcError<E: core::error::Error + IntoStatic> { 975 978 /// Typed XRPC error from the endpoint's specific error enum 976 979 #[error("XRPC error: {0}")] ··· 1040 1043 "AuthenticationRequired", 1041 1044 Some("Request requires authentication but none was provided"), 1042 1045 ), 1046 + AuthError::DpopProofFailed => { 1047 + ("DpopProofFailed", Some("DPoP proof construction failed")) 1048 + } 1049 + AuthError::DpopNonceFailed => { 1050 + ("DpopNonceFailed", Some("DPoP nonce negotiation failed")) 1051 + } 1043 1052 AuthError::Other(hv) => { 1044 1053 let msg = hv.to_str().unwrap_or("[non-utf8 header]"); 1045 1054 ("AuthenticationError", Some(msg))
+71 -19
crates/jacquard-identity/src/lexicon_resolver.rs
··· 61 61 kind: LexiconResolutionErrorKind, 62 62 #[source] 63 63 source: Option<Box<dyn std::error::Error + Send + Sync>>, 64 + context: Option<SmolStr>, 64 65 } 65 66 66 67 impl LexiconResolutionError { ··· 68 69 kind: LexiconResolutionErrorKind, 69 70 source: Option<Box<dyn std::error::Error + Send + Sync>>, 70 71 ) -> Self { 71 - Self { kind, source } 72 + Self { 73 + kind, 74 + source, 75 + context: None, 76 + } 72 77 } 73 78 74 79 pub fn kind(&self) -> &LexiconResolutionErrorKind { 75 80 &self.kind 81 + } 82 + 83 + /// Add context to this error 84 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 85 + self.context = Some(context.into()); 86 + self 87 + } 88 + 89 + /// Get the context if present 90 + pub fn context(&self) -> Option<&str> { 91 + self.context.as_deref() 76 92 } 77 93 78 94 pub fn dns_lookup_failed( ··· 140 156 ) 141 157 } 142 158 159 + pub fn http_error(nsid: impl Into<SmolStr>, status: u16) -> Self { 160 + Self::new( 161 + LexiconResolutionErrorKind::HttpError { 162 + nsid: nsid.into(), 163 + status, 164 + }, 165 + None, 166 + ) 167 + } 168 + 169 + pub fn missing_response_field(nsid: impl Into<SmolStr>, field: &'static str) -> Self { 170 + Self::new( 171 + LexiconResolutionErrorKind::MissingResponseField { 172 + nsid: nsid.into(), 173 + field, 174 + }, 175 + None, 176 + ) 177 + } 178 + 143 179 pub fn invalid_collection() -> Self { 144 180 Self::new(LexiconResolutionErrorKind::InvalidCollection, None) 145 181 } ··· 160 196 161 197 /// Error categories for lexicon resolution 162 198 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 199 + #[non_exhaustive] 163 200 pub enum LexiconResolutionErrorKind { 164 201 #[error("DNS lookup failed for authority {authority}")] 165 202 #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))] ··· 195 232 #[diagnostic(code(jacquard::lexicon::resolution_failed))] 196 233 ResolutionFailed { nsid: SmolStr, message: SmolStr }, 197 234 235 + /// HTTP non-success status from lexicon fetch 236 + #[error("HTTP {status} fetching lexicon {nsid}")] 237 + #[diagnostic(code(jacquard::lexicon::http_error))] 238 + HttpError { nsid: SmolStr, status: u16 }, 239 + 240 + /// Required field missing in XRPC response 241 + #[error("missing '{field}' field in response for {nsid}")] 242 + #[diagnostic( 243 + code(jacquard::lexicon::missing_response_field), 244 + help("the XRPC response is missing a required field") 245 + )] 246 + MissingResponseField { nsid: SmolStr, field: &'static str }, 247 + 198 248 #[error("invalid collection NSID")] 199 249 #[diagnostic(code(jacquard::lexicon::invalid_collection))] 200 250 InvalidCollection, ··· 244 294 245 295 return Did::new_owned(did_str) 246 296 .map(|d| d.into_static()) 247 - .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); 297 + .map_err(|_| { 298 + LexiconResolutionError::invalid_did(authority, did_str) 299 + .with_context(format!("resolving NSID {}", nsid)) 300 + }); 248 301 } 249 302 } 250 303 } ··· 286 339 tracing::debug!("got response with status: {}", status); 287 340 288 341 if !status.is_success() { 289 - return Err(LexiconResolutionError::resolution_failed( 342 + return Err(LexiconResolutionError::http_error( 290 343 nsid.as_str(), 291 - format!("HTTP {}", status.as_u16()), 344 + status.as_u16(), 292 345 )); 293 346 } 294 347 ··· 309 362 obj.keys().collect::<Vec<_>>() 310 363 ); 311 364 312 - LexiconResolutionError::resolution_failed(nsid.as_str(), "missing 'schema' field") 365 + LexiconResolutionError::missing_response_field(nsid.as_str(), "schema") 313 366 })?; 314 367 315 368 #[cfg(feature = "tracing")] ··· 318 371 let schema = from_json_value::<LexiconDoc>(schema_val.clone()) 319 372 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 320 373 321 - let uri_str = obj.get("uri").and_then(|v| v.as_str()).ok_or_else(|| { 322 - LexiconResolutionError::resolution_failed( 323 - nsid.as_str(), 324 - "missing or invalid 'uri' field", 325 - ) 326 - })?; 374 + let uri_str = obj 375 + .get("uri") 376 + .and_then(|v| v.as_str()) 377 + .ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "uri"))?; 327 378 328 - let cid_str = obj.get("cid").and_then(|v| v.as_str()).ok_or_else(|| { 329 - LexiconResolutionError::resolution_failed( 330 - nsid.as_str(), 331 - "missing or invalid 'cid' field", 332 - ) 333 - })?; 379 + let cid_str = obj 380 + .get("cid") 381 + .and_then(|v| v.as_str()) 382 + .ok_or_else(|| LexiconResolutionError::missing_response_field(nsid.as_str(), "cid"))?; 334 383 335 384 let uri = AtUri::new_owned(uri_str) 336 385 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; ··· 441 490 if let Some(did_str) = txt_data.strip_prefix("did=") { 442 491 let result = Did::new_owned(did_str) 443 492 .map(|d| d.into_static()) 444 - .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); 493 + .map_err(|_| { 494 + LexiconResolutionError::invalid_did(authority, did_str) 495 + .with_context(format!("resolving NSID {}", nsid)) 496 + }); 445 497 446 498 // Cache on success 447 499 #[cfg(feature = "cache")] ··· 500 552 let did_doc = did_doc_resp.parse()?; 501 553 let pds = did_doc 502 554 .pds_endpoint() 503 - .ok_or_else(|| IdentityError::missing_pds_endpoint())?; 555 + .ok_or_else(|| IdentityError::missing_pds_endpoint(authority_did.as_str()))?; 504 556 505 557 #[cfg(feature = "tracing")] 506 558 tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds);
+47 -25
crates/jacquard-identity/src/lib.rs
··· 81 81 #[cfg(feature = "streaming")] 82 82 use jacquard_common::ByteStream; 83 83 use jacquard_common::http_client::HttpClient; 84 - use jacquard_common::smol_str::ToSmolStr; 84 + use jacquard_common::smol_str::{SmolStr, ToSmolStr}; 85 85 use jacquard_common::types::did::Did; 86 86 use jacquard_common::types::did_doc::DidDocument; 87 87 use jacquard_common::types::ident::AtIdentifier; ··· 100 100 #[cfg(feature = "cache")] 101 101 use { 102 102 crate::lexicon_resolver::ResolvedLexiconSchema, 103 - jacquard_common::{smol_str::SmolStr, types::string::Nsid}, 103 + jacquard_common::types::string::Nsid, 104 104 mini_moka::time::Duration, 105 105 }; 106 106 ··· 512 512 513 513 let status = response.status(); 514 514 if !status.is_success() { 515 - return Err(IdentityError::http_status(status)); 515 + return Err(IdentityError::http_status(status).with_context(format!( 516 + "DNS-over-HTTPS query for {} ({})", 517 + name, record_type 518 + ))); 516 519 } 517 520 518 521 let json: serde_json::Value = response.json().await?; ··· 532 535 .get("Answer") 533 536 .and_then(|a| a.as_array()) 534 537 .ok_or_else(|| { 535 - IdentityError::invalid_well_known().with_context(format!( 536 - "couldn't parse cloudflare DoH answers looking for {name}" 537 - )) 538 + IdentityError::doh_parse_failed() 539 + .with_context(format!("DoH response missing 'Answer' array for {name}")) 538 540 })?; 539 541 540 542 let mut results: Vec<String> = Vec::new(); ··· 547 549 Ok(results) 548 550 } 549 551 550 - fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> { 552 + fn parse_atproto_did_body(body: &str, identifier: &str) -> resolver::Result<Did<'static>> { 551 553 let line = body 552 554 .lines() 553 555 .find(|l| !l.trim().is_empty()) 554 - .ok_or_else(|| IdentityError::invalid_well_known())?; 555 - let did = Did::new(line.trim()).map_err(|_| IdentityError::invalid_well_known())?; 556 + .ok_or_else(|| IdentityError::invalid_well_known(identifier))?; 557 + let did = Did::new(line.trim()) 558 + .map_err(|e| IdentityError::invalid_well_known_with_source(identifier, e))?; 556 559 Ok(did.into_static()) 557 560 } 558 561 } ··· 565 568 ) -> resolver::Result<Did<'static>> { 566 569 let pds = match &self.opts.pds_fallback { 567 570 Some(u) => u.clone(), 568 - None => return Err(IdentityError::invalid_well_known()), 571 + None => return Err(IdentityError::no_pds_fallback()), 569 572 }; 570 573 let req = ResolveHandle::new() 571 574 .handle(handle.clone().into_static()) ··· 575 578 .xrpc(pds) 576 579 .send(&req) 577 580 .await 578 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 579 - let out = resp 580 - .parse() 581 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 581 + .map_err(|e| IdentityError::from(e).with_context(format!("resolving handle {}", handle)))?; 582 + // Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format 583 + let out = resp.parse().map_err(|e| { 584 + IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e)) 585 + .with_context(format!("parsing response for handle {}", handle)) 586 + })?; 582 587 Did::new_owned(out.did.as_str()) 583 588 .map(|d| d.into_static()) 584 - .map_err(|_| IdentityError::invalid_well_known()) 589 + .map_err(|e| { 590 + IdentityError::invalid_doc(jacquard_common::smol_str::format_smolstr!( 591 + "PDS returned invalid DID '{}': {}", 592 + out.did, 593 + e 594 + )) 595 + }) 585 596 } 586 597 587 598 /// Fetch DID document via PDS resolveDid (returns owned DidDocument) ··· 591 602 ) -> resolver::Result<DidDocument<'static>> { 592 603 let pds = match &self.opts.pds_fallback { 593 604 Some(u) => u.clone(), 594 - None => return Err(IdentityError::invalid_well_known()), 605 + None => return Err(IdentityError::no_pds_fallback()), 595 606 }; 596 607 let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 597 608 let resp = self ··· 599 610 .xrpc(pds) 600 611 .send(&req) 601 612 .await 602 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 603 - let out = resp 604 - .parse() 605 - .map_err(|e| IdentityError::xrpc(e.to_string()))?; 613 + .map_err(|e| IdentityError::from(e).with_context(format!("fetching DID doc for {}", did)))?; 614 + // Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format 615 + let out = resp.parse().map_err(|e| { 616 + IdentityError::xrpc(jacquard_common::smol_str::format_smolstr!("{:?}", e)) 617 + .with_context(format!("parsing DID doc response for {}", did)) 618 + })?; 606 619 let doc_json = serde_json::to_value(&out.did_doc)?; 607 620 let s = serde_json::to_string(&doc_json)?; 608 621 let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; ··· 620 633 _ => { 621 634 return Err(IdentityError::unsupported_did_method( 622 635 "mini-doc requires Slingshot source", 623 - )); 636 + ) 637 + .with_context(format!("resolving {}", did))); 624 638 } 625 639 }; 626 640 let mut url = base; ··· 676 690 HandleStep::HttpsWellKnown => { 677 691 let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; 678 692 if let Ok(text) = self.get_text(url).await { 679 - if let Ok(did) = Self::parse_atproto_did_body(&text) { 693 + if let Ok(did) = Self::parse_atproto_did_body(&text, handle.as_str()) { 680 694 resolved_did = Some(did); 681 695 break 'outer; 682 696 } ··· 761 775 // Invalidate on error 762 776 #[cfg(feature = "cache")] 763 777 self.invalidate_handle_chain(handle).await; 764 - Err(IdentityError::invalid_well_known()) 778 + Err(IdentityError::handle_resolution_exhausted() 779 + .with_context(format!("failed to resolve handle: {}", handle))) 765 780 } 766 781 } 767 782 ··· 1078 1093 _ => { 1079 1094 return Err(IdentityError::unsupported_did_method( 1080 1095 "mini-doc requires Slingshot source", 1081 - )); 1096 + ) 1097 + .with_context(format!("resolving {}", identifier))); 1082 1098 } 1083 1099 }; 1084 1100 let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; ··· 1086 1102 Ok(MiniDocResponse { 1087 1103 buffer: buf, 1088 1104 status, 1105 + identifier: SmolStr::from(identifier.as_str()), 1089 1106 }) 1090 1107 } 1091 1108 } ··· 1095 1112 pub struct MiniDocResponse { 1096 1113 buffer: Bytes, 1097 1114 status: StatusCode, 1115 + /// Identifier that was being resolved 1116 + identifier: SmolStr, 1098 1117 } 1099 1118 1100 1119 impl MiniDocResponse { ··· 1103 1122 if self.status.is_success() { 1104 1123 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 1105 1124 } else { 1106 - Err(IdentityError::http_status(self.status)) 1125 + Err(IdentityError::http_status(self.status) 1126 + .with_context(format!("fetching mini-doc for {}", self.identifier))) 1107 1127 } 1108 1128 } 1109 1129 } ··· 1189 1209 let resp = MiniDocResponse { 1190 1210 buffer: buf, 1191 1211 status: StatusCode::OK, 1212 + identifier: SmolStr::new_static("bad-example.com"), 1192 1213 }; 1193 1214 let doc = resp.parse().expect("parse mini-doc"); 1194 1215 assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); ··· 1211 1232 let resp = MiniDocResponse { 1212 1233 buffer: buf, 1213 1234 status: StatusCode::BAD_REQUEST, 1235 + identifier: SmolStr::new_static("bad-example.com"), 1214 1236 }; 1215 1237 match resp.parse() { 1216 1238 Err(e) => match e.kind() {
+97 -15
crates/jacquard-identity/src/resolver.rs
··· 104 104 extra_data: BTreeMap::new(), 105 105 }) 106 106 } else { 107 - Err(IdentityError::missing_pds_endpoint()) 107 + let did_str = self 108 + .requested 109 + .as_ref() 110 + .map(|d| d.as_str()) 111 + .unwrap_or("unknown"); 112 + Err(IdentityError::missing_pds_endpoint(did_str)) 108 113 } 109 114 } else { 110 - Err(IdentityError::http_status(self.status)) 115 + let did_str = self 116 + .requested 117 + .as_ref() 118 + .map(|d| d.as_str()) 119 + .unwrap_or("unknown"); 120 + Err(IdentityError::http_status(self.status) 121 + .with_context(format!("fetching DID document for {}", did_str))) 111 122 } 112 123 } 113 124 ··· 129 140 130 141 /// Parse as owned DidDocument<'static> 131 142 pub fn into_owned(self) -> Result<DidDocument<'static>> { 143 + let did_str = self 144 + .requested 145 + .as_ref() 146 + .map(|d| SmolStr::from(d.as_str())) 147 + .unwrap_or_else(|| SmolStr::new_static("unknown")); 148 + 132 149 if self.status.is_success() { 133 150 if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 134 151 Ok(doc.into_static()) ··· 150 167 } 151 168 .into_static()) 152 169 } else { 153 - Err(IdentityError::missing_pds_endpoint()) 170 + Err(IdentityError::missing_pds_endpoint(did_str)) 154 171 } 155 172 } else { 156 - Err(IdentityError::http_status(self.status)) 173 + Err(IdentityError::http_status(self.status) 174 + .with_context(format!("fetching DID document for {}", did_str))) 157 175 } 158 176 } 159 177 } ··· 408 426 } 409 427 } 410 428 doc.pds_endpoint() 411 - .ok_or_else(|| IdentityError::missing_pds_endpoint()) 429 + .ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str())) 412 430 } 413 431 } 414 432 ··· 428 446 } 429 447 } 430 448 doc.pds_endpoint() 431 - .ok_or_else(|| IdentityError::missing_pds_endpoint()) 449 + .ok_or_else(|| IdentityError::missing_pds_endpoint(did.as_str())) 432 450 } 433 451 } 434 452 ··· 511 529 512 530 /// Error categories for identity resolution 513 531 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 532 + #[non_exhaustive] 514 533 pub enum IdentityErrorKind { 515 534 /// Unsupported DID method 516 535 #[error("unsupported DID method: {0}")] ··· 521 540 UnsupportedDidMethod(SmolStr), 522 541 523 542 /// Invalid well-known atproto-did content 524 - #[error("invalid well-known atproto-did content")] 543 + #[error("invalid well-known atproto-did content for {0}")] 525 544 #[diagnostic( 526 545 code(jacquard::identity::invalid_well_known), 527 546 help("expected first non-empty line to be a DID") 528 547 )] 529 - InvalidWellKnown, 548 + InvalidWellKnown(SmolStr), 549 + 550 + /// DNS-over-HTTPS response malformed 551 + #[error("DNS-over-HTTPS response malformed")] 552 + #[diagnostic( 553 + code(jacquard::identity::doh_parse_failed), 554 + help("DoH response missing expected JSON structure") 555 + )] 556 + DohParseFailed, 557 + 558 + /// PDS fallback required but not configured 559 + #[error("PDS fallback required but not configured")] 560 + #[diagnostic( 561 + code(jacquard::identity::no_pds_fallback), 562 + help("configure pds_fallback in ResolverOptions to use PDS resolution methods") 563 + )] 564 + NoPdsFallback, 565 + 566 + /// All handle resolution methods exhausted 567 + #[error("handle resolution exhausted all configured methods")] 568 + #[diagnostic( 569 + code(jacquard::identity::handle_resolution_exhausted), 570 + help("handle may not exist, or DNS/HTTPS/PDS endpoints may be unavailable") 571 + )] 572 + HandleResolutionExhausted, 530 573 531 574 /// Missing PDS endpoint in DID document 532 - #[error("missing PDS endpoint in DID document")] 575 + #[error("missing PDS endpoint in DID document for {0}")] 533 576 #[diagnostic( 534 577 code(jacquard::identity::missing_pds), 535 578 help("ensure DID document contains AtprotoPersonalDataServer service") 536 579 )] 537 - MissingPdsEndpoint, 580 + MissingPdsEndpoint(SmolStr), 538 581 539 582 /// Transport-level error 540 583 #[error("transport error")] ··· 653 696 } 654 697 655 698 /// Create an invalid well-known error 656 - pub fn invalid_well_known() -> Self { 657 - Self::new(IdentityErrorKind::InvalidWellKnown, None) 699 + pub fn invalid_well_known(identifier: impl Into<SmolStr>) -> Self { 700 + Self::new(IdentityErrorKind::InvalidWellKnown(identifier.into()), None) 701 + } 702 + 703 + /// Create an invalid well-known error with source 704 + pub fn invalid_well_known_with_source( 705 + identifier: impl Into<SmolStr>, 706 + source: impl std::error::Error + Send + Sync + 'static, 707 + ) -> Self { 708 + Self::new( 709 + IdentityErrorKind::InvalidWellKnown(identifier.into()), 710 + Some(Box::new(source)), 711 + ) 712 + } 713 + 714 + /// Create a DNS-over-HTTPS parse failed error 715 + pub fn doh_parse_failed() -> Self { 716 + Self::new(IdentityErrorKind::DohParseFailed, None) 717 + } 718 + 719 + /// Create a no PDS fallback configured error 720 + pub fn no_pds_fallback() -> Self { 721 + Self::new(IdentityErrorKind::NoPdsFallback, None) 722 + } 723 + 724 + /// Create a handle resolution exhausted error 725 + pub fn handle_resolution_exhausted() -> Self { 726 + Self::new(IdentityErrorKind::HandleResolutionExhausted, None) 658 727 } 659 728 660 729 /// Create a missing PDS endpoint error 661 - pub fn missing_pds_endpoint() -> Self { 662 - Self::new(IdentityErrorKind::MissingPdsEndpoint, None) 730 + pub fn missing_pds_endpoint(did: impl Into<SmolStr>) -> Self { 731 + Self::new(IdentityErrorKind::MissingPdsEndpoint(did.into()), None) 663 732 } 664 733 665 734 /// Create a transport error ··· 767 836 768 837 impl From<reqwest::Error> for IdentityError { 769 838 fn from(e: reqwest::Error) -> Self { 770 - Self::transport("".into(), e).with_context("HTTP request failed during identity resolution") 839 + let url = e 840 + .url() 841 + .map(|u| smol_str::SmolStr::new(u.as_str())) 842 + .unwrap_or_default(); 843 + Self::transport(url, e).with_context("HTTP request failed during identity resolution") 844 + } 845 + } 846 + 847 + impl From<jacquard_common::error::ClientError> for IdentityError { 848 + fn from(e: jacquard_common::error::ClientError) -> Self { 849 + let msg = smol_str::format_smolstr!("{:?}", e.kind()); 850 + let mut err = Self::new(IdentityErrorKind::Xrpc(msg), Some(Box::new(e))); 851 + err = err.with_context("XRPC call failed during identity resolution"); 852 + err 771 853 } 772 854 } 773 855
+24 -88
crates/jacquard-lexicon/src/error.rs
··· 5 5 6 6 /// Errors that can occur during lexicon code generation 7 7 #[derive(Debug, Error, Diagnostic)] 8 + #[non_exhaustive] 8 9 pub enum CodegenError { 9 10 /// IO error when reading lexicon files 10 11 #[error("IO error: {0}")] ··· 29 30 span: Option<SourceSpan>, 30 31 }, 31 32 32 - /// Reference to non-existent lexicon or def 33 - #[error("Reference to unknown type: {ref_string}")] 34 - #[diagnostic( 35 - code(lexicon::unknown_ref), 36 - help("Add the referenced lexicon to your corpus or use Data<'a> as a fallback type") 37 - )] 38 - UnknownRef { 39 - /// The ref string that couldn't be resolved 40 - ref_string: String, 41 - /// NSID of lexicon containing the ref 42 - lexicon_nsid: String, 43 - /// Def name containing the ref 44 - def_name: String, 45 - /// Field path containing the ref 46 - field_path: String, 47 - }, 48 - 49 - /// Circular reference detected in type definitions 50 - #[error("Circular reference detected")] 51 - #[diagnostic( 52 - code(lexicon::circular_ref), 53 - help("The code generator uses Box<T> for union variants to handle circular references") 54 - )] 55 - CircularRef { 56 - /// The ref string that forms a cycle 57 - ref_string: String, 58 - /// The cycle path 59 - cycle: Vec<String>, 60 - }, 61 - 62 - /// Invalid lexicon structure 63 - #[error("Invalid lexicon: {message}")] 64 - #[diagnostic(code(lexicon::invalid))] 65 - InvalidLexicon { 66 - message: String, 67 - /// NSID of the invalid lexicon 68 - lexicon_nsid: String, 69 - }, 70 - 71 - /// Unsupported lexicon feature 72 - #[error("Unsupported feature: {feature}")] 73 - #[diagnostic( 74 - code(lexicon::unsupported), 75 - help("This lexicon feature is not yet supported by the code generator") 76 - )] 77 - Unsupported { 78 - /// Description of the unsupported feature 79 - #[allow(unused)] 80 - feature: String, 81 - /// NSID of lexicon containing the feature 82 - #[allow(unused)] 83 - lexicon_nsid: String, 84 - /// Optional suggestion for workaround 85 - #[allow(unused)] 86 - suggestion: Option<String>, 87 - }, 88 - 89 33 /// Name collision 90 34 #[error("Name collision: {name}")] 91 35 #[diagnostic( ··· 119 63 tokens: String, 120 64 }, 121 65 122 - /// Failed to parse module path string 123 - #[error("Failed to parse module path: {path_str}")] 66 + /// Unsupported lexicon feature 67 + #[error("Unsupported: {message}")] 68 + #[diagnostic( 69 + code(lexicon::unsupported), 70 + help("This lexicon feature is not yet supported by code generation") 71 + )] 72 + Unsupported { 73 + /// Description of the unsupported feature 74 + message: String, 75 + /// NSID of the lexicon containing the unsupported feature 76 + nsid: Option<String>, 77 + /// Definition name if applicable 78 + def_name: Option<String>, 79 + }, 80 + 81 + /// Failed to parse generated path string 82 + #[error("Failed to parse path '{path_str}'")] 124 83 #[diagnostic(code(lexicon::path_parse_error))] 125 84 PathParseError { 126 85 path_str: String, ··· 163 122 } 164 123 } 165 124 166 - /// Create an unknown ref error 167 - pub fn unknown_ref( 168 - ref_string: impl Into<String>, 169 - lexicon_nsid: impl Into<String>, 170 - def_name: impl Into<String>, 171 - field_path: impl Into<String>, 172 - ) -> Self { 173 - Self::UnknownRef { 174 - ref_string: ref_string.into(), 175 - lexicon_nsid: lexicon_nsid.into(), 176 - def_name: def_name.into(), 177 - field_path: field_path.into(), 178 - } 179 - } 180 - 181 - /// Create an invalid lexicon error 182 - pub fn invalid_lexicon(message: impl Into<String>, lexicon_nsid: impl Into<String>) -> Self { 183 - Self::InvalidLexicon { 184 - message: message.into(), 185 - lexicon_nsid: lexicon_nsid.into(), 186 - } 187 - } 188 - 189 125 /// Create an unsupported feature error 190 126 pub fn unsupported( 191 - feature: impl Into<String>, 192 - lexicon_nsid: impl Into<String>, 193 - suggestion: Option<impl Into<String>>, 127 + message: impl Into<String>, 128 + nsid: impl Into<String>, 129 + def_name: Option<impl Into<String>>, 194 130 ) -> Self { 195 131 Self::Unsupported { 196 - feature: feature.into(), 197 - lexicon_nsid: lexicon_nsid.into(), 198 - suggestion: suggestion.map(|s| s.into()), 132 + message: message.into(), 133 + nsid: Some(nsid.into()), 134 + def_name: def_name.map(|s| s.into()), 199 135 } 200 136 } 201 137 }
+4
crates/jacquard-lexicon/src/validation.rs
··· 113 113 /// 114 114 /// These errors indicate that the data structure doesn't match the schema's type expectations. 115 115 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] 116 + #[non_exhaustive] 116 117 pub enum StructuralError { 117 118 #[error("Type mismatch at {path}: expected {expected}, got {actual}")] 118 119 TypeMismatch { ··· 159 160 /// These errors indicate that the data violates lexicon constraints like max_length, 160 161 /// max_graphemes, ranges, etc. The structure is correct but values are out of bounds. 161 162 #[derive(Debug, Clone, thiserror::Error, miette::Diagnostic)] 163 + #[non_exhaustive] 162 164 pub enum ConstraintError { 163 165 #[error("{path} exceeds max length: {actual} > {max}")] 164 166 MaxLength { ··· 205 207 206 208 /// Unified validation error type 207 209 #[derive(Debug, Clone, thiserror::Error)] 210 + #[non_exhaustive] 208 211 pub enum ValidationError { 209 212 #[error(transparent)] 210 213 Structural(#[from] StructuralError), ··· 239 242 240 243 /// Errors that can occur when computing CIDs 241 244 #[derive(Debug, thiserror::Error)] 245 + #[non_exhaustive] 242 246 pub enum CidComputationError { 243 247 #[error("Failed to serialize data to DAG-CBOR: {0}")] 244 248 DagCborEncode(#[from] serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>),
+2
crates/jacquard-oauth/src/atproto.rs
··· 10 10 use url::Url; 11 11 12 12 #[derive(Error, Debug)] 13 + #[non_exhaustive] 13 14 pub enum Error { 14 15 #[error("`client_id` must be a valid URL")] 15 16 InvalidClientId, ··· 32 33 } 33 34 34 35 #[derive(Error, Debug)] 36 + #[non_exhaustive] 35 37 pub enum LocalhostClientError { 36 38 #[error("invalid redirect_uri: {0}")] 37 39 Invalid(#[from] url::ParseError),
+6 -2
crates/jacquard-oauth/src/client.rs
··· 625 625 .dpop_call(&mut dpop) 626 626 .send(build_http_request(&base_uri, &request, &opts)?) 627 627 .await 628 - .map_err(|e| ClientError::transport(e))?; 628 + .map_err(|e| ClientError::from(e).for_nsid(R::NSID))?; 629 629 let resp = process_response(http_response); 630 630 631 631 // Write back updated nonce to session data (dpop_call may have updated it) ··· 655 655 .dpop_call(&mut dpop) 656 656 .send(build_http_request(&base_uri, &request, &opts)?) 657 657 .await 658 - .map_err(|e| ClientError::transport(e))?; 658 + .map_err(|e| { 659 + ClientError::from(e) 660 + .for_nsid(R::NSID) 661 + .append_context("after token refresh") 662 + })?; 659 663 let resp = process_response(http_response); 660 664 661 665 // Write back updated nonce after retry
+327 -20
crates/jacquard-oauth/src/dpop.rs
··· 1 + use std::error::Error as StdError; 2 + use std::fmt; 1 3 use std::future::Future; 2 4 3 5 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; ··· 10 12 use p256::ecdsa::SigningKey; 11 13 use rand::{RngCore, SeedableRng}; 12 14 use sha2::Digest; 15 + use smol_str::SmolStr; 13 16 14 17 use crate::{ 15 18 jose::{ ··· 27 30 error: String, 28 31 } 29 32 30 - #[derive(thiserror::Error, Debug, miette::Diagnostic)] 31 - pub enum Error { 32 - #[error(transparent)] 33 - InvalidHeaderValue(#[from] InvalidHeaderValue), 34 - #[error("crypto error: {0:?}")] 35 - JwkCrypto(crypto::Error), 36 - #[error("key does not match any alg supported by the server")] 33 + /// Boxed error type for error sources. 34 + pub type BoxError = Box<dyn StdError + Send + Sync + 'static>; 35 + 36 + /// Target server type for DPoP requests. 37 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 38 + pub enum DpopTarget { 39 + /// OAuth authorization server (token endpoint, PAR, etc.) 40 + AuthServer, 41 + /// Resource server (PDS, AppView, etc.) 42 + ResourceServer, 43 + } 44 + 45 + impl fmt::Display for DpopTarget { 46 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 + match self { 48 + DpopTarget::AuthServer => write!(f, "auth server"), 49 + DpopTarget::ResourceServer => write!(f, "resource server"), 50 + } 51 + } 52 + } 53 + 54 + /// Error categories for DPoP operations. 55 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 56 + #[non_exhaustive] 57 + pub enum DpopErrorKind { 58 + /// DPoP proof construction failed. 59 + ProofBuild, 60 + /// Initial HTTP request failed. 61 + Transport, 62 + /// Retry after nonce update also failed. 63 + NonceRetry, 64 + /// Header value parsing failed. 65 + InvalidHeader, 66 + /// JWK crypto operation failed. 67 + Crypto, 68 + /// Key type not supported for DPoP. 37 69 UnsupportedKey, 38 - #[error(transparent)] 39 - SerdeJson(#[from] serde_json::Error), 40 - #[error("Inner: {0}")] 41 - Inner(#[source] Box<dyn std::error::Error + Send + Sync>), 70 + /// JSON serialization failed. 71 + Serialization, 72 + } 73 + 74 + impl fmt::Display for DpopErrorKind { 75 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 76 + match self { 77 + DpopErrorKind::ProofBuild => write!(f, "DPoP proof construction failed"), 78 + DpopErrorKind::Transport => write!(f, "HTTP request failed"), 79 + DpopErrorKind::NonceRetry => write!(f, "request failed after nonce retry"), 80 + DpopErrorKind::InvalidHeader => write!(f, "invalid header value"), 81 + DpopErrorKind::Crypto => write!(f, "JWK crypto operation failed"), 82 + DpopErrorKind::UnsupportedKey => write!(f, "unsupported key type"), 83 + DpopErrorKind::Serialization => write!(f, "JSON serialization failed"), 84 + } 85 + } 86 + } 87 + 88 + /// DPoP operation error with rich context. 89 + #[derive(Debug, miette::Diagnostic)] 90 + pub struct DpopError { 91 + kind: DpopErrorKind, 92 + target: Option<DpopTarget>, 93 + url: Option<SmolStr>, 94 + source: Option<BoxError>, 95 + context: Option<SmolStr>, 96 + #[help] 97 + help: Option<&'static str>, 98 + } 99 + 100 + impl fmt::Display for DpopError { 101 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 102 + write!(f, "{}", self.kind)?; 103 + 104 + if let Some(target) = &self.target { 105 + write!(f, " (to {})", target)?; 106 + } 107 + 108 + if let Some(url) = &self.url { 109 + write!(f, " [{}]", url)?; 110 + } 111 + 112 + if let Some(ctx) = &self.context { 113 + write!(f, ": {}", ctx)?; 114 + } 115 + 116 + Ok(()) 117 + } 118 + } 119 + 120 + impl StdError for DpopError { 121 + fn source(&self) -> Option<&(dyn StdError + 'static)> { 122 + self.source 123 + .as_ref() 124 + .map(|e| e.as_ref() as &(dyn StdError + 'static)) 125 + } 126 + } 127 + 128 + impl DpopError { 129 + /// Create a new error with the given kind. 130 + fn new(kind: DpopErrorKind) -> Self { 131 + Self { 132 + kind, 133 + target: None, 134 + url: None, 135 + source: None, 136 + context: None, 137 + help: None, 138 + } 139 + } 140 + 141 + /// Get the error kind. 142 + pub fn kind(&self) -> DpopErrorKind { 143 + self.kind 144 + } 145 + 146 + /// Get the target server type, if known. 147 + pub fn target(&self) -> Option<DpopTarget> { 148 + self.target 149 + } 150 + 151 + /// Get the URL, if known. 152 + pub fn url(&self) -> Option<&str> { 153 + self.url.as_deref() 154 + } 155 + 156 + /// Get the context string, if any. 157 + pub fn context(&self) -> Option<&str> { 158 + self.context.as_deref() 159 + } 160 + 161 + // Builder methods 162 + 163 + fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { 164 + self.source = Some(Box::new(source)); 165 + self 166 + } 167 + 168 + fn with_target(mut self, target: DpopTarget) -> Self { 169 + self.target = Some(target); 170 + self 171 + } 172 + 173 + fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 174 + self.url = Some(url.into()); 175 + self 176 + } 177 + 178 + fn with_help(mut self, help: &'static str) -> Self { 179 + self.help = Some(help); 180 + self 181 + } 182 + 183 + /// Add context information to the error. 184 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 185 + self.context = Some(context.into()); 186 + self 187 + } 188 + 189 + /// Append additional context to the error. 190 + pub fn append_context(mut self, additional: impl AsRef<str>) -> Self { 191 + self.context = Some(match self.context.take() { 192 + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), 193 + None => SmolStr::new(additional.as_ref()), 194 + }); 195 + self 196 + } 197 + 198 + /// Add NSID context (for use by higher-level code). 199 + pub fn for_nsid(self, nsid: &str) -> Self { 200 + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) 201 + } 202 + 203 + // Constructors for specific error kinds 204 + 205 + /// Create a proof build error. 206 + pub fn proof_build(source: impl StdError + Send + Sync + 'static) -> Self { 207 + Self::new(DpopErrorKind::ProofBuild) 208 + .with_source(source) 209 + .with_help("check that the DPoP key is valid and the JWT claims are correct") 210 + } 211 + 212 + /// Create a transport error for initial request. 213 + pub fn transport( 214 + target: DpopTarget, 215 + url: impl Into<SmolStr>, 216 + source: impl StdError + Send + Sync + 'static, 217 + ) -> Self { 218 + Self::new(DpopErrorKind::Transport) 219 + .with_target(target) 220 + .with_url(url) 221 + .with_source(source) 222 + } 223 + 224 + /// Create a nonce retry error. 225 + pub fn nonce_retry( 226 + target: DpopTarget, 227 + url: impl Into<SmolStr>, 228 + source: impl StdError + Send + Sync + 'static, 229 + ) -> Self { 230 + Self::new(DpopErrorKind::NonceRetry) 231 + .with_target(target) 232 + .with_url(url) 233 + .with_source(source) 234 + .with_help( 235 + "the server rejected both the initial request and the retry with updated nonce", 236 + ) 237 + } 238 + 239 + /// Create an invalid header error. 240 + pub fn invalid_header(source: InvalidHeaderValue) -> Self { 241 + Self::new(DpopErrorKind::InvalidHeader) 242 + .with_source(source) 243 + .with_help("the DPoP proof could not be set as a header value") 244 + } 245 + 246 + /// Create a crypto error. 247 + pub fn crypto(source: crypto::Error) -> Self { 248 + Self::new(DpopErrorKind::Crypto) 249 + .with_context(format!("{:?}", source)) 250 + .with_help( 251 + "ensure the key is a valid secret key in JWK format with a supported algorithm", 252 + ) 253 + } 254 + 255 + /// Create an unsupported key error. 256 + pub fn unsupported_key() -> Self { 257 + Self::new(DpopErrorKind::UnsupportedKey) 258 + .with_help("DPoP requires an EC P-256 key; other key types are not currently supported") 259 + } 260 + 261 + /// Create a serialization error. 262 + pub fn serialization(source: serde_json::Error) -> Self { 263 + Self::new(DpopErrorKind::Serialization) 264 + .with_source(source) 265 + .with_help("failed to serialize JWT claims or header") 266 + } 267 + } 268 + 269 + impl From<InvalidHeaderValue> for DpopError { 270 + fn from(e: InvalidHeaderValue) -> Self { 271 + Self::invalid_header(e) 272 + } 273 + } 274 + 275 + impl From<serde_json::Error> for DpopError { 276 + fn from(e: serde_json::Error) -> Self { 277 + Self::serialization(e) 278 + } 279 + } 280 + 281 + impl From<DpopError> for jacquard_common::error::ClientError { 282 + fn from(e: DpopError) -> Self { 283 + use jacquard_common::error::{AuthError, ClientError}; 284 + 285 + // Extract context from DpopError before converting 286 + let kind = e.kind; 287 + let url = e.url.clone(); 288 + let context = e.context.clone(); 289 + let target = e.target; 290 + 291 + // Build combined context string 292 + let combined_context = match (target, context) { 293 + (Some(t), Some(c)) => Some(smol_str::format_smolstr!("to {}: {}", t, c)), 294 + (Some(t), None) => Some(smol_str::format_smolstr!("to {}", t)), 295 + (None, Some(c)) => Some(c), 296 + (None, None) => None, 297 + }; 298 + 299 + // Map DpopErrorKind to appropriate ClientError 300 + let mut client_err = match kind { 301 + DpopErrorKind::ProofBuild | DpopErrorKind::Crypto | DpopErrorKind::UnsupportedKey => { 302 + ClientError::auth(AuthError::DpopProofFailed) 303 + } 304 + DpopErrorKind::NonceRetry => ClientError::auth(AuthError::DpopNonceFailed), 305 + DpopErrorKind::Transport => ClientError::new( 306 + jacquard_common::error::ClientErrorKind::Transport, 307 + Some(Box::new(e)), 308 + ), 309 + DpopErrorKind::InvalidHeader | DpopErrorKind::Serialization => { 310 + let msg = smol_str::format_smolstr!("DPoP: {:?}", kind); 311 + ClientError::encode(msg) 312 + } 313 + }; 314 + 315 + // Add URL if present (skip for Transport since e was consumed) 316 + if !matches!(kind, DpopErrorKind::Transport) { 317 + if let Some(u) = url { 318 + client_err = client_err.with_url(u); 319 + } 320 + } 321 + 322 + // Add combined context if present (skip for Transport since e was consumed) 323 + if !matches!(kind, DpopErrorKind::Transport) { 324 + if let Some(ctx) = combined_context { 325 + client_err = client_err.with_context(ctx); 326 + } 327 + } 328 + 329 + client_err 330 + } 42 331 } 43 332 44 - type Result<T> = core::result::Result<T, Error>; 333 + type Result<T> = core::result::Result<T, DpopError>; 45 334 46 335 #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 47 336 pub trait DpopClient: HttpClient { ··· 191 480 T: HttpClient, 192 481 N: DpopDataSource, 193 482 { 483 + let target = if is_to_auth_server { 484 + DpopTarget::AuthServer 485 + } else { 486 + DpopTarget::ResourceServer 487 + }; 194 488 let uri = request.uri().clone(); 195 489 let method = request.method().to_cowstr().into_static(); 490 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 196 491 let uri = uri.to_cowstr(); 197 492 let ath = extract_ath(request.headers()); 198 493 ··· 208 503 let response = client 209 504 .send_http(request.clone()) 210 505 .await 211 - .map_err(|e| Error::Inner(e.into()))?; 506 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 212 507 213 508 let next_nonce = response 214 509 .headers() ··· 232 527 let response = client 233 528 .send_http(request) 234 529 .await 235 - .map_err(|e| Error::Inner(e.into()))?; 530 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 236 531 Ok(response) 237 532 } 238 533 ··· 249 544 { 250 545 use jacquard_common::xrpc::StreamingResponse; 251 546 547 + let target = if is_to_auth_server { 548 + DpopTarget::AuthServer 549 + } else { 550 + DpopTarget::ResourceServer 551 + }; 252 552 let uri = request.uri().clone(); 253 553 let method = request.method().to_cowstr().into_static(); 554 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 254 555 let uri = uri.to_cowstr(); 255 556 let ath = extract_ath(request.headers()); 256 557 ··· 266 567 let http_response = client 267 568 .send_http_streaming(request.clone()) 268 569 .await 269 - .map_err(|e| Error::Inner(e.into()))?; 570 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 270 571 271 572 let (parts, body) = http_response.into_parts(); 272 573 let next_nonce = parts ··· 294 595 let http_response = client 295 596 .send_http_streaming(request) 296 597 .await 297 - .map_err(|e| Error::Inner(e.into()))?; 598 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 298 599 let (parts, body) = http_response.into_parts(); 299 600 Ok(StreamingResponse::new(parts, body)) 300 601 } ··· 313 614 { 314 615 use jacquard_common::xrpc::StreamingResponse; 315 616 617 + let target = if is_to_auth_server { 618 + DpopTarget::AuthServer 619 + } else { 620 + DpopTarget::ResourceServer 621 + }; 316 622 let uri = parts.uri.clone(); 317 623 let method = parts.method.to_cowstr().into_static(); 624 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 318 625 let uri = uri.to_cowstr(); 319 626 let ath = extract_ath(&parts.headers); 320 627 ··· 334 641 let http_response = client 335 642 .send_http_bidirectional(parts.clone(), body1.into_inner()) 336 643 .await 337 - .map_err(|e| Error::Inner(e.into()))?; 644 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 338 645 339 646 let (resp_parts, resp_body) = http_response.into_parts(); 340 647 let next_nonce = resp_parts ··· 363 670 let http_response = client 364 671 .send_http_bidirectional(parts, body2.into_inner()) 365 672 .await 366 - .map_err(|e| Error::Inner(e.into()))?; 673 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 367 674 let (parts, body) = http_response.into_parts(); 368 675 Ok(StreamingResponse::new(parts, body)) 369 676 } ··· 430 737 nonce: Option<CowStr<'s>>, 431 738 ath: Option<CowStr<'s>>, 432 739 ) -> Result<CowStr<'s>> { 433 - let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { 740 + let secret = match crypto::Key::try_from(key).map_err(DpopError::crypto)? { 434 741 crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 435 - _ => return Err(Error::UnsupportedKey), 742 + _ => return Err(DpopError::unsupported_key()), 436 743 }; 437 744 let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 438 745 header.typ = Some(JWT_HEADER_TYP_DPOP.into());
+3 -1
crates/jacquard-oauth/src/error.rs
··· 6 6 7 7 /// High-level errors emitted by OAuth helpers. 8 8 #[derive(Debug, thiserror::Error, Diagnostic)] 9 + #[non_exhaustive] 9 10 pub enum OAuthError { 10 11 #[error(transparent)] 11 12 #[diagnostic(code(jacquard_oauth::resolver))] ··· 21 22 22 23 #[error(transparent)] 23 24 #[diagnostic(code(jacquard_oauth::dpop))] 24 - Dpop(#[from] crate::dpop::Error), 25 + Dpop(#[from] crate::dpop::DpopError), 25 26 26 27 #[error(transparent)] 27 28 #[diagnostic(code(jacquard_oauth::keyset))] ··· 54 55 55 56 /// Typed callback validation errors (redirect handling). 56 57 #[derive(Debug, thiserror::Error, Diagnostic)] 58 + #[non_exhaustive] 57 59 pub enum CallbackError { 58 60 #[error("missing state parameter in callback")] 59 61 #[diagnostic(code(jacquard_oauth::callback::missing_state))]
+5 -4
crates/jacquard-oauth/src/keyset.rs
··· 9 9 use thiserror::Error; 10 10 11 11 #[derive(Error, Debug)] 12 + #[non_exhaustive] 12 13 pub enum Error { 13 14 #[error("duplicate kid: {0}")] 14 15 DuplicateKid(String), 15 16 #[error("keys must not be empty")] 16 17 EmptyKeys, 17 - #[error("key must have a `kid`")] 18 - EmptyKid, 18 + #[error("key at index {0} must have a `kid`")] 19 + EmptyKid(usize), 19 20 #[error("no signing key found for algorithms: {0:?}")] 20 21 NotFound(Vec<CowStr<'static>>), 21 22 #[error("key for signing must be a secret key")] ··· 103 104 } 104 105 let mut v = Vec::with_capacity(keys.len()); 105 106 let mut hs = HashSet::with_capacity(keys.len()); 106 - for key in keys { 107 + for (i, key) in keys.into_iter().enumerate() { 107 108 if let Some(kid) = key.prm.kid.clone() { 108 109 if hs.contains(&kid) { 109 110 return Err(Error::DuplicateKid(kid)); ··· 119 120 } 120 121 v.push(key); 121 122 } else { 122 - return Err(Error::EmptyKid); 123 + return Err(Error::EmptyKid(i)); 123 124 } 124 125 } 125 126 Ok(Self(v))
+3 -2
crates/jacquard-oauth/src/request.rs
··· 61 61 62 62 /// Error categories for OAuth request operations 63 63 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 64 + #[non_exhaustive] 64 65 pub enum RequestErrorKind { 65 66 /// No endpoint available 66 67 #[error("no {0} endpoint available")] ··· 331 332 } 332 333 } 333 334 334 - impl From<crate::dpop::Error> for RequestError { 335 - fn from(e: crate::dpop::Error) -> Self { 335 + impl From<crate::dpop::DpopError> for RequestError { 336 + fn from(e: crate::dpop::DpopError) -> Self { 336 337 let msg = smol_str::format_smolstr!("{:?}", e); 337 338 Self::new(RequestErrorKind::Dpop, Some(Box::new(e))) 338 339 .with_context(msg)
+1
crates/jacquard-oauth/src/resolver.rs
··· 74 74 75 75 /// Error categories for OAuth resolver operations 76 76 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 77 + #[non_exhaustive] 77 78 pub enum ResolverErrorKind { 78 79 /// Resource not found 79 80 #[error("resource not found")]
+1
crates/jacquard-oauth/src/scopes.rs
··· 1023 1023 1024 1024 /// Error type for scope parsing 1025 1025 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1026 + #[non_exhaustive] 1026 1027 pub enum ParseError { 1027 1028 /// Unknown scope prefix 1028 1029 UnknownPrefix(String),
+1
crates/jacquard-oauth/src/session.rs
··· 287 287 } 288 288 289 289 #[derive(thiserror::Error, Debug, miette::Diagnostic)] 290 + #[non_exhaustive] 290 291 pub enum Error { 291 292 #[error(transparent)] 292 293 #[diagnostic(code(jacquard_oauth::session::request))]
+11
crates/jacquard-repo/src/error.rs
··· 22 22 23 23 /// Error categories for repository operations 24 24 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 25 + #[non_exhaustive] 25 26 pub enum RepoErrorKind { 26 27 /// Storage operation failed 27 28 Storage, ··· 145 146 Self::new(RepoErrorKind::Car, Some(Box::new(source))) 146 147 } 147 148 149 + /// Create a CAR write error with CID context 150 + pub fn car_write(cid: impl fmt::Display, source: impl Error + Send + Sync + 'static) -> Self { 151 + Self::new(RepoErrorKind::Car, Some(Box::new(source))) 152 + .with_context(format!("failed to write block {}", cid)) 153 + } 154 + 148 155 /// Create a CAR parse error (alias for car) 149 156 pub fn car_parse(source: impl Error + Send + Sync + 'static) -> Self { 150 157 Self::car(source).with_context("Failed to parse CAR file".to_string()) ··· 207 214 208 215 /// MST-specific errors 209 216 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 217 + #[non_exhaustive] 210 218 pub enum MstError { 211 219 /// Empty key not allowed 212 220 #[error("Empty key not allowed")] ··· 253 261 254 262 /// Commit-specific errors 255 263 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 264 + #[non_exhaustive] 256 265 pub enum CommitError { 257 266 /// Invalid commit version 258 267 #[error("Invalid commit version: {0}")] ··· 302 311 303 312 /// Diff-specific errors 304 313 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 314 + #[non_exhaustive] 305 315 pub enum DiffError { 306 316 /// Too many operations 307 317 #[error("Too many operations: {count} (max {max})")] ··· 335 345 336 346 /// Proof verification errors 337 347 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 348 + #[non_exhaustive] 338 349 pub enum ProofError { 339 350 /// CAR file has no root CID 340 351 #[error("CAR file has no root CID")]
+22 -12
crates/jacquard-repo/src/mst/tree.rs
··· 1262 1262 entries = self.get_entries().await? 1263 1263 } 1264 1264 let data = util::serialize_node_data(entries.as_slice()).await?; 1265 - let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| RepoError::car(e))?; 1265 + let bytes = serde_ipld_dagcbor::to_vec(&data).map_err(|e| { 1266 + // Provide best available context for debugging serialization failures 1267 + let context = if let Some(left_cid) = &data.left { 1268 + format!("serializing MST node (left pointer: {})", left_cid) 1269 + } else { 1270 + // No left pointer - use last leaf key as identifier 1271 + let last_key = entries.iter().rev().find_map(|e| match e { 1272 + NodeEntry::Leaf { key, .. } => Some(key.as_str()), 1273 + _ => None, 1274 + }); 1275 + match last_key { 1276 + Some(k) => format!("serializing MST node (last key: {})", k), 1277 + None => "serializing MST node (empty or tree-only)".to_string(), 1278 + } 1279 + }; 1280 + RepoError::serialization(e).with_context(context) 1281 + })?; 1266 1282 let cid = util::compute_cid(&bytes)?; 1267 1283 1268 1284 Ok((cid, Bytes::from_owner(bytes))) ··· 1336 1352 writer 1337 1353 .write(*cid, &data) 1338 1354 .await 1339 - .map_err(|e| RepoError::car(e))?; 1355 + .map_err(|e| RepoError::car_write(cid, e))?; 1340 1356 } 1341 1357 } 1342 1358 ··· 1364 1380 writer 1365 1381 .write(pointer, &node_bytes) 1366 1382 .await 1367 - .map_err(|e| RepoError::car(e))?; 1383 + .map_err(|e| RepoError::car_write(&pointer, e))?; 1368 1384 1369 1385 // Parse to get entries 1370 1386 let entries = self.get_entries().await?; ··· 1411 1427 .collect(); 1412 1428 1413 1429 // Await all tasks concurrently 1414 - let results = try_join_all(tasks) 1415 - .await 1416 - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; 1430 + let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; 1417 1431 1418 1432 // Flatten results 1419 1433 let mut cids = vec![pointer]; ··· 1455 1469 1456 1470 // Await all tasks concurrently 1457 1471 if !tasks.is_empty() { 1458 - let subtree_results = try_join_all(tasks) 1459 - .await 1460 - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; 1472 + let subtree_results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; 1461 1473 1462 1474 for (pos, leaves) in task_positions.into_iter().zip(subtree_results) { 1463 1475 result.push((pos, leaves?)); ··· 1518 1530 1519 1531 // Await all tasks concurrently 1520 1532 if !tasks.is_empty() { 1521 - let results = try_join_all(tasks) 1522 - .await 1523 - .map_err(|e| RepoError::invalid(format!("Task join error: {}", e)))?; 1533 + let results = try_join_all(tasks).await.map_err(RepoError::task_failed)?; 1524 1534 1525 1535 for subtree_result in results { 1526 1536 let (_, subtree_blocks) = subtree_result?;
+45 -35
crates/jacquard/src/client.rs
··· 674 674 let (did, _) = self 675 675 .session_info() 676 676 .await 677 - .ok_or_else(AgentError::no_session)?; 677 + .ok_or_else(|| AgentError::no_session().for_collection("create record", R::NSID))?; 678 678 679 679 #[cfg(feature = "tracing")] 680 680 let _span = tracing::debug_span!("create_record", collection = %R::nsid()).entered(); ··· 692 692 #[cfg(feature = "tracing")] 693 693 _span.exit(); 694 694 695 - let response = self.send(request).await?; 695 + let response = self 696 + .send(request) 697 + .await 698 + .map_err(|e| e.for_collection("create record", R::NSID))?; 696 699 response.into_output().map_err(|e| match e { 697 700 XrpcError::Auth(auth) => AgentError::from(auth), 698 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 699 701 XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed), 702 + e => AgentError::xrpc(e), 700 703 }) 701 704 } 702 705 } ··· 790 793 let http_response = self 791 794 .send_http(http_request) 792 795 .await 793 - .map_err(|e| ClientError::transport(e))?; 796 + .map_err(|e| ClientError::transport(e).for_collection("get record", R::NSID))?; 794 797 795 798 xrpc::process_response(http_response) 796 - }?; 799 + .map_err(|e| e.for_collection("get record", R::NSID))? 800 + }; 797 801 Ok(response.transmute()) 798 802 } 799 803 } ··· 837 841 &self.opts().await, 838 842 )?; 839 843 840 - let http_response = self 841 - .send_http(http_request) 842 - .await 843 - .map_err(|e| ClientError::transport(e))?; 844 + let http_response = self.send_http(http_request).await.map_err(|e| { 845 + ClientError::transport(e).for_collection("fetch record", collection.as_str()) 846 + })?; 844 847 845 848 xrpc::process_response(http_response) 846 - }?; 849 + .map_err(|e| e.for_collection("fetch record", collection.as_str()))? 850 + }; 847 851 let output = response.into_output().map_err(|e| match e { 848 852 XrpcError::Auth(auth) => AgentError::from(auth), 849 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 850 - XrpcError::Xrpc(typed) => AgentError::new( 851 - AgentErrorKind::SubOperation { 852 - step: "fetch record", 853 - }, 854 - None, 855 - ) 856 - .with_details(typed.to_string()), 853 + XrpcError::Xrpc(typed) => AgentError::sub_operation("parse record", typed), 854 + e => AgentError::xrpc(e), 857 855 })?; 858 856 Ok(output) 859 857 } ··· 874 872 { 875 873 let uri = uri.as_uri(); 876 874 async move { 875 + use smol_str::format_smolstr; 876 + 877 877 let response = self.get_record::<R>(uri).await?; 878 878 let response: Response<R::Record> = response.transmute(); 879 879 let output = response.into_output().map_err(|e| match e { 880 880 XrpcError::Auth(auth) => AgentError::from(auth), 881 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 882 - XrpcError::Xrpc(typed) => { 883 - AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) 884 - .with_details(typed.to_string()) 885 - } 881 + XrpcError::Xrpc(typed) => AgentError::new( 882 + AgentErrorKind::SubOperation { 883 + step: "parse record", 884 + }, 885 + None, 886 + ) 887 + .with_details(format_smolstr!("{:?}", typed)), 888 + // Note for future orual: the above was done this way due to GAT lifetime inference constraints.. 889 + e => AgentError::xrpc(e), 886 890 })?; 887 891 Ok(output) 888 892 } ··· 937 941 // Parse to get R<'_> borrowing from response buffer 938 942 let record = response.parse().map_err(|e| match e { 939 943 XrpcError::Auth(auth) => AgentError::from(auth), 940 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 941 944 XrpcError::Xrpc(typed) => { 942 - AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None) 943 - .with_details(typed.to_string()) 945 + AgentError::sub_operation("parse record", typed.into_static()) 944 946 } 947 + e => AgentError::xrpc(e), 945 948 })?; 946 949 947 950 // Convert to owned ··· 954 957 let rkey = uri 955 958 .rkey() 956 959 .ok_or_else(|| { 960 + use jacquard_common::types::string::AtStrError; 957 961 AgentError::sub_operation( 958 962 "extract rkey", 959 - std::io::Error::new(std::io::ErrorKind::InvalidInput, "AtUri missing rkey"), 963 + AtStrError::missing("at-uri-scheme", &uri, "rkey"), 960 964 ) 961 965 })? 962 966 .clone() ··· 983 987 let (did, _) = self 984 988 .session_info() 985 989 .await 986 - .ok_or_else(AgentError::no_session)?; 990 + .ok_or_else(|| AgentError::no_session().for_collection("delete record", R::NSID))?; 987 991 #[cfg(feature = "tracing")] 988 992 let _span = tracing::debug_span!("delete_record", collection = %R::nsid()).entered(); 989 993 ··· 999 1003 #[cfg(feature = "tracing")] 1000 1004 _span.exit(); 1001 1005 1002 - let response = self.send(request).await?; 1006 + let response = self 1007 + .send(request) 1008 + .await 1009 + .map_err(|e| e.for_collection("delete record", R::NSID))?; 1003 1010 response.into_output().map_err(|e| match e { 1004 1011 XrpcError::Auth(auth) => AgentError::from(auth), 1005 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1006 1012 XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed), 1013 + e => AgentError::xrpc(e), 1007 1014 }) 1008 1015 } 1009 1016 } ··· 1031 1038 let (did, _) = self 1032 1039 .session_info() 1033 1040 .await 1034 - .ok_or_else(AgentError::no_session)?; 1041 + .ok_or_else(|| AgentError::no_session().for_collection("put record", R::NSID))?; 1035 1042 1036 1043 let data = 1037 1044 to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?; ··· 1046 1053 #[cfg(feature = "tracing")] 1047 1054 _span.exit(); 1048 1055 1049 - let response = self.send(request).await?; 1056 + let response = self 1057 + .send(request) 1058 + .await 1059 + .map_err(|e| e.for_collection("put record", R::NSID))?; 1050 1060 response.into_output().map_err(|e| match e { 1051 1061 XrpcError::Auth(auth) => AgentError::from(auth), 1052 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1053 1062 XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed), 1063 + e => AgentError::xrpc(e), 1054 1064 }) 1055 1065 } 1056 1066 } ··· 1105 1115 let response = self.send_with_opts(request, opts).await?; 1106 1116 let output = response.into_output().map_err(|e| match e { 1107 1117 XrpcError::Auth(auth) => AgentError::from(auth), 1108 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1109 1118 XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed), 1119 + e => AgentError::xrpc(e), 1110 1120 })?; 1111 1121 Ok(output.blob.blob().clone().into_static()) 1112 1122 } ··· 1148 1158 let response = self.send(get_request).await?; 1149 1159 let output = response.parse().map_err(|e| match e { 1150 1160 XrpcError::Auth(auth) => AgentError::from(auth), 1151 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 1152 1161 XrpcError::Xrpc(typed) => { 1153 1162 AgentError::sub_operation("update vec", typed.into_static()) 1154 1163 } 1164 + e => AgentError::xrpc(e), 1155 1165 })?; 1156 1166 1157 1167 // Extract vec (converts to owned via IntoStatic)
+39 -1
crates/jacquard/src/client/error.rs
··· 50 50 51 51 /// Error categories for Agent operations 52 52 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 53 + #[non_exhaustive] 53 54 pub enum AgentErrorKind { 54 55 /// Transport/network layer failure 55 56 #[error("client error (see context for details)")] ··· 172 173 self 173 174 } 174 175 176 + /// Append additional context to existing context string. 177 + /// 178 + /// If context already exists, appends with ": " separator. 179 + /// If no context exists, sets it directly. 180 + pub fn append_context(mut self, additional: impl AsRef<str>) -> Self { 181 + self.context = Some(match self.context.take() { 182 + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), 183 + None => additional.as_ref().into(), 184 + }); 185 + self 186 + } 187 + 188 + /// Add NSID context for XRPC operations. 189 + /// 190 + /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`. 191 + pub fn for_nsid(self, nsid: &str) -> Self { 192 + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) 193 + } 194 + 195 + /// Add collection context for record operations. 196 + /// 197 + /// Use this when a record operation fails to indicate the target collection. 198 + pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self { 199 + self.append_context(smol_str::format_smolstr!("{} [{}]", operation, collection_nsid)) 200 + } 201 + 175 202 /// Add XRPC error data to this error for observability 176 203 pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self 177 204 where ··· 253 280 fn from(e: ClientError) -> Self { 254 281 use smol_str::ToSmolStr; 255 282 256 - let context_msg: SmolStr; 283 + let mut context_msg: SmolStr; 257 284 let help_msg: SmolStr; 258 285 let url = e.url().map(|s| s.to_smolstr()); 259 286 let details = e.details().map(|s| s.to_smolstr()); 287 + // Preserve original context from ClientError to append later 288 + let original_context = e.context().map(|s| s.to_smolstr()); 260 289 261 290 // Build context and help based on the error kind 262 291 match e.kind() { ··· 297 326 help_msg = "verify storage backend is accessible".to_smolstr(); 298 327 context_msg = "storage operation failed".to_smolstr(); 299 328 } 329 + _ => { 330 + help_msg = "see source error for details".to_smolstr(); 331 + context_msg = "client error".to_smolstr(); 332 + } 333 + } 334 + 335 + // Append original context from ClientError if present 336 + if let Some(original) = original_context { 337 + context_msg = smol_str::format_smolstr!("{}: {}", context_msg, original); 300 338 } 301 339 302 340 let mut error = Self::new(AgentErrorKind::Client, Some(Box::new(e)));
+14
crates/jacquard/src/client/token.rs
··· 243 243 244 244 impl FileAuthStore { 245 245 /// Create a new file-backed auth store wrapping `FileTokenStore`. 246 + /// 247 + /// # Errors 248 + /// 249 + /// Returns an error if parent directories cannot be created or the file cannot be written. 250 + pub fn try_new(path: impl AsRef<std::path::Path>) -> Result<Self, SessionStoreError> { 251 + Ok(Self(FileTokenStore::try_new(path)?)) 252 + } 253 + 254 + /// Create a new file-backed auth store wrapping `FileTokenStore`. 255 + /// 256 + /// # Panics 257 + /// 258 + /// Panics if parent directories cannot be created or the file cannot be written. 259 + /// Prefer [`try_new`](Self::try_new) for fallible construction. 246 260 pub fn new(path: impl AsRef<std::path::Path>) -> Self { 247 261 Self(FileTokenStore::new(path)) 248 262 }
+2 -1
crates/jacquard/src/moderation/fetch.rs
··· 34 34 XrpcError::Generic(g) => ClientError::decode(g.to_string()), 35 35 XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)), 36 36 XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)), 37 + _ => ClientError::decode("unknown XRPC error"), 37 38 })?; 38 39 39 40 let mut defs = LabelerDefs::new(); ··· 132 133 .into_output() 133 134 .map_err(|e| match e { 134 135 XrpcError::Auth(auth) => AgentError::from(auth), 135 - e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 136 136 XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)), 137 + e => AgentError::xrpc(e), 137 138 })?; 138 139 Ok((labels.labels, labels.cursor)) 139 140 }
+1
crates/jacquard/src/richtext.rs
··· 699 699 700 700 /// Errors that can occur during richtext building 701 701 #[derive(Debug, thiserror::Error, miette::Diagnostic)] 702 + #[non_exhaustive] 702 703 pub enum RichTextError { 703 704 /// Handle found that needs resolution but no resolver provided 704 705 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]