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
5 changes: 4 additions & 1 deletion packages/smooth-api-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/smooth-api-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 9 additions & 3 deletions packages/smooth-api-py/smooth_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +89 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Explicit timeout_ms=0 is silently ignored.

if config.timeout_ms: treats 0 as falsy, so a caller who intentionally sets timeout_ms=0 gets no timeout enforcement at all, silently diverging from the configured value. Since the field is Optional[int], use an explicit is not None check.

🐛 Proposed fix
-                            if config.timeout_ms:
+                            if config.timeout_ms is not None:
                                 result = await asyncio.wait_for(fn(*args, **kwargs), timeout=config.timeout_ms / 1000.0)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if config.timeout_ms:
result = await asyncio.wait_for(fn(*args, **kwargs), timeout=config.timeout_ms / 1000.0)
else:
result = await fn(*args, **kwargs)
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)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/smooth-api-py/smooth_api/__init__.py` around lines 89 - 92, The
timeout handling in the async wrapper currently treats timeout_ms=0 as falsy, so
an explicit zero timeout is skipped instead of enforced. Update the conditional
in the decorator logic around the fn/asyncio.wait_for call to check
config.timeout_ms with an explicit is not None test, so both 0 and positive
values use wait_for while None keeps the no-timeout path.

breaker.record_success(domain)
return result
except asyncio.CancelledError:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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):
Expand All @@ -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
2 changes: 2 additions & 0 deletions packages/smooth-api-py/smooth_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 33 additions & 0 deletions packages/smooth-api-py/tests/test_timeout.py
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 4 additions & 1 deletion packages/smooth-api-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion packages/smooth-api-ts/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/smooth-api-ts/src/dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<R>(
Expand Down
30 changes: 27 additions & 3 deletions packages/smooth-api-ts/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -11,7 +11,7 @@ const BACKOFF_DEFAULTS = {

const DEFAULT_RETRY_ON = [429, 500, 502, 503, 504];

export function createSmoothFetch<T>(globalConfig: ResilientFetchConfig<T>) {
export function createSmoothFetch<T>(globalConfig: SmoothFetchConfig<T>) {
const backoffConfig = { ...BACKOFF_DEFAULTS, ...globalConfig.backoff };
const retryOn = globalConfig.retryOn ?? DEFAULT_RETRY_ON;
const breaker = new CircuitBreakerState(globalConfig.circuitBreaker);
Expand Down Expand Up @@ -41,8 +41,25 @@ export function createSmoothFetch<T>(globalConfig: ResilientFetchConfig<T>) {

const run = async (): Promise<Response | T> => {
for (let attempt = 0; attempt <= backoffConfig.maxRetries; attempt++) {
let timeoutId: ReturnType<typeof setTimeout> | 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));
Comment on lines +50 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Remove the bridged abort listener in finally.

Each retry adds a listener to the caller’s AbortSignal and retains the attempt controller after completion. Clean it up with the timeout.

Abort listener cleanup
           let timeoutId: ReturnType<typeof setTimeout> | undefined;
           let currentOptions = options;
           let controller: AbortController | undefined;
+          let removeUserAbortListener: (() => void) | 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);
+              } else {
+                const abortFromUser = () => controller?.abort(userSignal.reason);
+                userSignal.addEventListener('abort', abortFromUser, { once: true });
+                removeUserAbortListener = () => userSignal.removeEventListener('abort', abortFromUser);
               }
             }
@@
           } finally {
             if (timeoutId) clearTimeout(timeoutId);
+            removeUserAbortListener?.();
           }

Also applies to: 118-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/smooth-api-ts/src/index.ts` around lines 50 - 52, The retry path in
`index.ts` leaves a bridged abort listener attached to the caller’s
`AbortSignal`, and the per-attempt controller can also remain referenced after
completion. Update the retry logic around the
`options.signal`/`userSignal.addEventListener('abort', ...)` bridge to store the
listener and remove it in the same `finally` block that clears the timeout,
ensuring each attempt cleans up both the abort listener and the controller
reference. Apply the same cleanup to the other retry block referenced by the
duplicate abort-listener setup.

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.
Expand Down Expand Up @@ -89,10 +106,17 @@ export function createSmoothFetch<T>(globalConfig: ResilientFetchConfig<T>) {
lastError = err;
breaker.recordFailure(domain);

// Do not retry if the user explicitly aborted the request
if (options?.signal?.aborted) {
throw err;
}
Comment on lines +109 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not count user cancellations as circuit-breaker failures.

Line 107 records a failure before Line 110 detects a caller abort, so repeated user cancellations can open the circuit and block later valid requests.

Move abort handling before failure accounting
           } catch (err) {
             lastError = err;
-            breaker.recordFailure(domain);
 
             // Do not retry if the user explicitly aborted the request
             if (options?.signal?.aborted) {
               throw err;
             }
+
+            breaker.recordFailure(domain);
 
             // Don't sleep after the final attempt
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Do not retry if the user explicitly aborted the request
if (options?.signal?.aborted) {
throw err;
}
// Do not retry if the user explicitly aborted the request
if (options?.signal?.aborted) {
throw err;
}
breaker.recordFailure(domain);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/smooth-api-ts/src/index.ts` around lines 109 - 112, User
cancellations are being counted as failures in the circuit-breaker flow. In the
request handling path inside the relevant logic in index.ts, check the abort
state from options?.signal before calling the failure accounting path, and
short-circuit aborted requests without incrementing failure metrics. Update the
surrounding error handling so the abort check happens before the circuit-breaker
failure record, keeping canceled calls from opening the circuit.


// Don't sleep after the final attempt
if (attempt < backoffConfig.maxRetries) {
await sleep(calculateBackoff(attempt, backoffConfig));
}
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/smooth-api-ts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,7 +38,7 @@ export interface CircuitBreakerConfig {
}

// T types the fallback payload so callers get inference at the use site.
export interface ResilientFetchConfig<T = unknown> {
export interface SmoothFetchConfig<T = unknown> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the deprecated alias is still exported from the TS package.
rg -nP '\b(ResilientFetchConfig|SmoothFetchConfig)\b' packages/smooth-api-ts/src packages/smooth-api-ts/tests packages/smooth-api-ts/README.md

Repository: AryanSharma48/smoothAPI

Length of output: 468


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Search for ResilientFetchConfig across repo ==\n'
rg -nP '\bResilientFetchConfig\b' .

printf '\n== package files ==\n'
git ls-files 'packages/smooth-api-ts/*'

printf '\n== types.ts (relevant slice) ==\n'
sed -n '1,120p' packages/smooth-api-ts/src/types.ts

printf '\n== index.ts (relevant slice) ==\n'
sed -n '1,120p' packages/smooth-api-ts/src/index.ts

printf '\n== package.json ==\n'
sed -n '1,220p' packages/smooth-api-ts/package.json

Repository: AryanSharma48/smoothAPI

Length of output: 212


Keep the deprecated ResilientFetchConfig alias exported. packages/smooth-api-ts/src/types.ts only exposes SmoothFetchConfig, so existing TypeScript consumers importing ResilientFetchConfig will break; add the deprecated alias alongside the new name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/smooth-api-ts/src/types.ts` at line 41, The exported config type was
renamed to SmoothFetchConfig, but the deprecated ResilientFetchConfig alias is
no longer exported, which breaks existing TypeScript consumers. Update the types
module to keep ResilientFetchConfig exported as a deprecated alias alongside
SmoothFetchConfig, using the same underlying shape so both names remain
available for imports.

backoff?: Partial<BackoffConfig>;
circuitBreaker?: Partial<CircuitBreakerConfig>;
fallback?: T; // returned immediately on an OPEN circuit, no network IO
Expand All @@ -50,6 +50,11 @@ export interface ResilientFetchConfig<T = unknown> {
* 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;
Comment on lines +53 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether timeoutMs is validated before use.
rg -nP 'timeoutMs|Number\.isFinite|RangeError' packages/smooth-api-ts/src

Repository: AryanSharma48/smoothAPI

Length of output: 455


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- packages/smooth-api-ts/src/index.ts ---\n'
wc -l packages/smooth-api-ts/src/index.ts packages/smooth-api-ts/src/types.ts

printf '\n--- outline: packages/smooth-api-ts/src/index.ts ---\n'
ast-grep outline packages/smooth-api-ts/src/index.ts --view expanded || true

printf '\n--- relevant excerpt: packages/smooth-api-ts/src/index.ts ---\n'
sed -n '1,120p' packages/smooth-api-ts/src/index.ts

printf '\n--- relevant excerpt: packages/smooth-api-ts/src/types.ts ---\n'
sed -n '1,120p' packages/smooth-api-ts/src/types.ts

Repository: AryanSharma48/smoothAPI

Length of output: 252


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- packages/smooth-api-ts/src/index.ts line count ---'
wc -l packages/smooth-api-ts/src/index.ts

echo
echo '--- packages/smooth-api-ts/src/types.ts line count ---'
wc -l packages/smooth-api-ts/src/types.ts

echo
echo '--- outline: packages/smooth-api-ts/src/index.ts ---'
ast-grep outline packages/smooth-api-ts/src/index.ts --view expanded || true

echo
echo '--- relevant excerpt: packages/smooth-api-ts/src/index.ts ---'
sed -n '1,120p' packages/smooth-api-ts/src/index.ts

echo
echo '--- relevant excerpt: packages/smooth-api-ts/src/types.ts ---'
sed -n '1,120p' packages/smooth-api-ts/src/types.ts

Repository: AryanSharma48/smoothAPI

Length of output: 7814


Constrain timeoutMs to a positive finite number. timeoutMs is only truthy-checked before scheduling the abort timer, so 0/NaN disable it and other invalid values still flow to setTimeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/smooth-api-ts/src/types.ts` around lines 53 - 57, The timeoutMs
field in the request types is currently too permissive and can accept invalid
values like 0 or NaN, which then bypass the intended abort behavior. Update the
type definition in types.ts for timeoutMs to constrain it to a positive finite
number, and ensure any validation or usage around the request timeout path
enforces that constraint before scheduling the abort timer. Use the timeoutMs
field and its related request-attempt timeout handling as the key symbols to
locate the fix.

}

// Thrown when the circuit is OPEN and no fallback is configured.
Expand Down
48 changes: 24 additions & 24 deletions packages/smooth-api-ts/tests/deduplication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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(),
Expand All @@ -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');
Expand All @@ -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) =>
Expand All @@ -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');
Expand Down
Loading
Loading