Skip to main content

fastapi-stream-lease

CI PyPI Python

Limit simultaneously active SSE, LLM token streams, and WebSocket sessions across FastAPI/Starlette workers with Redis. Set a per-user limit, a global limit, or both. An extra connection receives HTTP 429 before streaming begins.

Request rate limiters answer “how many requests arrived this minute?” This package answers “how many streams are open right now?” It is useful when a connection can remain open for seconds or minutes. For a general-purpose distributed semaphore, consider py-redis-limiters or py-redis-semaphore.

Install

Requires Python 3.10+ and Redis 5.0+.

With Redis 5 and redis-py 8+, construct your client with redis.from_url(url, protocol=2): redis-py 8 defaults to RESP3, which Redis 5 does not support. See the redis-py protocol documentation.

pip install 'fastapi-stream-lease[fastapi]'

The fastapi extra supplies FastAPI for the HTTP helpers; the core package only requires redis.

Try it locally

The runnable SSE example accepts one API key from STREAM_DEMO_TOKEN and uses it as a demo identity. Start Redis, clone this repository, then run:

git clone https://github.com/agustin18/fastapi-stream-lease.git
cd fastapi-stream-lease
pip install -e '.[fastapi]' uvicorn
export STREAM_DEMO_TOKEN=local-secret
export REDIS_URL=redis://localhost:6379/0
uvicorn examples.sse_demo:app

In another terminal, open two streams, then try a third with the same key:

curl -N -H 'X-API-Key: local-secret' http://localhost:8000/stream

The first two requests stream events; the third receives 429 with a Retry-After header until a slot is freed. Replace the demo API key with your application's authenticated user or account ID before production use. Never use an unverified request parameter as the user ID.

FastAPI integration

from fastapi import Depends, FastAPI, Request
from fastapi.responses import StreamingResponse
import redis.asyncio as redis

from fastapi_stream_lease import (
    LeaseConfig,
    StreamLeaseManager,
    StreamLeaseRejected,
    StreamLeaseUnavailable,
)

app = FastAPI()
# Set explicit timeouts so slow Redis calls do not block worker threads
redis_client = redis.from_url(
    "redis://localhost:6379",
    socket_timeout=1.0,
    socket_connect_timeout=1.0,
)
manager = StreamLeaseManager(
    redis_client,
    LeaseConfig(max_per_user=2, max_global=500, lease_seconds=30),
)


async def authenticated_user_id() -> str:
    # Illustrative placeholder: replace with your auth dependency (e.g. API key or JWT sub).
    # For fully runnable code, see examples/sse_demo.py and examples/websocket_demo.py.
    return "user-123"


@app.exception_handler(StreamLeaseRejected)
async def rejected(request: Request, exc: StreamLeaseRejected):
    return exc.as_response()


@app.exception_handler(StreamLeaseUnavailable)
async def unavailable(request: Request, exc: StreamLeaseUnavailable):
    return exc.as_response()


@app.get("/stream")
async def stream(user_id: str = Depends(authenticated_user_id)):
    async def events():
        yield "data: token or event\n\n"

    # 1-Line Protected StreamingResponse (auto-acquires, wraps, and cleans up on errors)
    return await manager.stream(user_id, events())

    # Or manually acquire and convert:
    # lease = await manager.acquire(user_id)
    # return lease.as_streaming_response(events())

Close the Redis client in your application's lifespan shutdown handler. Reuse the same StreamLeaseManager and key_prefix across workers that share limits. max_per_user=0 or max_global=0 disables that limit; the global count is unavailable when global tracking is disabled.

For WebSockets, keep the context open for the whole session:

async with manager.lease(user_id) as lease:
    while True:
        message = await websocket.receive_text()
        await websocket.send_text(message)

The manager context and async with lease both renew while open. Handle normal WebSocket disconnects in your route as usual. If renewal fails or the lease expires, StreamLeaseLost interrupts the stream or context. Catch it at the application boundary if you want to record a metric or send an application-specific WebSocket close code. wrap(auto_renew=False) disables automatic renewal; use it only if you renew the lease yourself.

Behavior and limits

  • Acquisition, renewal, expiration cleanup, and release use atomic Redis Lua scripts. The keys share a Redis Cluster hash tag, so a user and global limit can be checked in one script.
  • Every lease expires after lease_seconds without a successful renewal. wrap() and manager.lease() renew every half interval by default. Transient Redis connection errors trigger fast retries across the remaining lease TTL (Adaptive Grace Period), preventing temporary hiccups from dropping active streams.
  • If Redis is unavailable on initial acquisition, StreamLeaseUnavailable (HTTP 503) is raised by default (fail_open=False). Set fail_open=True in LeaseConfig if your application prefers allowing streams during Redis outages (graceful degradation).
  • Normal completion or cancellation attempts immediate release. If Redis is unavailable during release, the lease is removed after expiration; cleanup of the key itself uses a longer TTL. An async iterator abandoned without being closed may also hold its slot until expiration. Use contextlib.aclosing() if your own consumer stops iteration early.
  • get_active_count(user_id) counts active leases for one identity; get_active_count() counts globally when max_global is enabled. Neither is a historical usage metric.
  • All workers sharing limits must use the same key prefix and compatible limit settings. Lease expiration is measured by Redis, avoiding clock differences among application workers.

Production and Operational Guide

  • Redis Client Timeouts: Always configure explicit timeouts on your Redis client (e.g. socket_timeout=1.0, socket_connect_timeout=1.0). Without timeouts, an unreachable Redis instance can block asyncio event loop execution indefinitely.
  • Fail-Open vs. Fail-Closed Strategy:
    • fail_open=False (Default): Raises StreamLeaseUnavailable (HTTP 503) when Redis is unreachable. Enforces limits during transient network partitions at the cost of rejecting requests when the backend is down. (Note: asynchronous Redis replication or master failover can still lose recently acknowledged writes if a master fails before syncing to its replica).
    • fail_open=True: Automatically grants in-memory fallback leases when Redis encounters network or timeout errors. Keeps streaming endpoints open during outages, with the operational trade-off that limits are not coordinated across workers until Redis recovers. Authentication, authorization, and script syntax errors never fail open.
  • Definitive Revocation vs. Network Errors: If Redis explicitly reports that a lease is missing or expired (renew() returning 0) or encounters an unhandled execution error, wrap() and lease() cancel the stream immediately to prevent exceeding limits. Transient network disconnects trigger rapid retries until the monotonic lease deadline is reached.
  • Observability and Lifecycle Hooks: LeaseConfig provides zero-dependency callback hooks (supporting both sync and async callables) to plug directly into Prometheus, Datadog, StatsD, or Sentry:
    config = LeaseConfig(
        on_acquired=lambda lease: PROMETHEUS_ACQUIRED.inc(),
        on_rejected=lambda uid, reason: PROMETHEUS_REJECTED.labels(reason=reason).inc(),
        on_lost=lambda lease, reason: PROMETHEUS_LOST.labels(reason=reason).inc(),
        on_backend_error=lambda exc: PROMETHEUS_BACKEND_ERRORS.inc(),
    )
    
  • Redis Failover & Sentinel Support: Automatically classifies ReadOnlyError (thrown when hitting a replica during master election) as a transient condition, enabling adaptive renewal retries to ride out failovers without dropping active streams.
  • Redis Cluster: All keys use Redis hash tags ({prefix}:user:... and {prefix}:global), guaranteeing user and global sorted sets reside on the same hash slot for multi-key atomic Lua operations. As with any multi-key Lua coordination, evaluate slot contention and failover behavior under your specific topology.

Examples directory

Contributing and security

See CONTRIBUTING.md for the local workflow and SECURITY.md for private vulnerability reports. Changes are proposed through pull requests and merged by the maintainer after CI passes. The package is licensed under MIT.

CI checks formatting, lint, types, Redis 5 and 7 behavior, package build, and a minimum of 95% combined line and branch coverage. This is a small beta project; reports from real deployments are especially helpful for documenting operational limits.

Release files for fastapi-stream-lease 0.1.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fastapi-stream-lease 0.1.5
File Size Uploaded
fastapi_stream_lease-0.1.5.tar.gz 97.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for fastapi-stream-lease 0.1.5
File Interpreter ABI Platform
fastapi_stream_lease-0.1.5-py3-none-any.whl Python 3 none any Details

Total release size: 113.7 kB

Release files / fastapi_stream_lease-0.1.5.tar.gz

Download URL fastapi_stream_lease-0.1.5.tar.gz
Size 97.5 kB
Tags Source
SHA-256 checksum
How to use checksums
28afbb72fadd6077d8772e8a9b87815a3ec67e05992e532750a34e76418a78fb
BLAKE2b-256 checksum
How to use checksums
0c96e3755003ffcce9df3420a0aec8bfa2cee4acce236cf95a5e0c60c62ba138
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release files / fastapi_stream_lease-0.1.5-py3-none-any.whl

Download URL fastapi_stream_lease-0.1.5-py3-none-any.whl
Size 16.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
314ec6ad19971478d8920381aac0dcdaafb3d70d1b2df52cb283a25880f4c059
BLAKE2b-256 checksum
How to use checksums
bae6c54b2391f3007e7b1bdcfdf089094b6091057fba1b2e4148454aef5c8033
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.5 This release

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page