diff --git a/.gitignore b/.gitignore index 219e51a..770d2a4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ __pycache__/ .venv/ venv/ env/ +.ruff_cache .pytest_cache/ .mypy_cache/ *.egg-info/ diff --git a/README.md b/README.md index a895ef0..ba2126b 100644 --- a/README.md +++ b/README.md @@ -214,11 +214,11 @@ pytest tests/ -v - [x] Exponential backoff with equal jitter - [x] Finite state machine circuit breaker - [x] Retry-After header support -- [ ] Request timeout & AbortController support -- [ ] Custom retry strategies +- [x] Request timeout & AbortController support +- [x] Custom retry strategies (`shouldRetry`) ### Observability -- [ ] Event & metric hooks +- [x] Event & metric hooks (`onRetry`, `onCircuitStateChange`, `onSuccess`, `onFailure`) - [ ] OpenTelemetry integration - [ ] Structured logging support diff --git a/packages/smooth-api-py/README.md b/packages/smooth-api-py/README.md index 18127bd..642f30c 100644 --- a/packages/smooth-api-py/README.md +++ b/packages/smooth-api-py/README.md @@ -226,11 +226,26 @@ config = SmoothConfig( SmoothAPI supports lifecycle event hooks to easily attach logging, metrics, alerting, or telemetry: +* **`on_success`**: Invoked on successful execution (HTTP status < 400). Receives a `SuccessContext` with `url`, `domain`, `attempts`, `duration_ms` (and `.duration` in seconds), `status`, and `response`. +* **`on_failure`**: Invoked on terminal failures (HTTP 4xx/5xx, exhausted retries, or when circuit is `OPEN`). Receives a `FailureContext` with `url`, `domain`, `attempts`, `duration_ms`, `error`, `status`, `response`, and `is_circuit_open`. * **`on_retry`**: Invoked before sleeping on a retryable failure. Receives a `RetryContext` with attempt number, maximum retries, calculated delay (`delay` in seconds, `delay_ms` in milliseconds), HTTP status code (if available), error exception (if available), url, and function domain. * **`on_circuit_state_change`**: Invoked whenever the circuit breaker transitions between states (`CLOSED`, `OPEN`, `HALF_OPEN`). Receives a `CircuitStateChangeEvent` with `domain`, `from_state`, `to_state`, and `failure_count`. ```python -from smooth_api import smooth_api, SmoothConfig, RetryContext, CircuitStateChangeEvent +from smooth_api import ( + smooth_api, + SmoothConfig, + RetryContext, + CircuitStateChangeEvent, + SuccessContext, + FailureContext, +) + +def log_success(ctx: SuccessContext): + print(f"[Success] {ctx.domain} completed in {ctx.duration_ms:.1f}ms ({ctx.attempts} attempts)") + +def log_failure(ctx: FailureContext): + print(f"[Failure] {ctx.domain} failed after {ctx.attempts} attempts. Circuit open: {ctx.is_circuit_open}") def log_retry(ctx: RetryContext): print(f"[Retry] Attempt {ctx.attempt}/{ctx.max_retries} for {ctx.domain} (status: {ctx.status}). Retrying in {ctx.delay_ms:.0f}ms") @@ -239,12 +254,14 @@ def log_circuit_transition(event: CircuitStateChangeEvent): print(f"[Circuit] {event.domain}: {event.from_state} -> {event.to_state} (failures: {event.failure_count})") config = SmoothConfig( + on_success=log_success, + on_failure=log_failure, on_retry=log_retry, on_circuit_state_change=log_circuit_transition, ) ``` -Both synchronous functions and `async def` coroutines are supported as hooks. All hooks execute with built-in fail-safe protection, ensuring that any exceptions raised inside user-defined callback functions never crash your request pipeline. +Both synchronous functions and `async def` coroutines are supported as hooks (supporting both `snake_case` and `camelCase` aliases). All hooks execute with built-in fail-safe protection, ensuring that any exceptions raised inside user-defined callback functions never crash your request pipeline. ## How It Works diff --git a/packages/smooth-api-py/smooth_api/__init__.py b/packages/smooth-api-py/smooth_api/__init__.py index d21cceb..d33cc86 100644 --- a/packages/smooth-api-py/smooth_api/__init__.py +++ b/packages/smooth-api-py/smooth_api/__init__.py @@ -3,14 +3,17 @@ import asyncio import functools import inspect +import time from typing import Any from .config import ( CircuitState, CircuitStateChangeEvent, DeduplicationConfig, + FailureContext, RetryContext, SmoothConfig, + SuccessContext, ) from .dedup import RequestDeduplicator from .state import CircuitBreakerState @@ -48,7 +51,7 @@ def _handle_task_done(t: asyncio.Task) -> None: sys.stderr.write(f"[smoothAPI] Error in lifecycle hook: {ex}\n") -def _extract_url(args: tuple, kwargs: dict) -> str: +def _extract_url(args: tuple, kwargs: dict, obj: Any = None) -> str: if "url" in kwargs and isinstance(kwargs["url"], str): return kwargs["url"] for arg in args: @@ -56,6 +59,13 @@ def _extract_url(args: tuple, kwargs: dict) -> str: return arg if args and isinstance(args[0], str): return args[0] + if obj is not None: + if hasattr(obj, "url") and getattr(obj, "url", None): + return str(obj.url) + if hasattr(obj, "response") and getattr(obj.response, "url", None): + return str(obj.response.url) + if hasattr(obj, "request") and getattr(obj.request, "url", None): + return str(obj.request.url) return "" @@ -126,10 +136,21 @@ def decorator(fn): if inspect.iscoroutinefunction(fn): @functools.wraps(fn) async def wrapper(*args, **kwargs): + start_time = time.perf_counter() + url = _extract_url(args, kwargs) # Runtime fallback overrides the config-level fallback. fallback = kwargs.pop('fallback', config.fallback) if not breaker.can_request(domain): + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=url, + domain=domain, + attempts=0, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + error=RuntimeError(f'Circuit breaker is OPEN for: {domain}'), + is_circuit_open=True, + )) if fallback is not None: return fallback raise RuntimeError(f'Circuit breaker is OPEN for: {domain}') @@ -139,25 +160,63 @@ async def wrapper(*args, **kwargs): # or attach to an already-running Future. async def _execute(): last_err: Exception | None = None + attempt_count = 0 for attempt in range(config.backoff.max_retries + 1): + attempt_count = attempt + 1 try: if config.timeout_ms is not None: result = await asyncio.wait_for(fn(*args, **kwargs), timeout=config.timeout_ms / 1000.0) else: result = await fn(*args, **kwargs) + + status = getattr(result, "status_code", None) + res_url = url or _extract_url(args, kwargs, result) + if status is not None and status >= 400: + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=res_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=result, + error=RuntimeError(f"HTTP {status}"), + is_circuit_open=False, + )) + else: + if config.on_success: + _safe_invoke(config.on_success, SuccessContext( + url=res_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=result, + )) + breaker.record_success(domain) return result - except asyncio.CancelledError: + except asyncio.CancelledError as err: # CancelledError is a BaseException (Python 3.8+) and # escapes `except Exception`. We still need to record # the failure so sustained cancellation (e.g. client # timeouts) is counted toward tripping the circuit, # then re-raise so the cancellation propagates normally. breaker.record_failure(domain) + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=url or _extract_url(args, kwargs, err), + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + error=err, + is_circuit_open=False, + )) raise except Exception as err: status = _get_status_code(err) + err_url = url or _extract_url(args, kwargs, err) # Non-retryable HTTP errors (e.g. 400, 401, 404) bubble up immediately. if status is not None and status not in config.retry_on: if config.fallback_on_non_retryable: @@ -170,6 +229,19 @@ async def _execute(): sys.stderr.write(f"{message}\n") breaker.record_success(domain) + + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=err_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=getattr(err, 'response', None), + error=err, + is_circuit_open=False, + )) + if fallback is not None: return fallback return MockResponse( @@ -177,6 +249,17 @@ async def _execute(): content={"error": True, "status": status, "message": message}, reason=reason ) + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=err_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=getattr(err, 'response', None), + error=err, + is_circuit_open=False, + )) raise breaker.record_failure(domain) last_err = err @@ -193,7 +276,7 @@ async def _execute(): delay_ms=delay * 1000.0, status=status, error=err, - url=_extract_url(args, kwargs), + url=err_url, domain=domain, ) _safe_invoke(config.on_retry, ctx) @@ -202,8 +285,29 @@ async def _execute(): # If retries are exhausted and it's an HTTP error, return the response instead of raising if status is not None and hasattr(err, 'response'): + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=err_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=err.response, + error=err, + is_circuit_open=False, + )) return err.response + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=url or _extract_url(args, kwargs, last_err), + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + error=last_err, + status=_get_status_code(last_err) if last_err else None, + is_circuit_open=False, + )) raise last_err # type: ignore[misc] if deduplicator is not None: @@ -224,22 +328,61 @@ async def _execute(): @functools.wraps(fn) def wrapper(*args, **kwargs): # type: ignore[misc] + start_time = time.perf_counter() + url = _extract_url(args, kwargs) fallback = kwargs.pop('fallback', config.fallback) if not breaker.can_request(domain): + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=url, + domain=domain, + attempts=0, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + error=RuntimeError(f'Circuit breaker is OPEN for: {domain}'), + is_circuit_open=True, + )) if fallback is not None: return fallback raise RuntimeError(f'Circuit breaker is OPEN for: {domain}') last_err: Exception | None = None + attempt_count = 0 for attempt in range(config.backoff.max_retries + 1): + attempt_count = attempt + 1 try: result = fn(*args, **kwargs) + status = getattr(result, "status_code", None) + res_url = url or _extract_url(args, kwargs, result) + if status is not None and status >= 400: + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=res_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=result, + error=RuntimeError(f"HTTP {status}"), + is_circuit_open=False, + )) + else: + if config.on_success: + _safe_invoke(config.on_success, SuccessContext( + url=res_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=result, + )) + breaker.record_success(domain) return result except Exception as err: status = _get_status_code(err) + err_url = url or _extract_url(args, kwargs, err) if status is not None and status not in config.retry_on: if config.fallback_on_non_retryable: import sys @@ -251,6 +394,19 @@ def wrapper(*args, **kwargs): # type: ignore[misc] sys.stderr.write(f"{message}\n") breaker.record_success(domain) + + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=err_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=getattr(err, 'response', None), + error=err, + is_circuit_open=False, + )) + if fallback is not None: return fallback return MockResponse( @@ -258,6 +414,17 @@ def wrapper(*args, **kwargs): # type: ignore[misc] content={"error": True, "status": status, "message": message}, reason=reason ) + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=err_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=getattr(err, 'response', None), + error=err, + is_circuit_open=False, + )) raise breaker.record_failure(domain) last_err = err @@ -274,7 +441,7 @@ def wrapper(*args, **kwargs): # type: ignore[misc] delay_ms=delay * 1000.0, status=status, error=err, - url=_extract_url(args, kwargs), + url=err_url, domain=domain, ) _safe_invoke(config.on_retry, ctx) @@ -282,8 +449,29 @@ def wrapper(*args, **kwargs): # type: ignore[misc] continue if status is not None and hasattr(err, 'response'): + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=err_url, + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + status=status, + response=err.response, + error=err, + is_circuit_open=False, + )) return err.response + if config.on_failure: + _safe_invoke(config.on_failure, FailureContext( + url=url or _extract_url(args, kwargs, last_err), + domain=domain, + attempts=attempt_count, + duration_ms=(time.perf_counter() - start_time) * 1000.0, + error=last_err, + status=_get_status_code(last_err) if last_err else None, + is_circuit_open=False, + )) raise last_err # type: ignore[misc] return wrapper @@ -298,6 +486,8 @@ def wrapper(*args, **kwargs): # type: ignore[misc] 'RetryContext', 'CircuitStateChangeEvent', 'CircuitState', + 'SuccessContext', + 'FailureContext', 'resilient_api', 'ResilientConfig', ] diff --git a/packages/smooth-api-py/smooth_api/config.py b/packages/smooth-api-py/smooth_api/config.py index a1d5b0f..2617907 100644 --- a/packages/smooth-api-py/smooth_api/config.py +++ b/packages/smooth-api-py/smooth_api/config.py @@ -80,6 +80,42 @@ class DeduplicationConfig: key_fn: Optional[KeyFn] = None +@dataclass +class SuccessContext: + url: str + domain: str + attempts: int + duration_ms: float + status: Optional[int] = None + response: Optional[Any] = None + + @property + def duration(self) -> float: + """Duration in seconds.""" + return self.duration_ms / 1000.0 + + +@dataclass +class FailureContext: + url: str + domain: str + attempts: int + duration_ms: float + error: Optional[Any] = None + status: Optional[int] = None + response: Optional[Any] = None + is_circuit_open: bool = False + + @property + def duration(self) -> float: + """Duration in seconds.""" + return self.duration_ms / 1000.0 + + @property + def isCircuitOpen(self) -> bool: + return self.is_circuit_open + + @dataclass class SmoothConfig: backoff: BackoffConfig = field(default_factory=BackoffConfig) @@ -97,11 +133,19 @@ class SmoothConfig: # Lifecycle event hooks on_retry: Optional[Callable[[RetryContext], Any]] = None on_circuit_state_change: Optional[Callable[[CircuitStateChangeEvent], Any]] = None + on_success: Optional[Callable[[SuccessContext], Any]] = None + on_failure: Optional[Callable[[FailureContext], Any]] = None onRetry: Optional[Callable[[RetryContext], Any]] = None onCircuitStateChange: Optional[Callable[[CircuitStateChangeEvent], Any]] = None + onSuccess: Optional[Callable[[SuccessContext], Any]] = None + onFailure: Optional[Callable[[FailureContext], Any]] = None def __post_init__(self): if self.on_retry is None and self.onRetry is not None: self.on_retry = self.onRetry if self.on_circuit_state_change is None and self.onCircuitStateChange is not None: self.on_circuit_state_change = self.onCircuitStateChange + if self.on_success is None and self.onSuccess is not None: + self.on_success = self.onSuccess + if self.on_failure is None and self.onFailure is not None: + self.on_failure = self.onFailure diff --git a/packages/smooth-api-py/tests/test_hooks.py b/packages/smooth-api-py/tests/test_hooks.py index c3a87ad..1a2d4ab 100644 --- a/packages/smooth-api-py/tests/test_hooks.py +++ b/packages/smooth-api-py/tests/test_hooks.py @@ -265,3 +265,182 @@ def fetch(self, url: str): service.fetch(f"{BASE}/always-fail") assert len(calls) == 1 assert calls[0].url == f"{BASE}/always-fail" + + +def test_on_success_sync_clean_200(): + reset() + success_calls = [] + failure_calls = [] + + config = SmoothConfig( + on_success=success_calls.append, + on_failure=failure_calls.append, + ) + + @smooth_api(config) + def call_health(): + return requests.get(f"{BASE}/health") + + resp = call_health() + assert resp.status_code == 200 + assert len(success_calls) == 1 + assert len(failure_calls) == 0 + + ctx = success_calls[0] + assert ctx.status == 200 + assert ctx.attempts == 1 + assert ctx.duration_ms >= 0 + assert ctx.duration >= 0 + assert "health" in ctx.url + + +@pytest.mark.asyncio +async def test_on_success_async_after_retries(): + reset() + success_calls = [] + retry_calls = [] + + config = SmoothConfig( + backoff=BackoffConfig(max_retries=3, base_delay=0.01, max_delay=0.05), + on_retry=retry_calls.append, + on_success=success_calls.append, + ) + + # First 2 calls hit 200, 3rd hits 500 and retries to 4th (200) + requests.get(f"{BASE}/unstable-data") + requests.get(f"{BASE}/unstable-data") + + @smooth_api(config) + async def call_unstable(): + resp = requests.get(f"{BASE}/unstable-data") + resp.raise_for_status() + return resp + + resp = await call_unstable() + assert resp.status_code == 200 + assert len(retry_calls) == 1 + assert len(success_calls) == 1 + assert success_calls[0].attempts == 2 + + +def test_on_failure_500_when_retries_exhausted(): + reset() + success_calls = [] + failure_calls = [] + + config = SmoothConfig( + backoff=BackoffConfig(max_retries=2, base_delay=0.01, max_delay=0.05), + on_success=success_calls.append, + on_failure=failure_calls.append, + ) + + @smooth_api(config) + def call_fail(): + resp = requests.get(f"{BASE}/always-fail") + resp.raise_for_status() + return resp + + resp = call_fail() + assert resp.status_code == 500 + assert len(success_calls) == 0, "on_success must NOT fire for 500" + assert len(failure_calls) == 1, "on_failure must fire for 500" + + ctx = failure_calls[0] + assert ctx.status == 500 + assert ctx.attempts == 3 # attempt 0, 1, 2 + assert ctx.is_circuit_open is False + assert ctx.duration_ms >= 0 + + +def test_on_failure_circuit_open(): + reset() + failure_calls = [] + + config = SmoothConfig( + backoff=BackoffConfig(max_retries=0, base_delay=0.01), + circuit_breaker=CircuitBreakerConfig(failure_threshold=1, cooldown_ms=5000), + on_failure=failure_calls.append, + ) + + @smooth_api(config) + def call_fail(): + resp = requests.get(f"{BASE}/always-fail") + resp.raise_for_status() + return resp + + # 1st call trips breaker + call_fail() + assert len(failure_calls) == 1 + + # 2nd call blocked by OPEN circuit + with pytest.raises(RuntimeError, match="Circuit breaker is OPEN"): + call_fail() + + assert len(failure_calls) == 2 + second_ctx = failure_calls[1] + assert second_ctx.is_circuit_open is True + assert second_ctx.isCircuitOpen is True + assert second_ctx.attempts == 0 + + +def test_on_success_and_failure_exceptions_safe(): + reset() + + def buggy_success(ctx): + raise ValueError("Bug in on_success") + + def buggy_failure(ctx): + raise ValueError("Bug in on_failure") + + config = SmoothConfig( + backoff=BackoffConfig(max_retries=0, base_delay=0.01), + on_success=buggy_success, + on_failure=buggy_failure, + ) + + @smooth_api(config) + def call_health(): + return requests.get(f"{BASE}/health") + + @smooth_api(config) + def call_fail(): + resp = requests.get(f"{BASE}/always-fail") + resp.raise_for_status() + return resp + + # Should not crash on success hook bug + res = call_health() + assert res.status_code == 200 + + # Should not crash on failure hook bug + fail_res = call_fail() + assert fail_res.status_code == 500 + + +def test_camel_case_hook_aliases(): + reset() + success_calls = [] + failure_calls = [] + + config = SmoothConfig( + backoff=BackoffConfig(max_retries=0, base_delay=0.01), + onSuccess=success_calls.append, + onFailure=failure_calls.append, + ) + + @smooth_api(config) + def call_health(): + return requests.get(f"{BASE}/health") + + @smooth_api(config) + def call_fail(): + resp = requests.get(f"{BASE}/always-fail") + resp.raise_for_status() + return resp + + call_health() + assert len(success_calls) == 1 + + call_fail() + assert len(failure_calls) == 1 + diff --git a/packages/smooth-api-ts/README.md b/packages/smooth-api-ts/README.md index 57b64e8..2775220 100644 --- a/packages/smooth-api-ts/README.md +++ b/packages/smooth-api-ts/README.md @@ -226,6 +226,14 @@ You can pass callback functions to listen to key events during the request lifec import { createSmoothFetch } from '@codingaryan/smoothapi'; const fetchWithRetry = createSmoothFetch({ + onSuccess: (context) => { + // Fired on successful completion (HTTP 2xx) + console.log(`[smoothAPI] ${context.url} succeeded in ${context.durationMs}ms after ${context.attempts} attempt(s)`); + }, + onFailure: (context) => { + // Fired on terminal error, exhausted retries, 4xx/5xx, or OPEN circuit + console.error(`[smoothAPI] ${context.url} failed after ${context.attempts} attempt(s). Circuit open: ${context.isCircuitOpen}`); + }, onRetry: (context) => { // Fired before a request is retried console.warn(`[Attempt ${context.attempt}/${context.maxRetries}] Retrying ${context.domain} in ${context.delayMs}ms. Status: ${context.status}`); @@ -237,8 +245,10 @@ const fetchWithRetry = createSmoothFetch({ }); ``` -- **`onRetry(context)`**: Receives `{ attempt, maxRetries, delayMs, status?, error?, url, domain }`. It fires immediately before the delay sleep begins. -- **`onCircuitStateChange(event)`**: Receives `{ domain, from, to, failureCount }`. It fires exactly when the state transitions (`CLOSED` → `OPEN` → `HALF_OPEN` → `CLOSED`). +- **`onSuccess(context)`**: Receives `{ url, domain, attempts, durationMs, status, response }`. Fires on HTTP 2xx completions. +- **`onFailure(context)`**: Receives `{ url, domain, attempts, durationMs, error?, status?, response?, isCircuitOpen }`. Fires on terminal failures (5xx/4xx, exhausted retries, or `CircuitOpenError`). +- **`onRetry(context)`**: Receives `{ attempt, maxRetries, delayMs, status?, error?, url, domain }`. Fires immediately before the delay sleep begins. +- **`onCircuitStateChange(event)`**: Receives `{ domain, from, to, failureCount }`. Fires exactly when the state transitions (`CLOSED` → `OPEN` → `HALF_OPEN` → `CLOSED`). - **Fail-Safe**: If your hook throws an exception, SmoothAPI catches and logs it internally, ensuring it never crashes your request pipeline. ### AbortController Support diff --git a/packages/smooth-api-ts/src/index.ts b/packages/smooth-api-ts/src/index.ts index fe5d07d..3f3b743 100644 --- a/packages/smooth-api-ts/src/index.ts +++ b/packages/smooth-api-ts/src/index.ts @@ -69,11 +69,23 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { url: string | URL, options?: RequestInit ): Promise { + const startTime = Date.now(); + const urlStr = url.toString(); // Fallback to local origin to support relative paths - const domain = new URL(url.toString(), typeof window !== 'undefined' && window.location ? window.location.origin : 'http://localhost').hostname; + const domain = new URL(urlStr, typeof window !== 'undefined' && window.location ? window.location.origin : 'http://localhost').hostname; // Block before any network IO if the circuit is OPEN. if (!breaker.canRequest(domain)) { + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: 0, + durationMs: Date.now() - startTime, + error: new CircuitOpenError(domain), + isCircuitOpen: true, + }); + } if (globalConfig.fallback !== undefined) { return globalConfig.fallback as T; } @@ -87,11 +99,23 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { const run = async (): Promise => { let prevDelay: number | undefined; + let attemptCount = 0; for (let attempt = 0; attempt <= backoffConfig.maxRetries; attempt++) { + attemptCount = attempt + 1; // Pre-flight abort check if (options?.signal?.aborted) { - // Match native fetch behavior for unhandled aborts - throw options.signal.reason || new DOMException("The operation was aborted.", "AbortError"); + const abortErr = options.signal.reason || new DOMException("The operation was aborted.", "AbortError"); + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount - 1, + durationMs: Date.now() - startTime, + error: abortErr, + isCircuitOpen: false, + }); + } + throw abortErr; } let timeoutId: ReturnType | undefined; @@ -132,16 +156,16 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { if (attempt < backoffConfig.maxRetries) { let delayMs = calculateBackoff(attempt, backoffConfig, prevDelay); - if (response.status === 429) { - const retryAfter = response.headers.get('Retry-After'); - if (retryAfter) { - const parsed = parseInt(retryAfter, 10); - if (!Number.isNaN(parsed) && parsed > 0) { - delayMs = parsed * 1000; - } - } - } - prevDelay = delayMs; + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + if (retryAfter) { + const parsed = parseInt(retryAfter, 10); + if (!Number.isNaN(parsed) && parsed > 0) { + delayMs = parsed * 1000; + } + } + } + prevDelay = delayMs; if (globalConfig.onRetry) { safeInvoke(globalConfig.onRetry, { @@ -149,7 +173,7 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { maxRetries: backoffConfig.maxRetries, delayMs, status: response.status, - url: url.toString(), + url: urlStr, domain, }); } @@ -157,6 +181,19 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { await sleep(delayMs, options?.signal); continue; } + + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + status: response.status, + response, + error: new Error(`HTTP ${response.status}: Retries exhausted`), + isCircuitOpen: false, + }); + } return response; } @@ -170,6 +207,19 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { breaker.recordSuccess(domain); + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + status: response.status, + response, + error: new Error(message), + isCircuitOpen: false, + }); + } + if (globalConfig.fallback !== undefined) { return globalConfig.fallback as T; } @@ -189,6 +239,33 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { } breaker.recordSuccess(domain); + + if (response.status >= 400) { + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + status: response.status, + response, + error: new Error(`HTTP ${response.status}${response.statusText ? ' ' + response.statusText : ''}`), + isCircuitOpen: false, + }); + } + } else { + if (globalConfig.onSuccess) { + safeInvoke(globalConfig.onSuccess, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + status: response.status, + response, + }); + } + } + return response; } catch (err: any) { lastError = err; @@ -196,11 +273,31 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { // (We know it's our timeout if controller is aborted and the user's signal isn't). const isInternalTimeout = controller?.signal?.aborted && !options?.signal?.aborted; if (err?.name === 'AbortError' && !isInternalTimeout) { - throw err; + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + error: err, + isCircuitOpen: false, + }); + } + throw err; } // Do not retry or record failure if the user explicitly aborted the request if (options?.signal?.aborted) { + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + error: err, + isCircuitOpen: false, + }); + } throw err; } @@ -208,6 +305,16 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { const should = await evaluateShouldRetry(globalConfig.shouldRetry, undefined, err, true); if (!should) { breaker.recordFailure(domain); + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + error: err, + isCircuitOpen: false, + }); + } throw err; } } @@ -224,7 +331,7 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { maxRetries: backoffConfig.maxRetries, delayMs, error: err instanceof Error ? err : new Error(String(err)), - url: url.toString(), + url: urlStr, domain, }); } @@ -238,6 +345,16 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { } } + if (globalConfig.onFailure) { + safeInvoke(globalConfig.onFailure, { + url: urlStr, + domain, + attempts: attemptCount, + durationMs: Date.now() - startTime, + error: lastError, + isCircuitOpen: false, + }); + } throw lastError; }; diff --git a/packages/smooth-api-ts/src/types.ts b/packages/smooth-api-ts/src/types.ts index a4d491a..c6e4494 100644 --- a/packages/smooth-api-ts/src/types.ts +++ b/packages/smooth-api-ts/src/types.ts @@ -65,6 +65,29 @@ export type ShouldRetryPredicate = ( error?: unknown ) => boolean | Promise; +export interface SuccessContext { + url: string; + domain: string; + attempts: number; + durationMs: number; + status: number; + response: Response; +} + +export interface FailureContext { + url: string; + domain: string; + attempts: number; + durationMs: number; + error?: unknown; + status?: number; + response?: Response; + isCircuitOpen: boolean; +} + +export type RequestSuccessContext = SuccessContext; +export type RequestFailureContext = FailureContext; + // T types the fallback payload so callers get inference at the use site. export interface SmoothFetchConfig { backoff?: Partial; @@ -92,6 +115,8 @@ export interface SmoothFetchConfig { timeoutMs?: number; onRetry?: (context: RetryContext) => void | Promise; onCircuitStateChange?: (event: CircuitStateChangeEvent) => void | Promise; + onSuccess?: (context: SuccessContext) => void | Promise; + onFailure?: (context: FailureContext) => void | Promise; } /** @deprecated use SmoothFetchConfig instead */ diff --git a/packages/smooth-api-ts/tests/hooks.test.ts b/packages/smooth-api-ts/tests/hooks.test.ts index 27be0f9..04934c1 100644 --- a/packages/smooth-api-ts/tests/hooks.test.ts +++ b/packages/smooth-api-ts/tests/hooks.test.ts @@ -123,4 +123,128 @@ describe('Lifecycle Event Hooks', () => { assert.ok(retryCalled, 'onRetry should have been invoked'); assert.ok(stateChangeCalled, 'onCircuitStateChange should have been invoked'); }); + + it('onSuccess fires on clean 200 response with correct context', async () => { + await reset(); + const mockSuccess = mock.fn(); + const mockFailure = mock.fn(); + + const fetch = createSmoothFetch({ + onSuccess: mockSuccess, + onFailure: mockFailure, + }); + + const res = await fetch(`${BASE}/health`) as Response; + assert.strictEqual(res.status, 200); + assert.strictEqual(mockSuccess.mock.calls.length, 1); + assert.strictEqual(mockFailure.mock.calls.length, 0); + + const ctx = mockSuccess.mock.calls[0].arguments[0]; + assert.strictEqual(ctx.status, 200); + assert.strictEqual(ctx.attempts, 1); + assert.ok(ctx.durationMs >= 0); + assert.strictEqual(ctx.domain, 'localhost'); + assert.ok(ctx.url.includes('/health')); + }); + + it('onSuccess fires after retries succeed', async () => { + await reset(); + const mockSuccess = mock.fn(); + const mockRetry = mock.fn(); + + const fetch = createSmoothFetch({ + backoff: { maxRetries: 3, baseDelay: 10, maxDelay: 50 }, + onRetry: mockRetry, + onSuccess: mockSuccess, + }); + + // /unstable-data: request 1 (200), request 2 (200), request 3 (500), request 4 (200) + // Make 2 calls first so the next call hits 500 and retries + await fetch(`${BASE}/unstable-data`); // req 1 -> 200 + await fetch(`${BASE}/unstable-data`); // req 2 -> 200 + + mockSuccess.mock.resetCalls(); + // req 3 is 500, retries to req 4 which is 200! + const res = await fetch(`${BASE}/unstable-data`) as Response; + assert.strictEqual(res.status, 200); + assert.strictEqual(mockRetry.mock.calls.length, 1); + assert.strictEqual(mockSuccess.mock.calls.length, 1); + + const ctx = mockSuccess.mock.calls[0].arguments[0]; + assert.strictEqual(ctx.status, 200); + assert.strictEqual(ctx.attempts, 2); // 1 initial + 1 retry + }); + + it('onFailure fires when retries are exhausted on 500 (500 should be failure)', async () => { + await reset(); + const mockSuccess = mock.fn(); + const mockFailure = mock.fn(); + + const fetch = createSmoothFetch({ + backoff: { maxRetries: 2, baseDelay: 10, maxDelay: 50 }, + onSuccess: mockSuccess, + onFailure: mockFailure, + }); + + const res = await fetch(`${BASE}/always-fail`) as Response; + assert.strictEqual(res.status, 500); + assert.strictEqual(mockSuccess.mock.calls.length, 0, 'onSuccess must NOT fire for 500'); + assert.strictEqual(mockFailure.mock.calls.length, 1, 'onFailure must fire for 500'); + + const ctx = mockFailure.mock.calls[0].arguments[0]; + assert.strictEqual(ctx.status, 500); + assert.strictEqual(ctx.attempts, 3); // attempt 0, 1, 2 + assert.strictEqual(ctx.isCircuitOpen, false); + assert.ok(ctx.durationMs >= 0); + }); + + it('onFailure fires on CircuitOpenError without network IO', async () => { + await reset(); + const mockFailure = mock.fn(); + + const fetch = createSmoothFetch({ + backoff: { maxRetries: 0, baseDelay: 10 }, + circuitBreaker: { failureThreshold: 1, cooldownMs: 1000 }, + onFailure: mockFailure, + }); + + // Trip circuit + await fetch(`${BASE}/always-fail`); + assert.strictEqual(mockFailure.mock.calls.length, 1); + + // Call again while circuit is OPEN + mockFailure.mock.resetCalls(); + try { + await fetch(`${BASE}/always-fail`); + assert.fail('Should throw CircuitOpenError'); + } catch (e: any) { + assert.strictEqual(e.name, 'CircuitOpenError'); + } + + assert.strictEqual(mockFailure.mock.calls.length, 1); + const ctx = mockFailure.mock.calls[0].arguments[0]; + assert.strictEqual(ctx.isCircuitOpen, true); + assert.strictEqual(ctx.attempts, 0); + }); + + it('exceptions in onSuccess and onFailure do not crash the request pipeline', async () => { + await reset(); + const fetch = createSmoothFetch({ + onSuccess: () => { + throw new Error('User bug in onSuccess'); + }, + onFailure: () => { + throw new Error('User bug in onFailure'); + }, + }); + + // Should succeed despite error in onSuccess + const res = await fetch(`${BASE}/health`) as Response; + assert.strictEqual(res.status, 200); + + // Should return 500 despite error in onFailure + const failRes = await fetch(`${BASE}/always-fail`) as Response; + assert.strictEqual(failRes.status, 500); + }); }); +