Metadata-Version: 2.4
Name: nimble-http
Version: 0.2.0
Summary: Async HTTP client with typed errors, timeouts and bounded retries
License-Expression: MIT
Author: Lucas Tiense Blazzi
Author-email: lucast.blazzi@gmail.com
Requires-Python: >=3.14,<4
Classifier: Framework :: AsyncIO
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Dist: httpx (>=0.28.1,<0.29)
Requires-Dist: pydantic (>=2.12,<3)
Requires-Dist: pydantic-settings (>=2.11,<3)
Project-URL: Repository, https://github.com/lucasblazzi/nimble
Description-Content-Type: text/markdown

# nimble-http

Async HTTP client for service-to-service calls: fully typed, timeouts on every
request, bounded retries for transient failures only, and typed errors that tell
a caller whether retrying is worth it.

Built on `httpx`. One `AsyncHttpClient` owns one connection pool — build it once
per process (or per Lambda container) and share it.

## Install

```bash
pip install nimble-http
```

## Usage

```python
from nimble_http import AsyncHttpClient, HttpMethod, HttpRequest, RetryPolicy


async def send() -> None:
    async with AsyncHttpClient(
        base_url="https://graph.facebook.com",
        headers={"authorization": "Bearer <token>"},
        read_timeout_seconds=10.0,
        retry=RetryPolicy(max_attempts=3),
    ) as client:
        response = await client.send(
            HttpRequest(
                method=HttpMethod.POST,
                url="/v26.0/<phone_number_id>/messages",
                json={"messaging_product": "whatsapp", "to": "<phone>"},
            ),
        )
        print(response.json())
```

### Typed responses

```python
from pydantic import BaseModel


class SendResult(BaseModel):
    messaging_product: str


result = await client.send_model(request, SendResult)
```

### Concurrency

```python
responses = await client.send_all(requests, max_concurrency=5)
```

`send_all` uses an `asyncio.TaskGroup`: the first failure cancels the rest and
surfaces as an `ExceptionGroup`. Catch it with `except*`.

### Streaming

```python
async for chunk in client.stream(request):
    ...

body = await client.send_bytes(request)
```

Streaming is never retried once the response starts arriving.

## Errors

Every failure is an `HttpError`. Check `error.is_transient` to decide whether to
let a worker retry or to fail definitively.

| Error | Raised when | `is_transient` |
|---|---|---|
| `HttpTimeoutError` | connect/read/write timeout | `True` |
| `HttpTransportError` | DNS, TLS, connection, protocol failure | `True` |
| `HttpStatusError` | status outside 2xx (carries `status_code`, `headers`, `content`) | only for 408, 425, 429, 5xx |
| `HttpDecodeError` | body is not valid JSON / does not match the model | `False` |

## Retries

`RetryPolicy` retries timeouts, transport errors and the status codes in
`retryable_status_codes` (408, 425, 429, 500, 502, 503, 504 by default), with
exponential backoff plus jitter. A `Retry-After` response header wins over the
computed backoff, capped by `backoff_max_seconds`. Use `NO_RETRY` to disable.

## Testing

Pass an `httpx.AsyncBaseTransport` to drive the client against a stub — no
network, no monkeypatching:

```python
import httpx

client = AsyncHttpClient(
    base_url="https://api.example.test",
    transport=httpx.MockTransport(lambda request: httpx.Response(200, json={})),
)
```

## Breaking change in 0.2.0

The `Requests`, `Session` and `Singleton` classes are gone. They were untyped,
shared a process-wide mutable session, had a fixed 600s timeout with no
per-request override, and `Requests.multiple` recursed into itself. Migration:

| 0.1.0 | 0.2.0 |
|---|---|
| `Requests(raise_for_status=True)` | `AsyncHttpClient(raise_for_status=True)` |
| `await r.single(payload)` | `await client.send_json(HttpRequest(...))` |
| `await r.single_raw(payload)` | `await client.send(HttpRequest(...))` |
| `await r.multiple(payloads)` | `await client.send_all([HttpRequest(...), ...])` |
| `await r.single_bin(payload)` | `await client.send_bytes(HttpRequest(...))` |

