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
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
```
Expand Down
33 changes: 33 additions & 0 deletions packages/smooth-api-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
18 changes: 15 additions & 3 deletions packages/smooth-api-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export function createSmoothFetch<T>(globalConfig: SmoothFetchConfig<T>) {

const run = async (): Promise<Response | T> => {
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<typeof setTimeout> | undefined;
let currentOptions = options;
let controller: AbortController | undefined;
Expand Down Expand Up @@ -82,7 +88,7 @@ export function createSmoothFetch<T>(globalConfig: SmoothFetchConfig<T>) {
}
}
}
await sleep(delayMs);
await sleep(delayMs, options?.signal);
continue;
}
return response;
Expand Down Expand Up @@ -118,9 +124,15 @@ export function createSmoothFetch<T>(globalConfig: SmoothFetchConfig<T>) {

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;
Expand All @@ -130,7 +142,7 @@ export function createSmoothFetch<T>(globalConfig: SmoothFetchConfig<T>) {

// 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);
Expand Down
31 changes: 28 additions & 3 deletions packages/smooth-api-ts/src/utils/backoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ export function calculateBackoff( attempt: number, config: BackoffConfig ): numb
return jitter;
}

export function sleep( ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small edge case: if signal.reason is undefined (which can happen in older environments or if aborted without a specific reason), falling back to a standard Error means err.name will just be 'Error'.

In the README.md, you suggest users check if (err.name === 'AbortError'), which would fail for this fallback case. Similarly in the call in src/index.ts

// 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<typeof setTimeout>;

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);
});
}
56 changes: 56 additions & 0 deletions packages/smooth-api-ts/tests/abort.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
Loading