Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,34 @@ pub struct SomeRequest { ... }
- `rust` feature — `bon` builder pattern for ergonomic Rust usage

### Error Handling
`SdkError` (`crates/core/src/errors.rs`) uses `thiserror` with three variants:
- `Http` — wraps `reqwest::Error`
`SdkError` (`crates/core/src/errors.rs`) uses `thiserror` with five variants:
- `Http` — wraps `reqwest::Error` (further classified via `SdkError::http_kind()` → `HttpKind::{Timeout, Connect, Other}`)
- `Api` — non-2xx response with status code and raw body
- `Decode` — JSON parse failure with raw body for debugging

Language bindings convert `SdkError` to native exceptions: `PyValueError` (Python), `napi::Error` (Node.js), `magnus::Error` / `RuntimeError` (Ruby).
- `UrlParse` — invalid URL (wraps `url::ParseError`)
- `Config` — invalid configuration (string message)

Each binding exposes a typed exception hierarchy rooted at a shared base class so callers can `rescue` / `catch` / `except` by category. The mapping is:

| `SdkError` variant | Python / Ruby class | Node class | Base |
|---|---|---|---|
| `Config`, `UrlParse` | `ConfigError` | `ConfigError` | `QuickNodeError` |
| `Http` + `HttpKind::Timeout` | `TimeoutError` | `TimeoutError` | `HttpError` |
| `Http` + `HttpKind::Connect` | `ConnectionError` | `ConnectionError` | `HttpError` |
| `Http` + `HttpKind::Other` | `HttpError` | `HttpError` | `QuickNodeError` |
| `Api { status, body }` | `ApiError` (with `.status`, `.body`) | `ApiError` (with `.status`, `.body`) | `QuickNodeError` |
| `Decode { body, .. }` | `DecodeError` (with `.body`) | `DecodeError` (with `.body`) | `QuickNodeError` |

Each binding owns its mapping in a dedicated `errors.rs` file:
- **Python** — `crates/python/src/errors.rs` uses `create_exception!` macros; `map_sdk_err` sets `.status` / `.body` attributes via `setattr` on the exception instance. Exceptions are registered on the module in `add_to_module`.
- **Node** — `crates/node/src/errors.rs` encodes the variant, status, and body into a tagged message (`[<kind>|<status>|<body_len>]<msg>\x1f<body>`) because napi-rs only supports plain `napi::Error`. The JS wrapper `npm/errors.js` (`fromNapiError` + `wrapClient` Proxy) parses the prefix and rethrows as the typed subclass. All client methods must be wrapped via `wrapClient` so sync throws and rejected promises are both re-tagged.
- **Ruby** — `crates/ruby/src/errors.rs` uses `module.define_error` to build the class hierarchy under `QuickNodeSdk::Error`; `map_err` instantiates the class and sets `@status` / `@body` ivars (exposed via `attr_reader`-style methods). Classes are captured once in a `OnceLock<Opaque<ExceptionClass>>`.

When adding a new `SdkError` variant:
1. Add the variant to `crates/core/src/errors.rs` and update `http_kind()` if it's transport-level.
2. Update the `match` in each binding's `map_*_err` function — the compiler will flag missing arms in Python and Ruby (Node's match is also exhaustive on the kind string).
3. If the new variant should surface as a new exception class, add it to all three bindings + `npm/errors.js` + the exports in `python/sdk/__init__.py` + `npm/sdk.d.ts` + `npm/sdk.mjs`.
4. Update examples in all four languages to demonstrate the new class if user-facing.

### Python Binding Pattern
`crates/python/src/lib.rs` wraps core async methods using `pyo3_async_runtimes::tokio::future_into_py`. The Python API accepts individual keyword arguments instead of structs.
Expand Down Expand Up @@ -148,6 +170,9 @@ Core clients are tested using mocked API calls with wiremock. All functions maki
### Error handling
- Library constructors should return `Result`, not panic — use `.unwrap()` or `.expect()` only in examples and tests, never in library code
- Validate numeric config values before casting between signed/unsigned types (e.g., check `>= 0` before `i64 as u64`)
- Map `SdkError` at the binding boundary only — keep core code returning `Result<_, SdkError>`, never a language-specific exception type. See the Error Handling section above for the typed exception hierarchy and how to add a new variant.
- When a binding needs new error metadata (status, body, retry info, etc.), add it to the `SdkError` variant first, then surface it on the exception class in each binding (PyO3 `setattr`, Ruby `ivar_set`, Node tagged-message prefix).
- Exception-raising tests belong in each language's example script (`crates/core/examples/admin_e2e.rs`, `python/examples/admin.py`, `npm/examples/admin.ts`, `ruby/examples/admin_e2e.rb`) — assert on the typed class, `status`, and `body` so regressions in the mapping layer fail loudly.

### Backwards Compatability
- If the release is still an 0.1.z release, we don't need to worry about backwards compatability as this is a greenfield project
Expand Down
78 changes: 50 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3300,52 +3300,74 @@ qn.kvstore.delete_list(key: "my-list")

## Error Handling

The core SDK defines `SdkError` (`crates/core/src/errors.rs`) with these variants:

- `Http` — transport failure (wraps `reqwest::Error`).
- `Api { status, body }` — non-2xx HTTP response, carrying the status code and raw response body.
- `Decode { source, body }` — response was 2xx but JSON parsing failed; `body` holds the raw payload for debugging.
- `Config` — misconfiguration surfaced at construction time.

Each language binding maps these to its native exception type:

- **Rust**: `Result<T, SdkError>` — pattern-match on the variants.
- **Python**: raises `ValueError` (`PyValueError`) with the error message.
- **Node.js**: rejects with a napi-wrapped `Error` carrying the message.
- **Ruby**: raises `RuntimeError` for SDK errors and `ArgumentError` for missing/unknown Hash keys or bad types.

```rust
// Rust
match qn.streams.get_stream("missing").await {
Ok(stream) => println!("{}", stream.name),
Err(SdkError::Api { status, body }) => eprintln!("api {status}: {body}"),
Err(e) => eprintln!("other error: {e}"),
Every binding exposes a typed exception hierarchy derived from the core `SdkError`
enum (`crates/core/src/errors.rs`). Catch the base class (`QuickNodeError` /
`QuickNodeSdk::Error` / `SdkError`) for any SDK-originated failure, or a specific
subclass to branch on transport vs. API semantics.

| Logical class | When it fires | Extra fields |
|----------------------|-------------------------------------------------------------|----------------------|
| `QuickNodeError` | base class; catches everything below | — |
| `ConfigError` | invalid config or URL surfaced at construction time | — |
| `HttpError` | transport failure that isn't a timeout/connect | — |
| `TimeoutError` | request timed out (subclass of `HttpError`) | — |
| `ConnectionError` | connection refused / DNS / TLS (subclass of `HttpError`) | — |
| `ApiError` | non-2xx HTTP response | `status`, `body` |
| `DecodeError` | 2xx response but JSON parse failed | `body` |

Per-language names:

- **Rust** — pattern-match on `SdkError { Http, Api, Decode, UrlParse, Config }`; use `err.http_kind()` to classify `Http` into `Timeout`, `Connect`, or `Other`.
- **Python** — `QuickNodeError`, `ConfigError`, `HttpError`, `TimeoutError`, `ConnectionError`, `ApiError`, `DecodeError` (importable from `sdk`).
- **Node.js** — same class names, importable from `@quicknode/sdk`, all extend `Error`.
- **Ruby** — `QuickNodeSdk::Error`, `QuickNodeSdk::ConfigError`, `QuickNodeSdk::HttpError`, `QuickNodeSdk::TimeoutError`, `QuickNodeSdk::ConnectionError`, `QuickNodeSdk::ApiError`, `QuickNodeSdk::DecodeError`; all extend `StandardError`. Hash-key validation still raises `ArgumentError`.

```rust
// Rust
match qn.admin.show_endpoint("missing").await {
Ok(resp) => println!("{:?}", resp.data),
Err(SdkError::Api { status, body }) if status.as_u16() == 404 => {
eprintln!("not found: {body}")
}
Err(e) if matches!(e.http_kind(), Some(HttpKind::Timeout)) => eprintln!("timed out"),
Err(e) => eprintln!("other: {e}"),
}
```

```python
# Python
from sdk import ApiError, TimeoutError
try:
await qn.streams.get_stream("missing")
except ValueError as e:
print(f"sdk error: {e}")
await qn.admin.show_endpoint("missing")
except ApiError as e:
if e.status == 404:
print(f"not found: {e.body}")
else:
raise
except TimeoutError:
print("timed out")
```

```typescript
// Node.js
import { ApiError, TimeoutError } from "@quicknode/sdk";
try {
await qn.streams.getStream("missing");
await qn.admin.showEndpoint("missing");
} catch (e) {
console.error("sdk error:", e);
if (e instanceof ApiError && e.status === 404) console.error("not found:", e.body);
else if (e instanceof TimeoutError) console.error("timed out");
else throw e;
}
```

```ruby
# Ruby
begin
qn.streams.get_stream(id: "missing")
rescue => e
warn "sdk error: #{e.message}"
qn.admin.show_endpoint(id: "missing")
rescue QuickNodeSdk::ApiError => e
warn "api #{e.status}: #{e.body}" if e.status == 404
rescue QuickNodeSdk::TimeoutError
warn "timed out"
end
```

Expand Down
43 changes: 41 additions & 2 deletions crates/core/examples/admin_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use sdk_core::{
UpdateRateLimitsRequest, UpdateRequestFilterRequest, UpdateSecurityOptionsRequest,
UpdateTeamEndpointsRequest,
},
QuickNodeSdk, SdkFullConfig,
errors::SdkError,
AdminConfig, HttpConfig, QuickNodeSdk, SdkFullConfig,
};

#[tokio::main]
Expand Down Expand Up @@ -539,7 +540,7 @@ async fn main() {
&endpoint_id,
&UpdateRateLimitsRequest {
rate_limits: RateLimitSettings {
rps: Some(10),
rps: Some(3),
..Default::default()
},
},
Expand Down Expand Up @@ -793,4 +794,42 @@ async fn main() {
Ok(()) => println!("archive_endpoint: ok"),
Err(e) => eprintln!("archive_endpoint error: {e}"),
}

// --- Error handling ---

// 1) API error path — 404 on a bogus endpoint id.
match qn.admin.show_endpoint("does-not-exist").await {
Err(SdkError::Api { status, body }) => {
println!("api error {status}: {}", &body[..body.len().min(80)]);
assert_eq!(status.as_u16(), 404);
}
other => eprintln!("expected Api 404, got {other:?}"),
}

// 2) Timeout path — unreachable base URL + 1s timeout forces a timeout
// from reqwest, which maps to SdkError::Http with http_kind() == Timeout.
let blackhole = SdkFullConfig {
api_key: config.api_key.clone(),
http: Some(HttpConfig {
timeout_secs: Some(1),
pool_max_idle_per_host: None,
}),
admin: Some(AdminConfig {
base_url: Some("http://10.255.255.1/".to_string()),
}),
streams: None,
webhooks: None,
kvstore: None,
};
let tiny = QuickNodeSdk::new(&blackhole).expect("build tiny sdk");
match tiny
.admin
.get_endpoints(&GetEndpointsRequest::default())
.await
{
Err(e) if matches!(e.http_kind(), Some(sdk_core::errors::HttpKind::Timeout)) => {
println!("timed out as expected");
}
other => eprintln!("expected timeout, got {other:?}"),
}
}
54 changes: 54 additions & 0 deletions crates/core/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,57 @@ pub enum SdkError {
#[error("Configuration error: {0}")]
Config(String),
}

// Classifies a transport-level HTTP failure. Bindings use this to pick a
// typed exception subclass (TimeoutError / ConnectionError / HttpError) so the
// reqwest predicate logic lives in one place.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpKind {
Timeout,
Connect,
Other,
}

impl SdkError {
pub fn http_kind(&self) -> Option<HttpKind> {
match self {
SdkError::Http(e) if e.is_timeout() => Some(HttpKind::Timeout),
SdkError::Http(e) if e.is_connect() => Some(HttpKind::Connect),
SdkError::Http(_) => Some(HttpKind::Other),
_ => None,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn api_error_display_includes_status_and_body() {
let err = SdkError::Api {
status: reqwest::StatusCode::NOT_FOUND,
body: "not found".to_string(),
};
let s = err.to_string();
assert!(s.contains("404"), "expected 404 in {s}");
assert!(s.contains("not found"), "expected body in {s}");
}

#[test]
fn config_error_display() {
let err = SdkError::Config("missing api key".to_string());
assert!(err.to_string().contains("missing api key"));
}

#[test]
#[allow(clippy::unwrap_used)]
fn http_kind_none_for_non_http_variants() {
assert!(SdkError::Config("x".to_string()).http_kind().is_none());
let decode_err = SdkError::Decode {
source: serde_json::from_str::<i32>("bad").unwrap_err(),
body: "bad".to_string(),
};
assert!(decode_err.http_kind().is_none());
}
}
36 changes: 36 additions & 0 deletions crates/node/src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use napi::{bindgen_prelude::Error, Status};
use sdk_core::errors::{HttpKind, SdkError};

// napi-rs can only throw plain napi::Error with a status + message. To give
// callers a typed class hierarchy in JS, we encode the variant as a structured
// prefix in the message; the JS-side wrapper (npm/sdk.js) parses the prefix
// and rethrows as the matching typed class (ApiError / TimeoutError / ...).
//
// Wire format: "[<kind>|<status>|<body_len>]<original_message>"
// - kind: one of Config | Http | Timeout | Connect | Api | Decode
// - status: u16 for Api, "-" otherwise
// - body_len: byte length of body blob appended after message (for Api/Decode), "-" otherwise
// The body bytes are appended after a "\x1f" (unit separator) so JS can split cleanly.
#[allow(clippy::needless_pass_by_value)]
pub fn map_sdk_err(e: SdkError) -> Error {
let msg = e.to_string();
let (kind, status, body) = match &e {
SdkError::Config(_) | SdkError::UrlParse(_) => ("Config", None, None),
SdkError::Api { status, body } => ("Api", Some(status.as_u16()), Some(body.clone())),
SdkError::Decode { body, .. } => ("Decode", None, Some(body.clone())),
SdkError::Http(_) => match e.http_kind() {
Some(HttpKind::Timeout) => ("Timeout", None, None),
Some(HttpKind::Connect) => ("Connect", None, None),
_ => ("Http", None, None),
},
};
let status_s = status.map_or("-".to_string(), |s| s.to_string());
let body_s = body.as_deref().unwrap_or("");
let body_len = if body.is_some() {
body_s.len().to_string()
} else {
"-".to_string()
};
let tagged = format!("[{kind}|{status_s}|{body_len}]{msg}\u{001f}{body_s}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing \x1f control character in Node.js error messages

Medium Severity

The Rust format! on line 34 unconditionally appends \u{001f} (unit separator) even when body_s is empty (for Config, Http, Timeout, Connect variants). On the JS side, fromNapiError only strips the separator when bodyLenStr !== "-", so non-body error variants end up with a trailing \x1f control character in their .message. This pollutes user-facing error messages and can break string comparisons, JSON serialization, and log output.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fb4e06a. Configure here.

Error::new(Status::GenericFailure, tagged)
}
Loading
Loading