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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ __pycache__/
.venv/
venv/
env/
.ruff_cache
.pytest_cache/
.mypy_cache/
*.egg-info/
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,11 @@ pytest tests/ -v
- [x] Exponential backoff with equal jitter
- [x] Finite state machine circuit breaker
- [x] Retry-After header support
- [ ] Request timeout & AbortController support
- [ ] Custom retry strategies
- [x] Request timeout & AbortController support
- [x] Custom retry strategies (`shouldRetry`)

### Observability
- [ ] Event & metric hooks
- [x] Event & metric hooks (`onRetry`, `onCircuitStateChange`, `onSuccess`, `onFailure`)
- [ ] OpenTelemetry integration
- [ ] Structured logging support

Expand Down
21 changes: 19 additions & 2 deletions packages/smooth-api-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,26 @@ config = SmoothConfig(

SmoothAPI supports lifecycle event hooks to easily attach logging, metrics, alerting, or telemetry:

* **`on_success`**: Invoked on successful execution (HTTP status < 400). Receives a `SuccessContext` with `url`, `domain`, `attempts`, `duration_ms` (and `.duration` in seconds), `status`, and `response`.
* **`on_failure`**: Invoked on terminal failures (HTTP 4xx/5xx, exhausted retries, or when circuit is `OPEN`). Receives a `FailureContext` with `url`, `domain`, `attempts`, `duration_ms`, `error`, `status`, `response`, and `is_circuit_open`.
* **`on_retry`**: Invoked before sleeping on a retryable failure. Receives a `RetryContext` with attempt number, maximum retries, calculated delay (`delay` in seconds, `delay_ms` in milliseconds), HTTP status code (if available), error exception (if available), url, and function domain.
* **`on_circuit_state_change`**: Invoked whenever the circuit breaker transitions between states (`CLOSED`, `OPEN`, `HALF_OPEN`). Receives a `CircuitStateChangeEvent` with `domain`, `from_state`, `to_state`, and `failure_count`.

```python
from smooth_api import smooth_api, SmoothConfig, RetryContext, CircuitStateChangeEvent
from smooth_api import (
smooth_api,
SmoothConfig,
RetryContext,
CircuitStateChangeEvent,
SuccessContext,
FailureContext,
)

def log_success(ctx: SuccessContext):
print(f"[Success] {ctx.domain} completed in {ctx.duration_ms:.1f}ms ({ctx.attempts} attempts)")

def log_failure(ctx: FailureContext):
print(f"[Failure] {ctx.domain} failed after {ctx.attempts} attempts. Circuit open: {ctx.is_circuit_open}")

def log_retry(ctx: RetryContext):
print(f"[Retry] Attempt {ctx.attempt}/{ctx.max_retries} for {ctx.domain} (status: {ctx.status}). Retrying in {ctx.delay_ms:.0f}ms")
Expand All @@ -239,12 +254,14 @@ def log_circuit_transition(event: CircuitStateChangeEvent):
print(f"[Circuit] {event.domain}: {event.from_state} -> {event.to_state} (failures: {event.failure_count})")

config = SmoothConfig(
on_success=log_success,
on_failure=log_failure,
on_retry=log_retry,
on_circuit_state_change=log_circuit_transition,
)
```

Both synchronous functions and `async def` coroutines are supported as hooks. All hooks execute with built-in fail-safe protection, ensuring that any exceptions raised inside user-defined callback functions never crash your request pipeline.
Both synchronous functions and `async def` coroutines are supported as hooks (supporting both `snake_case` and `camelCase` aliases). All hooks execute with built-in fail-safe protection, ensuring that any exceptions raised inside user-defined callback functions never crash your request pipeline.

## How It Works

Expand Down
198 changes: 194 additions & 4 deletions packages/smooth-api-py/smooth_api/__init__.py

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions packages/smooth-api-py/smooth_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ class DeduplicationConfig:
key_fn: Optional[KeyFn] = None


@dataclass
class SuccessContext:
url: str
domain: str
attempts: int
duration_ms: float
status: Optional[int] = None
response: Optional[Any] = None

@property
def duration(self) -> float:
"""Duration in seconds."""
return self.duration_ms / 1000.0


@dataclass
class FailureContext:
url: str
domain: str
attempts: int
duration_ms: float
error: Optional[Any] = None
status: Optional[int] = None
response: Optional[Any] = None
is_circuit_open: bool = False

@property
def duration(self) -> float:
"""Duration in seconds."""
return self.duration_ms / 1000.0

@property
def isCircuitOpen(self) -> bool:
return self.is_circuit_open


@dataclass
class SmoothConfig:
backoff: BackoffConfig = field(default_factory=BackoffConfig)
Expand All @@ -97,11 +133,19 @@ class SmoothConfig:
# Lifecycle event hooks
on_retry: Optional[Callable[[RetryContext], Any]] = None
on_circuit_state_change: Optional[Callable[[CircuitStateChangeEvent], Any]] = None
on_success: Optional[Callable[[SuccessContext], Any]] = None
on_failure: Optional[Callable[[FailureContext], Any]] = None
onRetry: Optional[Callable[[RetryContext], Any]] = None
onCircuitStateChange: Optional[Callable[[CircuitStateChangeEvent], Any]] = None
onSuccess: Optional[Callable[[SuccessContext], Any]] = None
onFailure: Optional[Callable[[FailureContext], Any]] = None

def __post_init__(self):
if self.on_retry is None and self.onRetry is not None:
self.on_retry = self.onRetry
if self.on_circuit_state_change is None and self.onCircuitStateChange is not None:
self.on_circuit_state_change = self.onCircuitStateChange
if self.on_success is None and self.onSuccess is not None:
self.on_success = self.onSuccess
if self.on_failure is None and self.onFailure is not None:
self.on_failure = self.onFailure
179 changes: 179 additions & 0 deletions packages/smooth-api-py/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,182 @@ def fetch(self, url: str):
service.fetch(f"{BASE}/always-fail")
assert len(calls) == 1
assert calls[0].url == f"{BASE}/always-fail"


def test_on_success_sync_clean_200():
reset()
success_calls = []
failure_calls = []

config = SmoothConfig(
on_success=success_calls.append,
on_failure=failure_calls.append,
)

@smooth_api(config)
def call_health():
return requests.get(f"{BASE}/health")

resp = call_health()
assert resp.status_code == 200
assert len(success_calls) == 1
assert len(failure_calls) == 0

ctx = success_calls[0]
assert ctx.status == 200
assert ctx.attempts == 1
assert ctx.duration_ms >= 0
assert ctx.duration >= 0
assert "health" in ctx.url


@pytest.mark.asyncio
async def test_on_success_async_after_retries():
reset()
success_calls = []
retry_calls = []

config = SmoothConfig(
backoff=BackoffConfig(max_retries=3, base_delay=0.01, max_delay=0.05),
on_retry=retry_calls.append,
on_success=success_calls.append,
)

# First 2 calls hit 200, 3rd hits 500 and retries to 4th (200)
requests.get(f"{BASE}/unstable-data")
requests.get(f"{BASE}/unstable-data")

@smooth_api(config)
async def call_unstable():
resp = requests.get(f"{BASE}/unstable-data")
resp.raise_for_status()
return resp

resp = await call_unstable()
assert resp.status_code == 200
assert len(retry_calls) == 1
assert len(success_calls) == 1
assert success_calls[0].attempts == 2


def test_on_failure_500_when_retries_exhausted():
reset()
success_calls = []
failure_calls = []

config = SmoothConfig(
backoff=BackoffConfig(max_retries=2, base_delay=0.01, max_delay=0.05),
on_success=success_calls.append,
on_failure=failure_calls.append,
)

@smooth_api(config)
def call_fail():
resp = requests.get(f"{BASE}/always-fail")
resp.raise_for_status()
return resp

resp = call_fail()
assert resp.status_code == 500
assert len(success_calls) == 0, "on_success must NOT fire for 500"
assert len(failure_calls) == 1, "on_failure must fire for 500"

ctx = failure_calls[0]
assert ctx.status == 500
assert ctx.attempts == 3 # attempt 0, 1, 2
assert ctx.is_circuit_open is False
assert ctx.duration_ms >= 0


def test_on_failure_circuit_open():
reset()
failure_calls = []

config = SmoothConfig(
backoff=BackoffConfig(max_retries=0, base_delay=0.01),
circuit_breaker=CircuitBreakerConfig(failure_threshold=1, cooldown_ms=5000),
on_failure=failure_calls.append,
)

@smooth_api(config)
def call_fail():
resp = requests.get(f"{BASE}/always-fail")
resp.raise_for_status()
return resp

# 1st call trips breaker
call_fail()
assert len(failure_calls) == 1

# 2nd call blocked by OPEN circuit
with pytest.raises(RuntimeError, match="Circuit breaker is OPEN"):
call_fail()

assert len(failure_calls) == 2
second_ctx = failure_calls[1]
assert second_ctx.is_circuit_open is True
assert second_ctx.isCircuitOpen is True
assert second_ctx.attempts == 0


def test_on_success_and_failure_exceptions_safe():
reset()

def buggy_success(ctx):
raise ValueError("Bug in on_success")

def buggy_failure(ctx):
raise ValueError("Bug in on_failure")

config = SmoothConfig(
backoff=BackoffConfig(max_retries=0, base_delay=0.01),
on_success=buggy_success,
on_failure=buggy_failure,
)

@smooth_api(config)
def call_health():
return requests.get(f"{BASE}/health")

@smooth_api(config)
def call_fail():
resp = requests.get(f"{BASE}/always-fail")
resp.raise_for_status()
return resp

# Should not crash on success hook bug
res = call_health()
assert res.status_code == 200

# Should not crash on failure hook bug
fail_res = call_fail()
assert fail_res.status_code == 500


def test_camel_case_hook_aliases():
reset()
success_calls = []
failure_calls = []

config = SmoothConfig(
backoff=BackoffConfig(max_retries=0, base_delay=0.01),
onSuccess=success_calls.append,
onFailure=failure_calls.append,
)

@smooth_api(config)
def call_health():
return requests.get(f"{BASE}/health")

@smooth_api(config)
def call_fail():
resp = requests.get(f"{BASE}/always-fail")
resp.raise_for_status()
return resp

call_health()
assert len(success_calls) == 1

call_fail()
assert len(failure_calls) == 1

14 changes: 12 additions & 2 deletions packages/smooth-api-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ You can pass callback functions to listen to key events during the request lifec
import { createSmoothFetch } from '@codingaryan/smoothapi';

const fetchWithRetry = createSmoothFetch({
onSuccess: (context) => {
// Fired on successful completion (HTTP 2xx)
console.log(`[smoothAPI] ${context.url} succeeded in ${context.durationMs}ms after ${context.attempts} attempt(s)`);
},
onFailure: (context) => {
// Fired on terminal error, exhausted retries, 4xx/5xx, or OPEN circuit
console.error(`[smoothAPI] ${context.url} failed after ${context.attempts} attempt(s). Circuit open: ${context.isCircuitOpen}`);
},
onRetry: (context) => {
// Fired before a request is retried
console.warn(`[Attempt ${context.attempt}/${context.maxRetries}] Retrying ${context.domain} in ${context.delayMs}ms. Status: ${context.status}`);
Expand All @@ -237,8 +245,10 @@ const fetchWithRetry = createSmoothFetch({
});
```

- **`onRetry(context)`**: Receives `{ attempt, maxRetries, delayMs, status?, error?, url, domain }`. It fires immediately before the delay sleep begins.
- **`onCircuitStateChange(event)`**: Receives `{ domain, from, to, failureCount }`. It fires exactly when the state transitions (`CLOSED` → `OPEN` → `HALF_OPEN` → `CLOSED`).
- **`onSuccess(context)`**: Receives `{ url, domain, attempts, durationMs, status, response }`. Fires on HTTP 2xx completions.
- **`onFailure(context)`**: Receives `{ url, domain, attempts, durationMs, error?, status?, response?, isCircuitOpen }`. Fires on terminal failures (5xx/4xx, exhausted retries, or `CircuitOpenError`).
- **`onRetry(context)`**: Receives `{ attempt, maxRetries, delayMs, status?, error?, url, domain }`. Fires immediately before the delay sleep begins.
- **`onCircuitStateChange(event)`**: Receives `{ domain, from, to, failureCount }`. Fires exactly when the state transitions (`CLOSED` → `OPEN` → `HALF_OPEN` → `CLOSED`).
- **Fail-Safe**: If your hook throws an exception, SmoothAPI catches and logs it internally, ensuring it never crashes your request pipeline.

### AbortController Support
Expand Down
Loading
Loading