-
Notifications
You must be signed in to change notification settings - Fork 3
Resolves #4 #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Resolves #4 #20
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" |
| 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 = { | ||||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Remove the bridged abort listener in Each retry adds a listener to the caller’s 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 |
||||||||||||||||||||||
| 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<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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Don't sleep after the final attempt | ||||||||||||||||||||||
| if (attempt < backoffConfig.maxRetries) { | ||||||||||||||||||||||
| await sleep(calculateBackoff(attempt, backoffConfig)); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||
| if (timeoutId) clearTimeout(timeoutId); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<T = unknown> { | ||
| export interface SmoothFetchConfig<T = unknown> { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.mdRepository: 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.jsonRepository: AryanSharma48/smoothAPI Length of output: 212 Keep the deprecated 🤖 Prompt for AI Agents |
||
| backoff?: Partial<BackoffConfig>; | ||
| circuitBreaker?: Partial<CircuitBreakerConfig>; | ||
| fallback?: T; // returned immediately on an OPEN circuit, no network IO | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/srcRepository: 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.tsRepository: 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.tsRepository: AryanSharma48/smoothAPI Length of output: 7814 Constrain 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Thrown when the circuit is OPEN and no fallback is configured. | ||
|
|
||
There was a problem hiding this comment.
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=0is silently ignored.if config.timeout_ms:treats0as falsy, so a caller who intentionally setstimeout_ms=0gets no timeout enforcement at all, silently diverging from the configured value. Since the field isOptional[int], use an explicitis not Nonecheck.🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents