From 73546fe77c038e35a5296f008c9def273b8965e1 Mon Sep 17 00:00:00 2001 From: Aryan Sharma Date: Sat, 4 Jul 2026 03:29:34 +0530 Subject: [PATCH 1/2] feat: implement request timeouts and rename resilient to smooth --- packages/smooth-api-py/smooth_api/__init__.py | 12 +++- packages/smooth-api-py/smooth_api/config.py | 2 + packages/smooth-api-py/tests/test_timeout.py | 33 ++++++++++ packages/smooth-api-ts/src/dedup.ts | 4 +- packages/smooth-api-ts/src/index.ts | 30 ++++++++- packages/smooth-api-ts/src/types.ts | 9 ++- .../smooth-api-ts/tests/deduplication.test.ts | 48 +++++++------- .../smooth-api-ts/tests/resilience.test.ts | 42 ++++++------ packages/smooth-api-ts/tests/timeout.test.ts | 66 +++++++++++++++++++ 9 files changed, 191 insertions(+), 55 deletions(-) create mode 100644 packages/smooth-api-py/tests/test_timeout.py create mode 100644 packages/smooth-api-ts/tests/timeout.test.ts diff --git a/packages/smooth-api-py/smooth_api/__init__.py b/packages/smooth-api-py/smooth_api/__init__.py index 67e5954..a93ec98 100644 --- a/packages/smooth-api-py/smooth_api/__init__.py +++ b/packages/smooth-api-py/smooth_api/__init__.py @@ -86,7 +86,10 @@ async def _execute(): for attempt in range(config.backoff.max_retries + 1): try: - result = await fn(*args, **kwargs) + if config.timeout_ms: + result = await asyncio.wait_for(fn(*args, **kwargs), timeout=config.timeout_ms / 1000.0) + else: + result = await fn(*args, **kwargs) breaker.record_success(domain) return result except asyncio.CancelledError: @@ -143,6 +146,9 @@ async def _execute(): import warnings # Sync deduplication requires a thread-safe lock manager, which is currently unsupported warnings.warn("Synchronous deduplication is not supported. Deduplication will be ignored for this function.", UserWarning, stacklevel=2) + + if config.timeout_ms is not None: + raise NotImplementedError("timeout_ms is not supported for synchronous decorators. Please use your HTTP client's native timeout support.") @functools.wraps(fn) def wrapper(*args, **kwargs): # type: ignore[misc] @@ -197,6 +203,8 @@ def wrapper(*args, **kwargs): # type: ignore[misc] return decorator +__all__ = ['smooth_api', 'SmoothConfig', 'DeduplicationConfig', 'resilient_api', 'ResilientConfig'] + import warnings def resilient_api(*args, **kwargs): @@ -208,6 +216,4 @@ def __init__(self, *args, **kwargs): warnings.warn("'ResilientConfig' is deprecated, use 'SmoothConfig' instead", DeprecationWarning, stacklevel=2) super().__init__(*args, **kwargs) -__all__ = ['smooth_api', 'SmoothConfig', 'DeduplicationConfig', 'resilient_api', 'ResilientConfig'] - from .config import DeduplicationConfig # re-export for convenience \ No newline at end of file diff --git a/packages/smooth-api-py/smooth_api/config.py b/packages/smooth-api-py/smooth_api/config.py index c488f5b..29f13d0 100644 --- a/packages/smooth-api-py/smooth_api/config.py +++ b/packages/smooth-api-py/smooth_api/config.py @@ -54,3 +54,5 @@ class SmoothConfig: on_non_retryable_error: Callable[[int, str], None] | None = None # When set, enables request deduplication for async-decorated functions. deduplication: Optional[DeduplicationConfig] = None + # Maximum duration in milliseconds before a request attempt is aborted. + timeout_ms: Optional[int] = None diff --git a/packages/smooth-api-py/tests/test_timeout.py b/packages/smooth-api-py/tests/test_timeout.py new file mode 100644 index 0000000..372957a --- /dev/null +++ b/packages/smooth-api-py/tests/test_timeout.py @@ -0,0 +1,33 @@ +import asyncio +import pytest +from smooth_api import smooth_api, SmoothConfig +from smooth_api.config import BackoffConfig + +@pytest.mark.asyncio +async def test_async_timeout_aborts_and_retries(): + call_count = 0 + + @smooth_api(SmoothConfig( + timeout_ms=100, + backoff=BackoffConfig(max_retries=1, base_delay=0.01) + )) + async def fetch_data(): + nonlocal call_count + call_count += 1 + if call_count == 1: + await asyncio.sleep(0.5) # Should timeout + return "delayed" + return "success" + + result = await fetch_data() + assert result == "success" + assert call_count == 2 + +def test_sync_timeout_raises_not_implemented(): + with pytest.raises(NotImplementedError, match="timeout_ms is not supported for synchronous decorators"): + @smooth_api(SmoothConfig( + timeout_ms=100, + backoff=BackoffConfig(max_retries=1, base_delay=0.01) + )) + def fetch_data_sync(): + return "success" diff --git a/packages/smooth-api-ts/src/dedup.ts b/packages/smooth-api-ts/src/dedup.ts index d20215f..127b7d0 100644 --- a/packages/smooth-api-ts/src/dedup.ts +++ b/packages/smooth-api-ts/src/dedup.ts @@ -61,8 +61,8 @@ export class RequestDeduplicator { * otherwise attach to the existing Promise. In either case the caller * receives `Response.clone()` so body streams are independent. * - * @param url - Same value passed to the outer resilientFetch. - * @param options - Same value passed to the outer resilientFetch. + * @param url - Same value passed to the outer smoothFetch. + * @param options - Same value passed to the outer smoothFetch. * @param fetcher - A thunk that performs the actual network call. */ execute( diff --git a/packages/smooth-api-ts/src/index.ts b/packages/smooth-api-ts/src/index.ts index 64bb4e0..7f3ae57 100644 --- a/packages/smooth-api-ts/src/index.ts +++ b/packages/smooth-api-ts/src/index.ts @@ -1,6 +1,6 @@ import { CircuitBreakerState } from "./state.js"; import { calculateBackoff, sleep } from "./utils/backoff.js"; -import { CircuitOpenError, ResilientFetchConfig } from "./types.js"; +import { CircuitOpenError, SmoothFetchConfig } from "./types.js"; import { RequestDeduplicator } from "./dedup.js"; const BACKOFF_DEFAULTS = { @@ -11,7 +11,7 @@ const BACKOFF_DEFAULTS = { const DEFAULT_RETRY_ON = [429, 500, 502, 503, 504]; -export function createSmoothFetch(globalConfig: ResilientFetchConfig) { +export function createSmoothFetch(globalConfig: SmoothFetchConfig) { const backoffConfig = { ...BACKOFF_DEFAULTS, ...globalConfig.backoff }; const retryOn = globalConfig.retryOn ?? DEFAULT_RETRY_ON; const breaker = new CircuitBreakerState(globalConfig.circuitBreaker); @@ -41,8 +41,25 @@ export function createSmoothFetch(globalConfig: ResilientFetchConfig) { const run = async (): Promise => { for (let attempt = 0; attempt <= backoffConfig.maxRetries; attempt++) { + let timeoutId: ReturnType | undefined; + let currentOptions = options; + let controller: AbortController | undefined; + + if (globalConfig.timeoutMs) { + controller = new AbortController(); + if (options?.signal) { + const userSignal = options.signal; + userSignal.addEventListener('abort', () => controller?.abort(userSignal.reason)); + if (userSignal.aborted) { + controller.abort(userSignal.reason); + } + } + currentOptions = { ...options, signal: controller.signal }; + timeoutId = setTimeout(() => controller?.abort(new Error('Request Timeout')), globalConfig.timeoutMs); + } + try { - const response = await fetch(url, options); + const response = await fetch(url, currentOptions); // fetch() resolves for any HTTP status. Retryable codes need to be // treated as failures manually. @@ -89,10 +106,17 @@ export function createSmoothFetch(globalConfig: ResilientFetchConfig) { lastError = err; breaker.recordFailure(domain); + // Do not retry if the user explicitly aborted the request + if (options?.signal?.aborted) { + throw err; + } + // Don't sleep after the final attempt if (attempt < backoffConfig.maxRetries) { await sleep(calculateBackoff(attempt, backoffConfig)); } + } finally { + if (timeoutId) clearTimeout(timeoutId); } } diff --git a/packages/smooth-api-ts/src/types.ts b/packages/smooth-api-ts/src/types.ts index 2a9348b..00d97e6 100644 --- a/packages/smooth-api-ts/src/types.ts +++ b/packages/smooth-api-ts/src/types.ts @@ -13,7 +13,7 @@ export type DeduplicationKeyFn = ( export interface DeduplicationConfig { /** * Custom function to compute the deduplication key. - * Receives the same (url, options) passed to resilientFetch. + * Receives the same (url, options) passed to smoothFetch. * Defaults to the stringified URL (method-agnostic). */ keyFn?: DeduplicationKeyFn; @@ -38,7 +38,7 @@ export interface CircuitBreakerConfig { } // T types the fallback payload so callers get inference at the use site. -export interface ResilientFetchConfig { +export interface SmoothFetchConfig { backoff?: Partial; circuitBreaker?: Partial; fallback?: T; // returned immediately on an OPEN circuit, no network IO @@ -50,6 +50,11 @@ export interface ResilientFetchConfig { * Pass an empty object `{}` to activate with the default key function. */ deduplication?: DeduplicationConfig; + /** + * Maximum duration in milliseconds before a request attempt is aborted. + * Applied per-attempt. If aborted, the request is considered a failure and may be retried. + */ + timeoutMs?: number; } // Thrown when the circuit is OPEN and no fallback is configured. diff --git a/packages/smooth-api-ts/tests/deduplication.test.ts b/packages/smooth-api-ts/tests/deduplication.test.ts index a050f73..420f5da 100644 --- a/packages/smooth-api-ts/tests/deduplication.test.ts +++ b/packages/smooth-api-ts/tests/deduplication.test.ts @@ -33,14 +33,14 @@ describe('Request Deduplication', () => { globalThis.fetch = makeStubFetch(() => new Response('{}', { status: 200 }), counter); try { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0, baseDelay: 0, maxDelay: 0 }, }); await Promise.all([ - resilientFetch('http://example.com/users/1'), - resilientFetch('http://example.com/users/1'), - resilientFetch('http://example.com/users/1'), + smoothFetch('http://example.com/users/1'), + smoothFetch('http://example.com/users/1'), + smoothFetch('http://example.com/users/1'), ]); assert.equal(counter.calls, 3, 'Without deduplication, each call hits the network'); @@ -64,15 +64,15 @@ describe('Request Deduplication', () => { }; try { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0, baseDelay: 0, maxDelay: 0 }, deduplication: {}, }); const results = await Promise.all([ - resilientFetch('http://example.com/users/1'), - resilientFetch('http://example.com/users/1'), - resilientFetch('http://example.com/users/1'), + smoothFetch('http://example.com/users/1'), + smoothFetch('http://example.com/users/1'), + smoothFetch('http://example.com/users/1'), ]); assert.equal(counter.calls, 1, 'Exactly one network call should be made for 3 concurrent identical requests'); @@ -96,14 +96,14 @@ describe('Request Deduplication', () => { }; try { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0, baseDelay: 0, maxDelay: 0 }, deduplication: {}, }); await Promise.all([ - resilientFetch('http://example.com/users/1'), - resilientFetch('http://example.com/users/2'), + smoothFetch('http://example.com/users/1'), + smoothFetch('http://example.com/users/2'), ]); assert.equal(counter.calls, 2, 'Different URLs must each trigger their own network call'); @@ -122,15 +122,15 @@ describe('Request Deduplication', () => { }; try { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0, baseDelay: 0, maxDelay: 0 }, deduplication: {}, }); // First call — completes before the second. - await resilientFetch('http://example.com/data'); + await smoothFetch('http://example.com/data'); // Second call — should trigger a new network request. - await resilientFetch('http://example.com/data'); + await smoothFetch('http://example.com/data'); assert.equal(counter.calls, 2, 'Sequential requests should each hit the network'); } finally { @@ -146,15 +146,15 @@ describe('Request Deduplication', () => { }; try { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0, baseDelay: 0, maxDelay: 0 }, deduplication: {}, }); const results = await Promise.allSettled([ - resilientFetch('http://example.com/flaky'), - resilientFetch('http://example.com/flaky'), - resilientFetch('http://example.com/flaky'), + smoothFetch('http://example.com/flaky'), + smoothFetch('http://example.com/flaky'), + smoothFetch('http://example.com/flaky'), ]); for (const result of results) { @@ -179,7 +179,7 @@ describe('Request Deduplication', () => { try { // Custom key returns only the HTTP method — two GETs share the same key // and are therefore deduplicated regardless of their URLs. - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0, baseDelay: 0, maxDelay: 0 }, deduplication: { keyFn: (_url, options) => (options?.method ?? 'GET').toUpperCase(), @@ -188,8 +188,8 @@ describe('Request Deduplication', () => { // Two concurrent GETs to different URLs — same key → deduplicated. await Promise.all([ - resilientFetch('http://example.com/a', { method: 'GET' }), - resilientFetch('http://example.com/b', { method: 'GET' }), + smoothFetch('http://example.com/a', { method: 'GET' }), + smoothFetch('http://example.com/b', { method: 'GET' }), ]); assert.equal(counter.calls, 1, 'Both GETs share a key via custom keyFn → single fetch'); @@ -209,7 +209,7 @@ describe('Request Deduplication', () => { }; try { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0, baseDelay: 0, maxDelay: 0 }, deduplication: { keyFn: (_url, options) => @@ -219,8 +219,8 @@ describe('Request Deduplication', () => { // Two concurrent POSTs — keyFn returns null, so they bypass deduplication. await Promise.all([ - resilientFetch('http://example.com/users', { method: 'POST' }), - resilientFetch('http://example.com/users', { method: 'POST' }), + smoothFetch('http://example.com/users', { method: 'POST' }), + smoothFetch('http://example.com/users', { method: 'POST' }), ]); assert.equal(counter.calls, 2, 'null key disables deduplication for this pair of POSTs'); diff --git a/packages/smooth-api-ts/tests/resilience.test.ts b/packages/smooth-api-ts/tests/resilience.test.ts index e014ecf..0d6c0eb 100644 --- a/packages/smooth-api-ts/tests/resilience.test.ts +++ b/packages/smooth-api-ts/tests/resilience.test.ts @@ -16,13 +16,13 @@ describe('retry logic', () => { await reset(); // seq after reset: 1=200, 2=200, 3=500, 4=200, 5=429, 6=500, ... // with maxRetries:3 and a lucky sequence we should get through - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { baseDelay: 10, maxDelay: 50, maxRetries: 3 }, circuitBreaker: { failureThreshold: 10, cooldownMs: 60_000 }, retryOn: [429, 500], }); - const res = await resilientFetch(`${BASE}/unstable-data`) as Response; + const res = await smoothFetch(`${BASE}/unstable-data`) as Response; assert.ok(res.ok || res.status < 500, 'should eventually get a non-500 response'); }); }); @@ -31,7 +31,7 @@ describe('circuit breaker', () => { it('trips to OPEN after failureThreshold consecutive failures', async () => { await reset(); - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { baseDelay: 5, maxDelay: 20, maxRetries: 0 }, circuitBreaker: { failureThreshold: 3, cooldownMs: 60_000 }, retryOn: [500, 429], @@ -44,7 +44,7 @@ describe('circuit breaker', () => { try { const results: (string | number)[] = []; for (let i = 0; i < 5; i++) { - const res = await resilientFetch(`${BASE}/unstable-data`); + const res = await smoothFetch(`${BASE}/unstable-data`); if (res && typeof res === 'object' && 'tripped' in (res as object)) { results.push('FALLBACK'); } else if (res) { @@ -65,7 +65,7 @@ describe('circuit breaker', () => { await reset(); const fallback = { data: 'cached_value' }; - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { baseDelay: 5, maxDelay: 20, maxRetries: 0 }, circuitBreaker: { failureThreshold: 3, cooldownMs: 60_000 }, retryOn: [500, 429], @@ -78,11 +78,11 @@ describe('circuit breaker', () => { try { // Drive failures until circuit trips, then check fallback is returned for (let i = 0; i < 5; i++) { - await resilientFetch(`${BASE}/unstable-data`).catch(() => {}); + await smoothFetch(`${BASE}/unstable-data`).catch(() => {}); } // Next call should hit OPEN circuit and get fallback instantly - const result = await resilientFetch(`${BASE}/unstable-data`); + const result = await smoothFetch(`${BASE}/unstable-data`); assert.deepEqual(result, fallback, 'should return the exact fallback object'); } finally { globalThis.fetch = originalFetch; @@ -92,7 +92,7 @@ describe('circuit breaker', () => { it('throws CircuitOpenError when OPEN and no fallback is configured', async () => { await reset(); - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { baseDelay: 5, maxDelay: 20, maxRetries: 0 }, circuitBreaker: { failureThreshold: 3, cooldownMs: 60_000 }, retryOn: [500, 429], @@ -104,11 +104,11 @@ describe('circuit breaker', () => { try { for (let i = 0; i < 5; i++) { - await resilientFetch(`${BASE}/unstable-data`).catch(() => {}); + await smoothFetch(`${BASE}/unstable-data`).catch(() => {}); } await assert.rejects( - () => resilientFetch(`${BASE}/unstable-data`), + () => smoothFetch(`${BASE}/unstable-data`), (err: unknown) => { assert.ok(err instanceof CircuitOpenError, `expected CircuitOpenError, got ${err}`); assert.ok((err as CircuitOpenError).domain.includes('localhost')); @@ -126,7 +126,7 @@ describe('circuit breaker recovery', () => { await reset(); const cooldownMs = 500; // short cooldown for testing - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { baseDelay: 5, maxDelay: 20, maxRetries: 0 }, circuitBreaker: { failureThreshold: 3, cooldownMs }, retryOn: [500, 429], @@ -139,7 +139,7 @@ describe('circuit breaker recovery', () => { try { // Trip the circuit for (let i = 0; i < 5; i++) { - await resilientFetch(`${BASE}/unstable-data`).catch(() => {}); + await smoothFetch(`${BASE}/unstable-data`).catch(() => {}); } } finally { globalThis.fetch = originalFetch; @@ -152,7 +152,7 @@ describe('circuit breaker recovery', () => { await reset(); // Probe request should succeed and close the circuit - const res = await resilientFetch(`${BASE}/unstable-data`); + const res = await smoothFetch(`${BASE}/unstable-data`); assert.ok( res instanceof Response && res.status === 200, `expected 200 after recovery, got ${JSON.stringify(res)}` @@ -162,7 +162,7 @@ describe('circuit breaker recovery', () => { describe('non-retryable error fallback & alerts', () => { it('returns normal response without alert when fallbackOnNonRetryable is false', async () => { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0 }, fallbackOnNonRetryable: false, }); @@ -176,7 +176,7 @@ describe('non-retryable error fallback & alerts', () => { globalThis.fetch = async () => new Response(null, { status: 404, statusText: 'Not Found' }); try { - const res = await resilientFetch(`${BASE}/some-url`); + const res = await smoothFetch(`${BASE}/some-url`); assert.ok(res instanceof Response); assert.equal(res.status, 404); assert.equal(errorCalled, false); @@ -187,7 +187,7 @@ describe('non-retryable error fallback & alerts', () => { }); it('triggers window.alert and returns mock Response on 405 when fallbackOnNonRetryable is true and no fallback config', async () => { - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0 }, fallbackOnNonRetryable: true, }); @@ -201,7 +201,7 @@ describe('non-retryable error fallback & alerts', () => { globalThis.fetch = async () => new Response(null, { status: 405, statusText: 'Method Not Allowed' }); try { - const res: any = await resilientFetch(`${BASE}/some-url`); + const res: any = await smoothFetch(`${BASE}/some-url`); assert.ok(res instanceof Response); assert.equal(res.status, 405); @@ -218,7 +218,7 @@ describe('non-retryable error fallback & alerts', () => { it('returns configured fallback when fallbackOnNonRetryable is true and fallback is provided', async () => { const fallbackVal = { fallbackMsg: 'custom_fallback' }; - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0 }, fallbackOnNonRetryable: true, fallback: fallbackVal, @@ -233,7 +233,7 @@ describe('non-retryable error fallback & alerts', () => { globalThis.fetch = async () => new Response(null, { status: 404, statusText: 'Not Found' }); try { - const res = await resilientFetch(`${BASE}/some-url`); + const res = await smoothFetch(`${BASE}/some-url`); assert.deepEqual(res, fallbackVal); assert.equal(errorCalled, true); } finally { @@ -244,7 +244,7 @@ describe('non-retryable error fallback & alerts', () => { it('calls custom callback instead of window.alert when provided', async () => { let callbackArgs: { status: number; msg: string } | unknown; - const resilientFetch = createSmoothFetch({ + const smoothFetch = createSmoothFetch({ backoff: { maxRetries: 0 }, fallbackOnNonRetryable: true, onNonRetryableError: (status, msg) => { @@ -261,7 +261,7 @@ describe('non-retryable error fallback & alerts', () => { globalThis.fetch = async () => new Response(null, { status: 403, statusText: 'Forbidden' }); try { - const res : any = await resilientFetch(`${BASE}/some-url`); + const res : any = await smoothFetch(`${BASE}/some-url`); assert.ok(res instanceof Response); assert.equal(res.status, 403); assert.equal(errorCalled, false); diff --git a/packages/smooth-api-ts/tests/timeout.test.ts b/packages/smooth-api-ts/tests/timeout.test.ts new file mode 100644 index 0000000..a56a83c --- /dev/null +++ b/packages/smooth-api-ts/tests/timeout.test.ts @@ -0,0 +1,66 @@ +import test, { describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import { createSmoothFetch } from '../src/index.js'; + +describe('Timeout Functionality', () => { + test('should abort fetch if timeout is exceeded and trigger retry', async (t) => { + // We mock global fetch to hang, then succeed + const originalFetch = global.fetch; + let callCount = 0; + + global.fetch = (async (url: any, options: any) => { + callCount++; + if (callCount === 1) { + return new Promise((resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new DOMException('Request Timeout', 'AbortError'))); + }); + } + return new Response('OK', { status: 200 }); + }) as any; + + try { + const smoothFetch = createSmoothFetch({ + timeoutMs: 50, + backoff: { baseDelay: 10, maxRetries: 1 } + }); + + const promise = smoothFetch('http://example.com/timeout'); + + const response = await promise as Response; + assert.strictEqual(response.status, 200); + assert.strictEqual(callCount, 2); + } finally { + global.fetch = originalFetch; + } + }); + + test('should respect user-provided abort signal', async (t) => { + const originalFetch = global.fetch; + let callCount = 0; + + global.fetch = (async (url: any, options: any) => { + callCount++; + return new Promise((resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new DOMException('User Abort', 'AbortError'))); + }); + }) as any; + + try { + const smoothFetch = createSmoothFetch({ + timeoutMs: 500, + backoff: { maxRetries: 1 } + }); + + const controller = new AbortController(); + const promise = smoothFetch('http://example.com/abort', { signal: controller.signal }); + + // Immediately abort from the user + controller.abort(); + + await assert.rejects(promise); + assert.strictEqual(callCount, 1); + } finally { + global.fetch = originalFetch; + } + }); +}); From 79c878de35599fe7e02541d6caf39c5f09e4f640 Mon Sep 17 00:00:00 2001 From: Aryan Sharma Date: Sat, 4 Jul 2026 03:29:53 +0530 Subject: [PATCH 2/2] docs: update READMEs and bump version to 1.3.0 --- packages/smooth-api-py/README.md | 5 ++++- packages/smooth-api-py/pyproject.toml | 2 +- packages/smooth-api-ts/README.md | 5 ++++- packages/smooth-api-ts/package.json | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/smooth-api-py/README.md b/packages/smooth-api-py/README.md index b3e7c82..f8f11ce 100644 --- a/packages/smooth-api-py/README.md +++ b/packages/smooth-api-py/README.md @@ -17,6 +17,7 @@ pip install smoothapi-py - **Smart Retries:** Automatically detects HTTP status codes from `requests` and `httpx` exceptions. Retries on retryable codes (429, 500, 502, 503, 504) and re-raises client errors immediately. - **Graceful Fallbacks:** Optionally return cached or default data instantly when the circuit is `OPEN`, bypassing network IO entirely. - **Request Deduplication:** Automatically merges concurrent identical requests into a single network call (async only). +- **Request Timeouts:** Configurable timeouts to automatically abort requests that hang indefinitely (async only). ## Usage @@ -74,7 +75,9 @@ config = SmoothConfig( # Optional: return this exact object when the circuit is OPEN fallback={"status": "degraded", "data": []}, # Optional: HTTP status codes to trigger a retry - retry_on=[429, 500, 502, 503, 504] + retry_on=[429, 500, 502, 503, 504], + # Optional: Abort a request attempt if it takes longer than 5000ms (Async Only) + timeout_ms=5000 ) @smooth_api(config) diff --git a/packages/smooth-api-py/pyproject.toml b/packages/smooth-api-py/pyproject.toml index f1a3cb8..c6a2c84 100644 --- a/packages/smooth-api-py/pyproject.toml +++ b/packages/smooth-api-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "smoothapi-py" -version = "1.2.3" +version = "1.3.0" description = "API protection library — exponential backoff and circuit breaker" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/smooth-api-ts/README.md b/packages/smooth-api-ts/README.md index bb38d77..8d50186 100644 --- a/packages/smooth-api-ts/README.md +++ b/packages/smooth-api-ts/README.md @@ -17,6 +17,7 @@ npm install @codingaryan/smoothapi - **Smart Retries:** Automatically retries on specific HTTP status codes (e.g., 429, 500, 502, 503, 504) while throwing immediately on client errors (400, 401, 404). - **Graceful Fallbacks:** Optionally serve cached or default data instantly when the circuit is `OPEN`. - **Request Deduplication:** Automatically couples concurrent identical requests into a single network call. +- **Request Timeouts:** Configurable timeouts to automatically abort requests that hang indefinitely. ## Usage @@ -69,7 +70,9 @@ const fetchWithRetry = createSmoothFetch({ // Optional: Return this instead of throwing when the circuit is OPEN fallback: { error: "Service degraded, returning stale data." }, // Optional: Custom status codes to retry on - retryOn: [429, 500, 502, 503, 504] + retryOn: [429, 500, 502, 503, 504], + // Optional: Abort a request attempt if it takes longer than 5000ms + timeoutMs: 5000 }); async function main() { diff --git a/packages/smooth-api-ts/package.json b/packages/smooth-api-ts/package.json index c4e926b..e8e5f66 100644 --- a/packages/smooth-api-ts/package.json +++ b/packages/smooth-api-ts/package.json @@ -1,6 +1,6 @@ { "name": "@codingaryan/smoothapi", - "version": "1.2.3", + "version": "1.3.0", "description": "API protection library — exponential backoff and circuit breaker for fetch", "type": "module", "main": "./dist/index.js",