diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52100b3..8a6353a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,28 @@ jobs: # 测试(如 catcher-ffi quality_test)掩盖后续整个套件的执行结果(issue #033)。 - run: cd packages && cargo test --workspace --no-fail-fast + napi-http-error-contract: + name: napi-http errors (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "packages -> target" + - run: pnpm install + - run: pnpm --filter @eric8810/catcher-napi-http build + - run: pnpm exec vitest run --config vitest.napi.config.ts packages/test/integration/napi-error-contract.test.ts + dart-ffi-roundtrip: runs-on: ubuntu-latest steps: diff --git a/packages/catcher-core/src/error.rs b/packages/catcher-core/src/error.rs index c9e7f91..88c4e99 100644 --- a/packages/catcher-core/src/error.rs +++ b/packages/catcher-core/src/error.rs @@ -15,6 +15,12 @@ pub enum CatcherError { #[error("DNS resolution failed for {host}: {reason}")] DnsError { host: String, reason: String }, + #[error("connection failed for {host}: {reason}")] + ConnectionError { host: String, reason: String }, + + #[error("transport request failed: {0}")] + TransportError(String), + #[error("HTTP error: status {status}, body: {body}")] HttpError { status: u16, body: String }, @@ -28,7 +34,13 @@ pub enum CatcherError { WsAllEndpointsFailed { count: usize }, #[error("retry exhausted after {attempts} attempts: {last_error}")] - RetryExhausted { attempts: u32, last_error: String }, + RetryExhausted { + /// 已实际执行的总次数,包含首次请求,不是额外重试次数。 + attempts: u32, + /// 最后一次请求的结构化错误。 + #[source] + last_error: Box, + }, #[error("circuit breaker is OPEN, request rejected")] CircuitBreakerOpen, @@ -74,6 +86,8 @@ impl CatcherError { | CatcherError::RequestTimeout(_) | CatcherError::TlsError(_) | CatcherError::DnsError { .. } + | CatcherError::ConnectionError { .. } + | CatcherError::TransportError(_) | CatcherError::WsHandshakeTimeout(_) | CatcherError::WsDisconnected { .. } | CatcherError::WsAllEndpointsFailed { .. } @@ -168,7 +182,7 @@ mod tests { fn retry_exhausted_is_non_retryable() { let err = CatcherError::RetryExhausted { attempts: 5, - last_error: "timeout".into(), + last_error: Box::new(CatcherError::RequestTimeout(1000)), }; assert_eq!(err.category(), ErrorCategory::NonRetryable); } @@ -242,6 +256,11 @@ mod tests { host: "h".into(), reason: "r".into(), }, + CatcherError::ConnectionError { + host: "h".into(), + reason: "refused".into(), + }, + CatcherError::TransportError("socket reset".into()), CatcherError::HttpError { status: 500, body: "b".into(), @@ -254,7 +273,10 @@ mod tests { CatcherError::WsAllEndpointsFailed { count: 1 }, CatcherError::RetryExhausted { attempts: 3, - last_error: "e".into(), + last_error: Box::new(CatcherError::ConnectionError { + host: "h".into(), + reason: "e".into(), + }), }, CatcherError::CircuitBreakerOpen, CatcherError::QueueTimeout(1000), diff --git a/packages/catcher-http/src/resilience/retry.rs b/packages/catcher-http/src/resilience/retry.rs index 233ca8a..e36b7be 100644 --- a/packages/catcher-http/src/resilience/retry.rs +++ b/packages/catcher-http/src/resilience/retry.rs @@ -4,7 +4,11 @@ use catcher_core::{CatcherError, ErrorCategory}; use std::cell::Cell; use std::time::Duration; -/// 对异步操作执行重试,带指数退避 + jitter +/// 对异步操作执行重试,带指数退避 + jitter。 +/// +/// 这是供 Rust 调用方独立使用的通用工具,不参与 `HttpTransport` 内部的 +/// `MetricsRetryMiddleware` 请求链。已经耗尽的 `RetryExhausted` 是不可重试错误, +/// 即使调用方把它返回给本函数,也不会再次重试或嵌套包装。 pub async fn retry_with_backoff( config: &RetryConfig, mut operation: F, @@ -89,8 +93,8 @@ where result.map_err(|e| { if final_attempt > 1 { CatcherError::RetryExhausted { - attempts: max_attempts, - last_error: format!("{e}"), + attempts: final_attempt, + last_error: Box::new(e), } } else { e @@ -177,6 +181,41 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn retry_exhausted_is_not_retried_or_nested() { + let calls = AtomicU32::new(0); + let result = retry_with_backoff( + &test_config(), + || { + calls.fetch_add(1, Ordering::SeqCst); + async { + Err::(CatcherError::RetryExhausted { + attempts: 2, + last_error: Box::new(CatcherError::ConnectionTimeout(100)), + }) + } + }, + |_| true, + |_, _| {}, + ) + .await; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + match result { + Err(CatcherError::RetryExhausted { + attempts, + last_error, + }) => { + assert_eq!(attempts, 2); + assert!(matches!( + last_error.as_ref(), + CatcherError::ConnectionTimeout(100) + )); + } + other => panic!("expected one RetryExhausted layer, got {other:?}"), + } + } + #[tokio::test] async fn on_retry_callback_invoked() { let retry_count = AtomicU32::new(0); diff --git a/packages/catcher-http/src/transport/http_client.rs b/packages/catcher-http/src/transport/http_client.rs index f4ae93e..adffad6 100644 --- a/packages/catcher-http/src/transport/http_client.rs +++ b/packages/catcher-http/src/transport/http_client.rs @@ -1,6 +1,8 @@ use reqwest::Client; use reqwest_middleware::{ClientBuilder as MiddlewareBuilder, ClientWithMiddleware}; +use reqwest_retry::RetryError; use std::collections::HashMap; +use std::error::Error as StdError; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; @@ -877,14 +879,73 @@ fn map_middleware_error_standalone( e: reqwest_middleware::Error, config: &HttpClientConfig, ) -> CatcherError { - let msg = format!("{e}"); - if msg.contains("timeout") || msg.contains("timed out") { - return CatcherError::RequestTimeout(config.response_timeout_ms); + match e { + reqwest_middleware::Error::Reqwest(error) => map_reqwest_error(error, config), + reqwest_middleware::Error::Middleware(error) => match error.downcast::() { + Ok(RetryError::WithRetries { retries, err }) => { + let last_error = map_middleware_error_standalone(err, config); + CatcherError::RetryExhausted { + attempts: retries.saturating_add(1), + last_error: Box::new(last_error), + } + } + Ok(RetryError::Error(err)) => map_middleware_error_standalone(err, config), + Err(error) => CatcherError::Internal(format!( + "request middleware: {}", + error_chain(error.as_ref()) + )), + }, } - if msg.contains("connect") || msg.contains("connection") { +} + +fn map_reqwest_error(error: reqwest::Error, config: &HttpClientConfig) -> CatcherError { + let is_timeout = error.is_timeout(); + let is_connect = error.is_connect(); + let host = error + .url() + .and_then(|url| url.host_str()) + .unwrap_or("unknown") + .to_string(); + let error = error.without_url(); + let reason = error_chain(&error); + let normalized_reason = reason.to_ascii_lowercase(); + + if is_timeout && is_connect { return CatcherError::ConnectionTimeout(config.connect_timeout_ms); } - CatcherError::Internal(format!("request: {e}")) + if is_timeout { + return CatcherError::RequestTimeout(config.response_timeout_ms); + } + if normalized_reason.contains("dns") + || normalized_reason.contains("failed to lookup address") + || normalized_reason.contains("failed to resolve") + || normalized_reason.contains("no record found") + { + return CatcherError::DnsError { host, reason }; + } + if normalized_reason.contains("tls") + || normalized_reason.contains("certificate") + || normalized_reason.contains("handshake") + { + return CatcherError::TlsError(reason); + } + if is_connect { + return CatcherError::ConnectionError { host, reason }; + } + CatcherError::TransportError(reason) +} + +fn error_chain(error: &(dyn StdError + 'static)) -> String { + let mut messages = Vec::new(); + let mut current = Some(error); + while let Some(cause) = current { + let message = cause.to_string(); + if messages.last() != Some(&message) { + messages.push(message); + } + current = cause.source(); + } + messages.join(": ") } /// Simple base64 encoding for Basic auth (no external dependency needed) @@ -1066,6 +1127,45 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn retry_exhaustion_preserves_last_transport_error() { + let config = HttpClientConfig { + base_url: "http://127.0.0.1:0".into(), + connect_timeout_ms: 500, + response_timeout_ms: 500, + retry: Some(catcher_core::RetryConfig { + max_attempts: 1, + min_backoff_ms: 1, + max_backoff_ms: 1, + ..Default::default() + }), + ..Default::default() + }; + let transport = HttpTransport::new(config).unwrap(); + let error = transport + .get("/test?access_token=must-not-leak") + .await + .unwrap_err(); + + match error { + CatcherError::RetryExhausted { + attempts, + last_error, + } => { + assert_eq!(attempts, 2); + assert!(matches!( + last_error.as_ref(), + CatcherError::ConnectionError { .. } + )); + let message = last_error.to_string(); + assert!(message.contains("connection failed")); + assert!(!message.contains("Request failed after")); + assert!(!message.contains("must-not-leak")); + } + other => panic!("expected RetryExhausted, got {other:?}"), + } + } + #[tokio::test] async fn rp2_no_proxy_direct_connection() { use wiremock::matchers::method; diff --git a/packages/catcher-napi-http/README.md b/packages/catcher-napi-http/README.md index efac894..d42731b 100644 --- a/packages/catcher-napi-http/README.md +++ b/packages/catcher-napi-http/README.md @@ -164,6 +164,25 @@ HTTP responses with status `>= 400` reject with `HttpError`, which exposes struc client's connection pool and retries once on a fresh connection. Other clients and in-flight requests are not cancelled. +Native transport failures reject with `CatcherError` instead of the N-API default +`GenericFailure`. The error exposes a stable `code`, failure `phase`, `retryable` +flag, and structured `details`. Retry exhaustion includes both the total attempt +count (the initial request plus retries actually executed) and the final structured +transport error in `details.lastError`, including its own `code`, `phase`, `retryable`, +and `details`; request URLs are stripped before the native cause chain is serialized. + +```typescript +import { CatcherError } from '@eric8810/catcher-napi-http' + +try { + await client.get('/users/1') +} catch (error) { + if (error instanceof CatcherError) { + console.log(error.code, error.phase, error.details) + } +} +``` + ### Methods | Method | Signature | diff --git a/packages/catcher-napi-http/src/client.rs b/packages/catcher-napi-http/src/client.rs index df78739..f0def38 100644 --- a/packages/catcher-napi-http/src/client.rs +++ b/packages/catcher-napi-http/src/client.rs @@ -25,7 +25,9 @@ use catcher_http::{ }; use catcher_core::types::resilience::CbState; +use catcher_core::CatcherError; +use crate::error::to_napi_error; use crate::helpers::{parse_method, stream_event_to_json, Tsfn}; // ── JavaScript-facing types ── @@ -91,9 +93,8 @@ impl JsHttpClient { #[napi(constructor)] pub fn new(config_json: String) -> napi::Result { let config: HttpClientConfig = serde_json::from_str(&config_json) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - let transport = - HttpTransport::new(config).map_err(|e| napi::Error::from_reason(e.to_string()))?; + .map_err(|e| to_napi_error(CatcherError::InvalidConfig(e.to_string())))?; + let transport = HttpTransport::new(config).map_err(to_napi_error)?; Ok(Self { inner: Arc::new(transport), }) @@ -208,9 +209,7 @@ impl JsHttpClient { /// In-flight requests are unaffected. #[napi] pub fn network_changed(&self) -> napi::Result<()> { - self.inner - .network_changed() - .map_err(|e| napi::Error::from_reason(e.to_string())) + self.inner.network_changed().map_err(to_napi_error) } // ── Cancel ── @@ -325,11 +324,7 @@ impl JsHttpClient { multipart: None, }; - let resp = self - .inner - .execute(request) - .await - .map_err(|e| napi::Error::from_reason(e.to_string()))?; + let resp = self.inner.execute(request).await.map_err(to_napi_error)?; Ok(JsHttpResponse { status: resp.status, diff --git a/packages/catcher-napi-http/src/error.rs b/packages/catcher-napi-http/src/error.rs new file mode 100644 index 0000000..a1f1602 --- /dev/null +++ b/packages/catcher-napi-http/src/error.rs @@ -0,0 +1,249 @@ +use catcher_core::{CatcherError, ErrorCategory}; +use serde::Serialize; + +pub(crate) const NATIVE_ERROR_PREFIX: &str = "CATCHER_ERROR:"; + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct NativeErrorDetails { + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + attempts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_error: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NativeErrorPayload { + code: &'static str, + phase: &'static str, + retryable: bool, + message: String, + details: NativeErrorDetails, +} + +impl From<&CatcherError> for NativeErrorPayload { + fn from(error: &CatcherError) -> Self { + let retryable = error.category() == ErrorCategory::Retryable; + let (code, phase, details) = match error { + CatcherError::ConnectionTimeout(timeout_ms) => ( + "CONNECTION_TIMEOUT", + "connect", + NativeErrorDetails { + timeout_ms: Some(*timeout_ms), + ..Default::default() + }, + ), + CatcherError::RequestTimeout(timeout_ms) => ( + "REQUEST_TIMEOUT", + "request", + NativeErrorDetails { + timeout_ms: Some(*timeout_ms), + ..Default::default() + }, + ), + CatcherError::TlsError(reason) => ( + "TLS_ERROR", + "tls", + NativeErrorDetails { + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::DnsError { host, reason } => ( + "DNS_ERROR", + "dns", + NativeErrorDetails { + host: Some(host.clone()), + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::ConnectionError { host, reason } => ( + "CONNECTION_ERROR", + "connect", + NativeErrorDetails { + host: Some(host.clone()), + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::TransportError(reason) => ( + "TRANSPORT_ERROR", + "request", + NativeErrorDetails { + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::HttpError { status, body } => ( + "HTTP_ERROR", + "response", + NativeErrorDetails { + status: Some(*status), + body: Some(body.clone()), + ..Default::default() + }, + ), + CatcherError::WsHandshakeTimeout(timeout_ms) => ( + "WS_HANDSHAKE_TIMEOUT", + "connect", + NativeErrorDetails { + timeout_ms: Some(*timeout_ms), + ..Default::default() + }, + ), + CatcherError::WsDisconnected { code, reason } => ( + "WS_DISCONNECTED", + "request", + NativeErrorDetails { + status: Some(*code), + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::WsAllEndpointsFailed { count } => ( + "WS_ALL_ENDPOINTS_FAILED", + "connect", + NativeErrorDetails { + attempts: u32::try_from(*count).ok(), + ..Default::default() + }, + ), + CatcherError::RetryExhausted { + attempts, + last_error, + } => ( + "RETRY_EXHAUSTED", + "request", + NativeErrorDetails { + attempts: Some(*attempts), + last_error: Some(Box::new(NativeErrorPayload::from(last_error.as_ref()))), + ..Default::default() + }, + ), + CatcherError::CircuitBreakerOpen => ( + "CIRCUIT_BREAKER_OPEN", + "request", + NativeErrorDetails::default(), + ), + CatcherError::QueueTimeout(timeout_ms) => ( + "QUEUE_TIMEOUT", + "queue", + NativeErrorDetails { + timeout_ms: Some(*timeout_ms), + ..Default::default() + }, + ), + CatcherError::EncodeError(reason) => ( + "ENCODE_ERROR", + "encode", + NativeErrorDetails { + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::DecodeError(reason) => ( + "DECODE_ERROR", + "decode", + NativeErrorDetails { + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::InvalidConfig(reason) => ( + "INVALID_CONFIG", + "config", + NativeErrorDetails { + reason: Some(reason.clone()), + ..Default::default() + }, + ), + CatcherError::SseTimeout(timeout_ms) => ( + "SSE_TIMEOUT", + "request", + NativeErrorDetails { + timeout_ms: Some(*timeout_ms), + ..Default::default() + }, + ), + CatcherError::Internal(reason) => ( + "INTERNAL_ERROR", + "internal", + NativeErrorDetails { + reason: Some(reason.clone()), + ..Default::default() + }, + ), + }; + + Self { + code, + phase, + retryable, + message: error.to_string(), + details, + } + } +} + +pub(crate) fn to_napi_error(error: CatcherError) -> napi::Error { + let payload = NativeErrorPayload::from(&error); + match serde_json::to_string(&payload) { + Ok(json) => napi::Error::from_reason(format!("{NATIVE_ERROR_PREFIX}{json}")), + Err(serialize_error) => napi::Error::from_reason(format!( + "failed to serialize catcher error: {serialize_error}; original error: {error}" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_exhausted_payload_preserves_attempts_and_last_error() { + let payload = NativeErrorPayload::from(&CatcherError::RetryExhausted { + attempts: 2, + last_error: Box::new(CatcherError::ConnectionError { + host: "api.example.com".into(), + reason: "socket closed".into(), + }), + }); + let value = serde_json::to_value(payload).unwrap(); + + assert_eq!(value["code"], "RETRY_EXHAUSTED"); + assert_eq!(value["phase"], "request"); + assert_eq!(value["details"]["attempts"], 2); + assert_eq!(value["details"]["lastError"]["code"], "CONNECTION_ERROR"); + assert_eq!(value["details"]["lastError"]["phase"], "connect"); + assert_eq!(value["details"]["lastError"]["retryable"], true); + assert_eq!( + value["details"]["lastError"]["details"]["reason"], + "socket closed" + ); + } + + #[test] + fn http_payload_preserves_status_and_body() { + let payload = NativeErrorPayload::from(&CatcherError::HttpError { + status: 421, + body: "misdirected".into(), + }); + let value = serde_json::to_value(payload).unwrap(); + + assert_eq!(value["code"], "HTTP_ERROR"); + assert_eq!(value["details"]["status"], 421); + assert_eq!(value["details"]["body"], "misdirected"); + } +} diff --git a/packages/catcher-napi-http/src/lib.rs b/packages/catcher-napi-http/src/lib.rs index 04da0d7..358cdf6 100644 --- a/packages/catcher-napi-http/src/lib.rs +++ b/packages/catcher-napi-http/src/lib.rs @@ -1,4 +1,5 @@ mod client; +mod error; mod helpers; mod sse; diff --git a/packages/catcher-napi-http/ts/client.ts b/packages/catcher-napi-http/ts/client.ts index 3bf057c..e1b6556 100644 --- a/packages/catcher-napi-http/ts/client.ts +++ b/packages/catcher-napi-http/ts/client.ts @@ -9,26 +9,144 @@ import { loadNativeAddon } from './native' const { JsHttpClient } = loadNativeAddon('catcher-napi-http') +const NATIVE_ERROR_PREFIX = 'CATCHER_ERROR:' const NATIVE_HTTP_ERROR_PATTERN = /^HTTP error: status (\d{3}), body: ([\s\S]*)$/ +export type CatcherErrorCode = + | 'CONNECTION_TIMEOUT' + | 'REQUEST_TIMEOUT' + | 'TLS_ERROR' + | 'DNS_ERROR' + | 'CONNECTION_ERROR' + | 'TRANSPORT_ERROR' + | 'HTTP_ERROR' + | 'WS_HANDSHAKE_TIMEOUT' + | 'WS_DISCONNECTED' + | 'WS_ALL_ENDPOINTS_FAILED' + | 'RETRY_EXHAUSTED' + | 'CIRCUIT_BREAKER_OPEN' + | 'QUEUE_TIMEOUT' + | 'ENCODE_ERROR' + | 'DECODE_ERROR' + | 'INVALID_CONFIG' + | 'SSE_TIMEOUT' + | 'INTERNAL_ERROR' + +export type CatcherErrorPhase = + | 'config' + | 'dns' + | 'connect' + | 'tls' + | 'queue' + | 'request' + | 'response' + | 'encode' + | 'decode' + | 'internal' + +export interface CatcherErrorSnapshot { + code: CatcherErrorCode + phase: CatcherErrorPhase + retryable: boolean + message: string + details: CatcherErrorDetails +} + +export interface CatcherErrorDetails { + status?: number + body?: string + timeoutMs?: number + host?: string + reason?: string + attempts?: number + lastError?: CatcherErrorSnapshot +} + +type NativeErrorPayload = CatcherErrorSnapshot + +/** Catcher 原生层的结构化错误。 */ +export class CatcherError extends Error { + readonly code: CatcherErrorCode + readonly phase: CatcherErrorPhase + readonly retryable: boolean + readonly details: CatcherErrorDetails + readonly cause: unknown + + constructor(payload: NativeErrorPayload, cause?: unknown) { + super(payload.message) + this.name = 'CatcherError' + this.code = payload.code + this.phase = payload.phase + this.retryable = payload.retryable + this.details = payload.details + this.cause = cause + } + + toJSON(): Record { + return { + name: this.name, + code: this.code, + phase: this.phase, + retryable: this.retryable, + message: this.message, + details: this.details, + } + } +} + /** Catcher HTTP 状态错误。 */ -export class HttpError extends Error { +export class HttpError extends CatcherError { readonly status: number readonly body: string - readonly cause: unknown constructor(status: number, body: string, cause?: unknown) { - super(`HTTP error: status ${status}, body: ${body}`) + super({ + code: 'HTTP_ERROR', + phase: 'response', + retryable: status >= 500, + message: `HTTP error: status ${status}, body: ${body}`, + details: { status, body }, + }, cause) this.name = 'HttpError' this.status = status this.body = body - this.cause = cause + } +} + +function parseNativeErrorPayload(message: string): NativeErrorPayload | undefined { + if (!message.startsWith(NATIVE_ERROR_PREFIX)) return undefined + try { + const payload = JSON.parse(message.slice(NATIVE_ERROR_PREFIX.length)) as NativeErrorPayload + if ( + typeof payload.code !== 'string' || + typeof payload.phase !== 'string' || + typeof payload.retryable !== 'boolean' || + typeof payload.message !== 'string' || + typeof payload.details !== 'object' || + payload.details === null + ) { + return undefined + } + return payload + } catch { + return undefined } } function normalizeNativeError(error: unknown): Error { - if (error instanceof HttpError) return error + if (error instanceof CatcherError) return error const message = error instanceof Error ? error.message : String(error) + const payload = parseNativeErrorPayload(message) + if (payload) { + if ( + payload.code === 'HTTP_ERROR' && + typeof payload.details.status === 'number' && + typeof payload.details.body === 'string' + ) { + return new HttpError(payload.details.status, payload.details.body, error) + } + return new CatcherError(payload, error) + } const match = NATIVE_HTTP_ERROR_PATTERN.exec(message) if (!match) return error instanceof Error ? error : new Error(message) return new HttpError(Number(match[1]), match[2], error) @@ -61,7 +179,11 @@ export class HttpClient { constructor(config: HttpClientConfig | string) { const json = typeof config === 'string' ? config : JSON.stringify(config) - this._raw = new JsHttpClient(json) + try { + this._raw = new JsHttpClient(json) + } catch (error) { + throw normalizeNativeError(error) + } } private async _execute(operation: () => Promise): Promise { @@ -138,7 +260,11 @@ export class HttpClient { if (!this._raw.networkChanged) { throw new Error('networkChanged() requires rebuilt native addon (cargo build)') } - this._raw.networkChanged() + try { + this._raw.networkChanged() + } catch (error) { + throw normalizeNativeError(error) + } } cancelAll(): void { diff --git a/packages/test/integration/napi-error-contract.test.ts b/packages/test/integration/napi-error-contract.test.ts new file mode 100644 index 0000000..02bfd7e --- /dev/null +++ b/packages/test/integration/napi-error-contract.test.ts @@ -0,0 +1,120 @@ +import http from 'node:http' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + CatcherError, + HttpClient, + type CatcherErrorSnapshot, +} from '@eric8810/catcher-napi-http' + +let timeoutServer: http.Server +let timeoutServerPort: number + +beforeAll(async () => { + timeoutServer = http.createServer(() => { + // Intentionally leave the response open until Catcher's request timeout fires. + }) + await new Promise((resolve) => { + timeoutServer.listen(0, '127.0.0.1', resolve) + }) + timeoutServerPort = (timeoutServer.address() as import('node:net').AddressInfo).port +}) + +afterAll(async () => { + timeoutServer.closeAllConnections() + await new Promise((resolve, reject) => { + timeoutServer.close((error) => error ? reject(error) : resolve()) + }) +}) + +describe('@eric8810/catcher-napi-http error contract', () => { + it('exposes invalid native configuration as a structured error', () => { + let error: unknown + try { + new HttpClient('{') + } catch (reason) { + error = reason + } + + expect(error).toBeInstanceOf(CatcherError) + expect(error).toMatchObject({ + name: 'CatcherError', + code: 'INVALID_CONFIG', + phase: 'config', + retryable: false, + }) + }) + + it('classifies a refused connection as retryable connection failure', async () => { + const client = new HttpClient({ + base_url: 'http://127.0.0.1:0', + connect_timeout_ms: 500, + response_timeout_ms: 500, + }) + + const error = await client.get('/unreachable').catch((reason) => reason) + + expect(error).toBeInstanceOf(CatcherError) + expect(error).toMatchObject({ + code: 'CONNECTION_ERROR', + phase: 'connect', + retryable: true, + }) + }) + + it('preserves retry attempts and the structured final cause', async () => { + const client = new HttpClient({ + base_url: 'http://127.0.0.1:0', + connect_timeout_ms: 500, + response_timeout_ms: 500, + retry: { + max_attempts: 1, + min_backoff_ms: 1, + max_backoff_ms: 1, + backoff: 'Fixed', + jitter: false, + }, + }) + + const error = await client + .get('/unreachable?access_token=must-not-leak') + .catch((reason) => reason) + const lastError = error.details.lastError as CatcherErrorSnapshot + + expect(error).toBeInstanceOf(CatcherError) + expect(error).toMatchObject({ + code: 'RETRY_EXHAUSTED', + phase: 'request', + retryable: false, + details: { attempts: 2 }, + }) + expect(lastError).toMatchObject({ + code: 'CONNECTION_ERROR', + phase: 'connect', + retryable: true, + }) + expect(lastError.details.reason).toEqual(expect.any(String)) + expect(lastError.details.reason).not.toBe('') + expect(lastError.details.reason).not.toContain('must-not-leak') + expect(error.message).not.toContain('Request failed after') + expect(JSON.stringify(error)).not.toContain('must-not-leak') + }) + + it('classifies a response timeout without parsing its message', async () => { + const client = new HttpClient({ + base_url: `http://127.0.0.1:${timeoutServerPort}`, + connect_timeout_ms: 500, + response_timeout_ms: 50, + }) + + const error = await client.get('/never-responds').catch((reason) => reason) + + expect(error).toBeInstanceOf(CatcherError) + expect(error).toMatchObject({ + code: 'REQUEST_TIMEOUT', + phase: 'request', + retryable: true, + details: { timeoutMs: 50 }, + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 75560fa..fa36342 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,10 @@ export default defineConfig({ 'packages/catcher-ws-ts/src/**/__tests__/**/*.test.ts', 'packages/catcher-web/src/**/__tests__/**/*.test.ts', ], - exclude: ['packages/test/integration/napi.test.ts'], + exclude: [ + 'packages/test/integration/napi.test.ts', + 'packages/test/integration/napi-error-contract.test.ts', + ], testTimeout: 30_000, hookTimeout: 15_000, reporters: ['verbose'],