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
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 25 additions & 3 deletions packages/catcher-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },

Expand All @@ -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<CatcherError>,
},

#[error("circuit breaker is OPEN, request rejected")]
CircuitBreakerOpen,
Expand Down Expand Up @@ -74,6 +86,8 @@ impl CatcherError {
| CatcherError::RequestTimeout(_)
| CatcherError::TlsError(_)
| CatcherError::DnsError { .. }
| CatcherError::ConnectionError { .. }
| CatcherError::TransportError(_)
| CatcherError::WsHandshakeTimeout(_)
| CatcherError::WsDisconnected { .. }
| CatcherError::WsAllEndpointsFailed { .. }
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(),
Expand All @@ -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),
Expand Down
45 changes: 42 additions & 3 deletions packages/catcher-http/src/resilience/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, F, Fut>(
config: &RetryConfig,
mut operation: F,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<u32, _>(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);
Expand Down
110 changes: 105 additions & 5 deletions packages/catcher-http/src/transport/http_client.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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::<RetryError>() {
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)
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions packages/catcher-napi-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
17 changes: 6 additions & 11 deletions packages/catcher-napi-http/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down Expand Up @@ -91,9 +93,8 @@ impl JsHttpClient {
#[napi(constructor)]
pub fn new(config_json: String) -> napi::Result<Self> {
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),
})
Expand Down Expand Up @@ -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 ──
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading