-2
.gitignore
-2
.gitignore
+1
AGENTS.md
+1
AGENTS.md
···
1
+
CLAUDE.md
+269
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
+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
+1
crates/jacquard-axum/src/service_auth.rs
+1
-1
crates/jacquard-axum/tests/service_auth_tests.rs
+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
+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
+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
+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
+1
crates/jacquard-common/src/stream.rs
+1
crates/jacquard-common/src/types/cid.rs
+1
crates/jacquard-common/src/types/cid.rs
+1
crates/jacquard-common/src/types/collection.rs
+1
crates/jacquard-common/src/types/collection.rs
+1
crates/jacquard-common/src/types/crypto.rs
+1
crates/jacquard-common/src/types/crypto.rs
+14
-9
crates/jacquard-common/src/types/uri.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
crates/jacquard-oauth/src/resolver.rs
+1
crates/jacquard-oauth/src/scopes.rs
+1
crates/jacquard-oauth/src/scopes.rs
+1
crates/jacquard-oauth/src/session.rs
+1
crates/jacquard-oauth/src/session.rs
+11
crates/jacquard-repo/src/error.rs
+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
+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
+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
+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
+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
+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
+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")]