Metadata-Version: 2.4
Name: stubbornly
Version: 1.0.0
Summary: A retry library that never hides your errors.
License: MIT
Project-URL: Homepage, https://github.com/yourusername/stubbornly
Project-URL: Issues, https://github.com/yourusername/stubbornly/issues
Keywords: retry,backoff,resilience,async,tenacity
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# stubbornly

**A retry library that never hides your errors.**

Zero config for the common case. Tells you exactly what's happening by default. Never buries the real exception.

```
pip install stubbornly
```

---

## 30-second example

```python
from stubbornly import retry

@retry
def call_flaky_api():
    response = requests.get("https://example.com/api")
    response.raise_for_status()
    return response.json()
```

That's it. By default, `stubbornly` will:

- Retry up to **3 times** on any `Exception`
- Wait **1s, then 2s** between retries (exponential backoff)
- Log every attempt to the `stubbornly` logger:

```
INFO stubbornly call_flaky_api :: [stubbornly] attempt 1/3 failed: HTTPError: 503 Service Unavailable. retrying in 1.00s
INFO stubbornly call_flaky_api :: [stubbornly] attempt 2/3 failed: HTTPError: 503 Service Unavailable. retrying in 2.00s
```

- Raise `StubbornlyGaveUp` with the **real original exception** always accessible on `.original_exception` — never buried.

The same decorator works on `async` functions, no configuration needed:

```python
@retry
async def async_flaky_api():
    async with aiohttp.ClientSession() as session:
        async with session.get("https://example.com/api") as r:
            r.raise_for_status()
            return await r.json()
```

---

## Configuration

All parameters are optional. The zero-config default is production-usable.

```python
from stubbornly import retry
from stubbornly.backoff import exponential, jitter

@retry(
    attempts=5,                          # total attempts (default: 3)
    wait="exponential",                  # "fixed", "exponential", a number, or a callable
    on=(ConnectionError, TimeoutError),  # which exceptions to retry (default: Exception)
    on_retry=my_callback,                # custom callback instead of default logging
    idempotent=False,                    # warn if this non-idempotent fn is retried
)
def robust_call():
    ...
```

### `wait=` options

| Value | Behaviour |
|---|---|
| `0` or `0.0` | No sleep (useful in tests) |
| A positive number | Fixed wait in seconds |
| `"fixed"` | Fixed 1s wait |
| `"exponential"` | 1s, 2s, 4s, ... capped at 60s |
| A callable `(attempt: int) -> float` | Full control |

### Custom backoff

```python
from stubbornly.backoff import exponential, jitter

# Exponential with jitter: attempt 1 → ~1s±25%, attempt 2 → ~2s±25%, ...
@retry(wait=jitter(exponential(base=1.0), ratio=0.25))
def f():
    ...

# Custom: 0.5s, 1.5s, 4.5s (base=0.5, multiplier=3)
@retry(wait=exponential(base=0.5, multiplier=3.0))
def g():
    ...
```

---

## Handling the final failure

`StubbornlyGaveUp` always carries:

- `.original_exception` — the real last exception, never wrapped or hidden
- `.attempts` — total attempts made
- `.history` — list of `(attempt_number, exception)` tuples for every failure

```python
from stubbornly import retry, StubbornlyGaveUp

@retry(attempts=3, wait=0)
def flaky():
    raise ConnectionError("gateway timeout")

try:
    flaky()
except StubbornlyGaveUp as e:
    print(e.original_exception)   # ConnectionError: gateway timeout
    print(e.attempts)              # 3
    print(e.history)               # [(1, ConnectionError(...)), (2, ...), (3, ...)]
```

---

## Custom logging / callback

Provide `on_retry=` to replace the default logger:

```python
def my_handler(attempt: int, total: int, exc: BaseException, wait: float, func) -> None:
    my_observability_system.record_retry(
        func=func.__name__,
        attempt=attempt,
        total=total,
        error=str(exc),
        wait_seconds=wait,
    )

@retry(on_retry=my_handler)
def f():
    ...
```

---

## `idempotent=False` guard

Marks a function as non-idempotent. If it's retried, a `UserWarning` is emitted — a reminder that retrying may cause double-writes, double-charges, or other duplicate side-effects.

```python
@retry(attempts=3, idempotent=False)
def charge_payment(amount):
    payment_gateway.charge(amount)  # NOT safe to retry blindly
```

This is a feature tenacity still doesn't have.

---

## Testing

### Zero-wait mode

Pass `wait=0` to skip all sleep in tests without monkeypatching:

```python
@retry(attempts=3, wait=0, on=(ConnectionError,))
def my_func():
    ...
```

### Disable retries entirely

Use `stubbornly.testing.disable()` to turn off all retry logic inside a test — the first exception propagates immediately, and you get the original exception type (not `StubbornlyGaveUp`):

```python
import stubbornly.testing

def test_underlying_failure():
    with stubbornly.testing.disable():
        with pytest.raises(ConnectionError):
            my_func()  # raises on first attempt, original error, no retries
```

---

## Guaranteed behaviour

These are invariants, not defaults. They cannot be overridden by configuration:

- `KeyboardInterrupt`, `SystemExit`, and `asyncio.CancelledError` are **never retried**
- `asyncio.CancelledError` is **never swallowed** in async functions (not slept through)
- `StubbornlyGaveUp` **always carries** `.original_exception`

---

## Why not tenacity?

`stubbornly` fixes 7 real, documented tenacity pain points:

| # | Problem | tenacity issue | stubbornly fix |
|---|---|---|---|
| 1 | No visibility — what's retrying, why? | [#644](https://github.com/jd/tenacity/issues/644) | Attempt-level logging **on by default** |
| 2 | Original error buried/wrapped on final failure | [#521](https://github.com/jd/tenacity/issues/521) | `StubbornlyGaveUp.original_exception` always present |
| 3 | Stacking decorators breaks / wrong exceptions | [#534](https://github.com/jd/tenacity/issues/534) | Single `RetryPolicy`, explicit composition |
| 4 | Async: cancellation gets swallowed | [#529](https://github.com/jd/tenacity/issues/529) | `CancelledError` hard-excluded, always propagates |
| 5 | Can't easily test retry code | [#228](https://github.com/jd/tenacity/issues/228) | Built-in `wait=0` + `testing.disable()` |
| 6 | Retrying non-idempotent ops causes silent double-execution | [#504](https://github.com/jd/tenacity/issues/504) | `idempotent=False` warning guard |
| 7 | Steep config curve for the common case | General sentiment | Zero-config `@retry` is production-usable |

---

## Requirements

- Python 3.9+
- Zero runtime dependencies (stdlib only)

## License

MIT
