Metadata-Version: 2.4
Name: fastapi-disconnect
Version: 0.4.0
Summary: Cancel FastAPI request handlers when the client disconnects
Project-URL: Repository, https://github.com/qweeze/fastapi-disconnect
Author-email: qweeze <qweeeze@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: asgi,cancellation,disconnect,fastapi,starlette
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: anyio>=4.0
Requires-Dist: starlette>=0.40
Description-Content-Type: text/markdown

# fastapi-disconnect

<p>
<a href="https://pypi.org/project/fastapi-disconnect">
    <img src="https://img.shields.io/pypi/v/fastapi-disconnect?color=%2334D058&label=pypi%20package" alt="Package version">
</a>
<a href="https://pypi.org/project/fastapi-disconnect">
    <img src="https://img.shields.io/pypi/pyversions/fastapi-disconnect.svg?color=%2334D058" alt="Supported Python versions">
</a>
</p>

Cancel FastAPI request handlers when the client disconnects.

By default, when a client disconnects (timeout, closed tab, network failure),
FastAPI keeps executing the request handler to completion. For long-running
handlers — an AI flow making paid LLM calls, a heavy database query — that
means paying for work nobody is waiting for. This library cancels the handler
the moment the client goes away, by listening for the ASGI `http.disconnect`
event (no polling of `request.is_disconnected()`).

## Installation

```sh
pip install fastapi-disconnect
```

## Usage

App-wide, via middleware — protects every route, no handler changes:

```python
from fastapi import FastAPI
from fastapi_disconnect import CancelOnDisconnectMiddleware

app = FastAPI()
app.add_middleware(
    CancelOnDisconnectMiddleware,
    exclude_paths=[r"/uploads/.*"],   # optional; re.fullmatch against the path
    on_disconnect=log_cancellation,   # optional; sync or async, gets the ASGI scope
    queue_size=64,                    # optional; bounds body buffering (default: unbounded)
)
```

`on_disconnect` runs only when a *premature* disconnect cancelled a request
(not on normal connection close) — a natural place for a metric counting the
work cancellation saved you.

Per-endpoint, via decorator — applied below the route decorator:

```python
from fastapi_disconnect import cancel_on_disconnect

@app.get("/generate")
@cancel_on_disconnect
async def generate() -> str:
    return await expensive_llm_flow()
```

Cooperative, via dependency — for handlers that want to decide *when* to
stop instead of being cancelled preemptively (e.g. finish the current
step, checkpoint, then exit):

```python
from fastapi_disconnect import Disconnected

@app.get("/agent")
async def agent(disconnected: Disconnected) -> Result:
    for step in plan:
        if disconnected.is_set():        # free local check, no polling
            return partial_result
        await run_step(step)
```

`Disconnected` is an annotated alias for
`Annotated[asyncio.Event, Depends(disconnected_event)]`; the event is set the
moment the client disconnects and can also be awaited (`disconnected.wait()`).
Use it *instead of* the decorator/middleware on a route — combined with them,
preemptive cancellation fires first (though the event is still set, which is
handy inside shielded cleanup). All three mechanisms share a single
per-request watcher, so any combination is safe.

On disconnect, the handler receives a regular `asyncio.CancelledError` at its
next `await`, so `finally` blocks and async context managers run as usual.
The decorator produces a `499 Client Closed Request` response (which goes
nowhere — the client is gone — but keeps logs meaningful); the middleware
simply stops.

## Cleanup on cancellation

Catching `CancelledError` for cleanup is supported, with one rule: the
cancellation is **level-triggered** (anyio cancel-scope semantics). Once the
client is gone, every subsequent `await` inside the handler raises
`CancelledError` again — so asynchronous cleanup must run in a shielded scope:

```python
import anyio

@app.get("/generate")
@cancel_on_disconnect
async def generate() -> str:
    try:
        return await expensive_llm_flow()
    except asyncio.CancelledError:
        with anyio.CancelScope(shield=True):
            await release_resources()  # runs to completion
        raise
```

An unshielded `await` in an `except CancelledError` or `finally` block is
re-cancelled at its first checkpoint. (`asyncio.shield()` is not a substitute:
it detaches the inner call, but the awaiting handler is still re-cancelled, so
the cleanup continues without you.) Synchronous cleanup needs no shielding.
Re-raise after cleaning up — a handler that swallows the cancellation and
returns a value is treated as having completed normally.

## Caveats

- **asyncio only**
- **Don't read the raw body (`request.body()` / `request.stream()`) inside a
  guarded handler** — the disconnect watcher owns the receive channel while
  the handler runs. FastAPI-parsed body params are fine, and under the
  middleware raw reads work normally.
- **The decorator guards only the handler** — dependencies and request
  parsing have already run when it starts, and a returned `StreamingResponse`
  body runs after it exits (the decorator warns). Use the middleware to cover
  the whole request lifecycle. Sync (`def`) handlers can't be cancelled and
  are rejected with `TypeError`.
- **The middleware buffers request bodies eagerly by default** — set
  `queue_size` to restore upload backpressure (disconnect detection pauses
  while the queue is full), exclude upload routes, or limit request size
  upstream.
- **Proxies can hide disconnects** — behind a buffering proxy (nginx, ALB)
  the `http.disconnect` event may arrive late or never.

## License

MIT
