diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40f991f..657f0ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,12 +19,12 @@ SmoothAPI is a dual-language API resilience and fault-tolerance library. The wor ```text smooth-api/ -├── examples/ # Broswer based examples for SmoothAPI +├── examples/ # Browser based examples for SmoothAPI ├── packages/ │ ├── smooth-api-ts/ # TypeScript package (@codingaryan/smoothapi) │ └── smooth-api-py/ # Python package (smoothapi-py) ├── sandbox/ # Express-based chaos server (used by integration tests) -├── website/ # Documentation wesbsite +├── website/ # Documentation website ├── README.md # Project overview └── CONTRIBUTING.md # You are here! ``` diff --git a/packages/smooth-api-ts/README.md b/packages/smooth-api-ts/README.md index 8d50186..517f56e 100644 --- a/packages/smooth-api-ts/README.md +++ b/packages/smooth-api-ts/README.md @@ -165,6 +165,39 @@ const fetchWithRetry = createSmoothFetch({ * **Error Propagation**: If the network call fails, all waiting callers receive the same error. * **Settlement**: Once a request completes, the next call to the same URL triggers a fresh network request. +### AbortController Support + +SmoothAPI natively supports `AbortController`, seamlessly propagating cancellation signals through both the active network request and any ongoing exponential backoff delays. + +```typescript +import { createSmoothFetch } from '@codingaryan/smoothapi'; + +const fetchWithRetry = createSmoothFetch({ + backoff: { maxRetries: 3 } +}); + +const controller = new AbortController(); + +// Cancel the request (or any ongoing retry delays) after 2 seconds +setTimeout(() => { + controller.abort(); +}, 2000); + +try { + const response = await fetchWithRetry('https://api.example.com/data', { + signal: controller.signal + }); + console.log(await response.json()); +} catch (err) { + if (err.name === 'AbortError') { + console.error("Request cancelled by the user."); + } +} +``` + +- **Immediate Halting:** If `controller.abort()` is called while the library is waiting between retries (sleeping), the sleep is immediately interrupted, preventing unnecessary delays. +- **Pre-aborted Signals:** If the signal is already aborted before the fetch is called, the library immediately throws without making any network requests. + ## How It Works 1. **Host Extraction:** The domain is automatically extracted from the URL. The circuit breaker state is isolated per host (e.g., `api.github.com` failing won't trip the circuit for `api.stripe.com`). diff --git a/packages/smooth-api-ts/src/index.ts b/packages/smooth-api-ts/src/index.ts index 7de8f94..41e159d 100644 --- a/packages/smooth-api-ts/src/index.ts +++ b/packages/smooth-api-ts/src/index.ts @@ -41,6 +41,12 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { const run = async (): Promise => { for (let attempt = 0; attempt <= backoffConfig.maxRetries; attempt++) { + // 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"); + } + let timeoutId: ReturnType | undefined; let currentOptions = options; let controller: AbortController | undefined; @@ -82,7 +88,7 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { } } } - await sleep(delayMs); + await sleep(delayMs, options?.signal); continue; } return response; @@ -118,9 +124,15 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { breaker.recordSuccess(domain); return response; - } catch (err) { + } catch (err: any) { lastError = err; + // DO NOT RETRY if the error is an AbortError. + // If the user cancelled the request, we must immediately halt and bubble up the error. + if (err?.name === 'AbortError') { + throw err; + } + // Do not retry or record failure if the user explicitly aborted the request if (options?.signal?.aborted) { throw err; @@ -130,7 +142,7 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { // Don't sleep after the final attempt if (attempt < backoffConfig.maxRetries) { - await sleep(calculateBackoff(attempt, backoffConfig)); + await sleep(calculateBackoff(attempt, backoffConfig), options?.signal); } } finally { if (timeoutId) clearTimeout(timeoutId); diff --git a/packages/smooth-api-ts/src/utils/backoff.ts b/packages/smooth-api-ts/src/utils/backoff.ts index c9fdc7f..3464cd0 100644 --- a/packages/smooth-api-ts/src/utils/backoff.ts +++ b/packages/smooth-api-ts/src/utils/backoff.ts @@ -9,6 +9,31 @@ export function calculateBackoff( attempt: number, config: BackoffConfig ): numb return jitter; } -export function sleep( ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file +export function sleep(ms: number, signal?: AbortSignal | null): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + // Use DOMException with "AbortError" name to match native fetch behavior. + // This ensures standard error handling (e.g. err.name === 'AbortError') works correctly. + return reject(signal.reason || new DOMException("The operation was aborted.", "AbortError")); + } + + let timeoutId: ReturnType; + + const abortHandler = () => { + clearTimeout(timeoutId); + // Match native fetch behavior by throwing a DOMException if no custom reason is provided. + reject(signal?.reason || new DOMException("The operation was aborted.", "AbortError")); + }; + + if (signal) { + signal.addEventListener('abort', abortHandler, { once: true }); + } + + timeoutId = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + resolve(); + }, ms); + }); +} diff --git a/packages/smooth-api-ts/tests/abort.test.ts b/packages/smooth-api-ts/tests/abort.test.ts new file mode 100644 index 0000000..c3f23e7 --- /dev/null +++ b/packages/smooth-api-ts/tests/abort.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSmoothFetch } from '../src/index.js'; + +const BASE = 'http://localhost:3001'; + +async function reset() { + await fetch(`${BASE}/reset`); +} + +describe('abort controller', () => { + it('prevents fetch if already aborted', async () => { + await reset(); + const fetch = createSmoothFetch({ + backoff: { maxRetries: 3, baseDelay: 10 }, + }); + + const controller = new AbortController(); + controller.abort(new Error("Pre-aborted")); + + try { + await fetch(`${BASE}/always-fail`, { + signal: controller.signal + }); + assert.fail('Should have thrown'); + } catch (err: any) { + assert.strictEqual(err.message, 'Pre-aborted'); + } + }); + + it('halts retry loop and throws when aborted during sleep', async () => { + await reset(); + // Using a large baseDelay to ensure the abort happens during sleep + const fetch = createSmoothFetch({ + backoff: { maxRetries: 3, baseDelay: 1000 }, + }); + + const controller = new AbortController(); + + setTimeout(() => { + controller.abort(new Error("Aborted during sleep")); + }, 50); + + const startTime = Date.now(); + try { + await fetch(`${BASE}/always-fail`, { + signal: controller.signal + }); + assert.fail('Should have thrown'); + } catch (err: any) { + const duration = Date.now() - startTime; + assert.ok(duration < 500, `Should have aborted quickly, took ${duration}ms`); + assert.strictEqual(err.message, 'Aborted during sleep'); + } + }); +});