From 69e5611a079c689babbc11160495c49ae25a8bb7 Mon Sep 17 00:00:00 2001 From: TejasNarula Date: Tue, 1 Sep 2026 03:08:06 +0530 Subject: [PATCH 1/3] feat: Add Abort Controller --- CONTRIBUTING.md | 4 +- packages/smooth-api-ts/README.md | 33 ++++++++++++ packages/smooth-api-ts/src/index.ts | 8 ++- packages/smooth-api-ts/src/utils/backoff.ts | 26 +++++++++- packages/smooth-api-ts/tests/abort.test.ts | 56 +++++++++++++++++++++ 5 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 packages/smooth-api-ts/tests/abort.test.ts 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..c77912c 100644 --- a/packages/smooth-api-ts/src/index.ts +++ b/packages/smooth-api-ts/src/index.ts @@ -41,6 +41,10 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { const run = async (): Promise => { for (let attempt = 0; attempt <= backoffConfig.maxRetries; attempt++) { + if (options?.signal?.aborted) { + throw options.signal.reason || new Error("Aborted"); + } + let timeoutId: ReturnType | undefined; let currentOptions = options; let controller: AbortController | undefined; @@ -82,7 +86,7 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { } } } - await sleep(delayMs); + await sleep(delayMs, options?.signal); continue; } return response; @@ -130,7 +134,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..1aa8cc1 100644 --- a/packages/smooth-api-ts/src/utils/backoff.ts +++ b/packages/smooth-api-ts/src/utils/backoff.ts @@ -9,6 +9,28 @@ export function calculateBackoff( attempt: number, config: BackoffConfig ): numb return jitter; } -export function sleep( ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); +export function sleep(ms: number, signal?: AbortSignal | null): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + return reject(signal.reason || new Error("Aborted")); + } + + let timeoutId: ReturnType; + + const abortHandler = () => { + clearTimeout(timeoutId); + reject(signal?.reason || new Error("Aborted")); + }; + + if (signal) { + signal.addEventListener('abort', abortHandler, { once: true }); + } + + timeoutId = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', abortHandler); + } + resolve(); + }, ms); + }); } \ No newline at end of file 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'); + } + }); +}); From fafa5a5703bd7a7536b3402779ca3f8ebc072798 Mon Sep 17 00:00:00 2001 From: Aryan Sharma Date: Tue, 1 Sep 2026 10:47:40 +0530 Subject: [PATCH 2/3] Improve abort handling in sleep function Updated error handling in sleep function to throw DOMException with 'AbortError' name when aborted. --- packages/smooth-api-ts/src/utils/backoff.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/smooth-api-ts/src/utils/backoff.ts b/packages/smooth-api-ts/src/utils/backoff.ts index 1aa8cc1..3464cd0 100644 --- a/packages/smooth-api-ts/src/utils/backoff.ts +++ b/packages/smooth-api-ts/src/utils/backoff.ts @@ -12,14 +12,17 @@ export function calculateBackoff( attempt: number, config: BackoffConfig ): numb export function sleep(ms: number, signal?: AbortSignal | null): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { - return reject(signal.reason || new Error("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); - reject(signal?.reason || new Error("Aborted")); + // 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) { @@ -33,4 +36,4 @@ export function sleep(ms: number, signal?: AbortSignal | null): Promise { resolve(); }, ms); }); -} \ No newline at end of file +} From 5b282adc4ce5d45b217a0c2a3d0b6659b2cbbe54 Mon Sep 17 00:00:00 2001 From: Aryan Sharma Date: Tue, 1 Sep 2026 10:48:03 +0530 Subject: [PATCH 3/3] Enhance abort error handling in fetch function Updated error handling for abort scenarios to match native fetch behavior. --- packages/smooth-api-ts/src/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/smooth-api-ts/src/index.ts b/packages/smooth-api-ts/src/index.ts index c77912c..41e159d 100644 --- a/packages/smooth-api-ts/src/index.ts +++ b/packages/smooth-api-ts/src/index.ts @@ -41,8 +41,10 @@ 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) { - throw options.signal.reason || new Error("Aborted"); + // Match native fetch behavior for unhandled aborts + throw options.signal.reason || new DOMException("The operation was aborted.", "AbortError"); } let timeoutId: ReturnType | undefined; @@ -122,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;