Metadata-Version: 2.5
Name: ratelimit-headers
Version: 0.1.0
Summary: Zero-dependency HTTP rate-limit header parser — GitHub, Stripe, Twitter, IETF draft, RFC 7231 Retry-After
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# ratelimit-headers

**Zero-dependency HTTP rate-limit header parser** — pure Python stdlib only.

Parses heterogeneous rate-limit header formats from GitHub, Stripe, Twitter/X, the IETF draft spec, and RFC 7231 into a single canonical `RateLimitState` dataclass.

```bash
# From PyPI (once published)
pip install ratelimit-headers

# From local clone
pip install .
```

```python
from ratelimit_headers import parse

state = parse(response_headers={
    "X-RateLimit-Limit": "5000",
    "X-RateLimit-Remaining": "4999",
    "X-RateLimit-Reset": "1893456000",
})
print(state.limit)        # 5000
print(state.remaining)   # 4999
print(state.reset)       # datetime(2099, 12, 31, tzinfo=UTC)
print(state.source)      # "github"
```

## ⚡ Performance & Benchmarks

No competitors exist — this is the first pure-stdlib rate-limit header normaliser on PyPI.

```
python3 benchmarks/run_benchmark.py
```

## Why `ratelimit-headers`?

Every major HTTP API uses a different rate-limit header convention. Existing PyPI packages (`aiolimiter`, `async-rate-limit`) are **enforcement** libraries, not parsing/normalisation utilities. No pure-stdlib package normalised these heterogeneous headers into a uniform struct.

`ratelimit-headers` is a single ~150 LOC library + CLI that handles:

| Provider | Header namespace | Reset format |
|---|---|---|
| GitHub | `X-RateLimit-*` | Unix epoch |
| Stripe | `X-RateLimit-*` | Unix epoch |
| Twitter/X | `x-rate-limit-*` | Unix epoch |
| IETF draft | `RateLimit-*` + `Retry-After` | delta-seconds / HTTP-date |
| RFC 7231 | `Retry-After` | delta-seconds / HTTP-date |

## Key Features

- **Pure stdlib** — zero third-party dependencies
- **Five parser formats** — GitHub, Stripe, Twitter/X, IETF draft, RFC 7231
- **Canonical dataclass** — `RateLimitState` with `limit`, `remaining`, `reset`, `retry_after`, `window`, `source`
- **CLI** — `parse`, `detect`, `wait` subcommands
- **Never crashes** — malformed/garbage input returns `RateLimitState(source="unknown")`
- **Type-safe** — `isinstance` guards prevent `AttributeError` on non-str keys
- **Fuzz-tested** — NaN, hex, oversized ints, non-UTF8 bytes all handled safely

## API Reference

### `RateLimitState` dataclass

```python
from ratelimit_headers import RateLimitState

@dataclass(frozen=True, slots=True)
class RateLimitState:
    limit:        Optional[int]      # requests allowed
    remaining:    Optional[int]      # requests remaining
    reset:        Optional[datetime] # UTC reset time
    retry_after:  Optional[int]      # seconds until retry
    window:       Optional[int]      # window size in seconds
    source:       Optional[str]       # format name
```

### `parse(request_headers=None, response_headers=None) -> RateLimitState`

Parses rate-limit headers from an HTTP response. Returns a `RateLimitState`. Never raises — malformed input yields `RateLimitState(source="unknown")`.

```python
from ratelimit_headers import parse

state = parse(response_headers=headers)
```

### `detect_format(headers: dict) -> str`

Returns the detected format name: `"github"` | `"stripe"` | `"twitter"` | `"ietf"` | `"retry-after"` | `"unknown"`.

### `format_name(source: str) -> str`

Human-readable name for a format identifier.

## CLI

```bash
# Parse headers from command-line arguments
ratelimit-headers parse "X-RateLimit-Limit: 100" "X-RateLimit-Remaining: 99"
ratelimit-headers parse --json "X-RateLimit-Limit: 100"

# Detect format from a file
ratelimit-headers detect --input headers.txt
ratelimit-headers detect --json < headers.txt

# Wait until rate-limit resets (reads from stdin)
ratelimit-headers wait --max 300 < headers.txt
```

### CLI JSON output schema

```json
{
  "limit": 5000,
  "remaining": 4999,
  "reset": "2099-12-31T23:59:59+00:00",
  "retry_after": null,
  "window": 3600,
  "source": "github"
}
```

## Limitations

- **No enforcement** — this library parses headers only; it does not throttle, back off, or retry
- **No async** — synchronous only; no `asyncio` or `aiohttp` integration
- **No server-side component** — not a middleware or reverse-proxy plugin
- **No retry orchestration** — does not retry failed requests
- Does not parse quoted-parameter syntax inside header values (e.g. `Limit: 100; w=3600`)

### Known issues (fuzzing-discovered, non-blocking for v0.1.0)

The v0.1.0 release ships with two known minor issues discovered during cycle_51 adversarial fuzzing (`benchmarks/adversarial/FUZZING_REPORT.md`). Both are classified Medium non-blockers with trivial 1-LOC fixes queued for cycle_52:

- **F-10 — `reset=0` epoch yields `remaining=100` instead of 0.** When the upstream server sends `X-RateLimit-Reset: 0` (Unix epoch, already-elapsed window), `parse()` returns `remaining=100` rather than `0` because the window-elapsed branch sets `remaining = limit` instead of 0. Trivial 1-LOC fix.
- **F-15 — `parsers.retry_after` propagates `RuntimeError` instead of returning `RateLimitState(source="unknown")`.** All other parsers are wrapped in defensive `except Exception`, but `parsers.retry_after` lets internal `RuntimeError`s escape. Trivial 1-LOC `try/except` wrap.
- **F-28 — `coerce_int` raises `OverflowError` on values >2^63.** Treated as Info (not Medium) because the function is not on the public surface — it is reached only when callers bypass the documented `parse()` entry point with `request_headers=` for an unsupported scheme.
- **F-NEW-1 — `parse_http_date(None)` raises `AttributeError`.** Same surface as F-28; non-public helper, defensive `except Exception` queued.

## Non-Goals

- NOT a rate limiter / throttler / enforcement library
- NOT an HTTP client
- NOT async
- NOT a policy enforcer
- NOT a server-side component

## Test suite

```
pytest --collect-only -q | tail -1
```

> **155 tests** across 9 test files — all passing.
>
> Full adversarial fuzzing report: [`benchmarks/adversarial/FUZZING_REPORT.md`](benchmarks/adversarial/FUZZING_REPORT.md).

## License

MIT
