Metadata-Version: 2.4
Name: csrd-delegate
Version: 0.3.23
Summary: HTTP delegate base class with retry support
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/delegate
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
License: MIT
Requires-Python: >=3.12
Requires-Dist: csrd-context
Requires-Dist: csrd-models
Requires-Dist: fastapi<1,>=0.115
Requires-Dist: httpx<1,>=0.28
Requires-Dist: tenacity<10,>=9.1
Requires-Dist: yarl<2,>=1.20
Description-Content-Type: text/markdown

# csrd-delegate

HTTP client delegate base class with retry support for FastAPI microservices.

**Package**: `csrd.delegate` · **Import**: `from csrd.delegate import BaseDelegate`

## What's included

- `BaseDelegate` — async HTTP client with header forwarding, retry via tenacity, and lifecycle (`close()` / `async with`)
- Response parsing via `csrd.models.model_parser`
- Configurable retry profiles (`conservative`, `aggressive`, `resilient`)
- httpx-specific response types (`ResponseHandler`, `ResponseHandlerMap`)

## Installation

```bash
uv pip install "csrd-delegate @ git+ssh://git@github.com/csrd-api/fastapi-common.git#subdirectory=packages/delegate"
```

## Dependencies

- `csrd-models`, `csrd-context` (Tier 2)

## Consumer Feature Matrix (`BaseDelegate`)

| Capability area | Common expectation | Actual support | How to use / notes |
|---|---|---|---|
| HTTP methods | Standard verb helpers exist | ✅ Built-in | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` |
| Retry support | Automatic retries for transient failures | ✅ Built-in + configurable | Set default in constructor (`retry_profile` / `retry_*`) or per call |
| Retry presets | Named retry policies | ✅ Built-in | `conservative`, `aggressive`, `resilient` |
| Auth forwarding | Bearer token from incoming request is forwarded | ✅ Built-in | Uses request context headers; can be filtered explicitly |
| Header filtering | Hop-by-hop headers are removed safely | ✅ Built-in | RFC-hop headers filtered by default; body requests additionally filter `content-length` |
| Response parsing | Auto-parse JSON to typed model | ✅ Built-in + configurable | Use `response_model` and/or `model_handler` |
| Status handling | Non-2xx statuses raise exceptions | ✅ Built-in + overridable | Default raises `HTTPException`; override specific statuses with `response_handlers` |
| Per-status custom behavior | Custom handling for 404/409/etc | ✅ Built-in | Pass `response_handlers={404: ...}` style map |
| Client lifecycle | Safe `async with` usage and close management | ✅ Built-in | Delegate-owned client closes automatically; injected client is not closed |
| Per-call transport overrides | Override timeout/headers/auth per request | ✅ Built-in | `timeout`, `headers`, `auth`, `follow_redirects`, etc. on each call |
| Circuit breaker | Open/half-open breaker semantics | ❌ Not built-in | Add externally (middleware/proxy/custom wrapper) |
| Rate limiting | Outbound request throttling | ❌ Not built-in | Add externally (gateway/proxy/custom transport) |
| Service discovery | Dynamic upstream resolution/registry | ❌ Not built-in | Provide concrete `service_host` at wiring time |

---

## Inter-Service Auth Propagation

In multi-service clusters, delegates automatically forward authentication headers from the inbound request to upstream service calls. This enables **transparent claim propagation** through delegate chains.

### How It Works

1. **Client → Service A**: Client sends JWT via `Authorization: Bearer <token>` header
2. **Service A** extracts and verifies claims using `VerifiedTokenDep` (or similar)
3. **Service A → Service B** (via `BaseDelegate`): Delegate automatically includes `Authorization` header from inbound request
4. **Service B** receives and verifies same JWT from Service A's outbound call
5. **Service B → Service C** (if needed): Claim propagation continues transitively

### Example: Multi-Hop Orchestration

```python
# quote-service orchestrates inventory + pricing calls
class QuoteDelegate(BaseDelegate):
    def __init__(self) -> None:
        super().__init__(settings.quote_service_url, retry_profile="conservative")

    async def compose_quote(self, item_id: str, quantity: int):
        # All three calls automatically include Authorization header
        inventory_data = await self.get(f"/api/inventory/{item_id}")
        pricing_data = await self.get(f"/api/pricing/{item_id}")
        return {
            "item_id": item_id,
            "quantity": quantity,
            "stock_available": inventory_data["available_qty"],
            "price": pricing_data["base_price"],
        }

# On inbound request with JWT:
@router.post("/api/quotes")
async def create_quote(
    item_id: str,
    quantity: int,
    verified: VerifiedTokenDep,  # JWT verified here
    quote_delegate: QuoteDelegate,
) -> dict:
    # QuoteDelegate.compose_quote() calls use verified user's JWT
    return await quote_delegate.compose_quote(item_id, quantity)
```

### Header Filtering Behavior

- **Hop-by-hop headers** (RFC 7230): Automatically filtered (connection, keep-alive, etc.)
- **Authorization header**: Always forwarded unless explicitly filtered
- **Content-Length**: Re-computed by httpx for each request
- **Custom headers**: Passed through by default; filter via `header_filter_list` if needed

### Pattern Constraints

1. **No auth bypass**: Each hop validates the JWT independently
2. **Claim consistency**: All services see the same `sub`, `roles`, etc.
3. **No credential injection**: Delegates don't add credentials; they forward inbound claims only
4. **No header injection**: Custom headers must be passed explicitly; inbound headers only forwarded if present

### When to Use Manual Header Control

Override the automatic forwarding only in specific cases:

```python
class InternalDelegate(BaseDelegate):
    def __init__(self) -> None:
        super().__init__(
            settings.internal_service_url,
            header_filter_list=["authorization", "x-tenant-id"],  # Don't forward these
            ignore_incoming_headers=False,  # Still use get_headers() for others
        )
```

---
