-
Notifications
You must be signed in to change notification settings - Fork 103
Description
Description
SlowAPIASGIMiddleware sends the http.response.start message on every http.response.body message, which violates the ASGI specification and causes streaming responses to fail with a LocalProtocolError: Too little data for declared Content-Length error.
Environment
- slowapi version: 0.1.9
- Python version: 3.13.2
- FastAPI version: 0.116.1
- OS: macOS (but likely affects all platforms)
Steps to Reproduce
- Create a FastAPI endpoint that returns a
StreamingResponsewith aContent-Lengthheader:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from slowapi import Limiter
from slowapi.middleware import SlowAPIASGIMiddleware
app = FastAPI()
app.state.limiter = Limiter(key_func=lambda: "test", default_limits=["10/minute"])
app.add_middleware(SlowAPIASGIMiddleware)
@app.get("/download")
async def download():
async def stream_body():
# Simulate streaming multiple chunks
for i in range(10):
yield f"chunk {i}\n".encode()
headers = {
"Content-Type": "application/octet-stream",
"Content-Length": "100", # Total size of all chunks
}
return StreamingResponse(stream_body(), headers=headers)- Make a request to the endpoint:
GET /download - Observe the error
Expected Behavior
The streaming response should work correctly, sending:
- One
http.response.startmessage (with headers) - Multiple
http.response.bodymessages (one per chunk)
Actual Behavior
The middleware sends:
- Multiple
http.response.startmessages (one before eachhttp.response.body) - Multiple
http.response.bodymessages
This results in the error:
h11._util.LocalProtocolError: Too little data for declared Content-Lengthelif message["type"] == "http.response.body":
# ... header injection logic ...
# send the http.response.start message just before the http.response.body one,
# now that the headers are updated
await self.send(self.initial_message) # ❌ Sent on EVERY body message
await self.send(message)According to the ASGI specification, http.response.start must be sent exactly once per response, before any http.response.body messages.
Proposed Fix
Track whether the initial message has been sent and only send it once:
class _ASGIMiddlewareResponder:
def __init__(self, app: ASGIApp) -> None:
self.app = app
self.error_response: Optional[Response] = None
self.initial_message: Message = {}
self.inject_headers = False
self.initial_message_sent = False # ✅ Add flag to track if sent
async def send_wrapper(self, message: Message) -> None:
if message["type"] == "http.response.start":
self.initial_message = message
self.initial_message_sent = False # Reset flag
elif message["type"] == "http.response.body":
if self.error_response:
self.initial_message["status"] = self.error_response.status_code
if self.inject_headers:
headers = MutableHeaders(raw=self.initial_message["headers"])
headers = self.limiter._inject_asgi_headers(
headers, self.request.state.view_rate_limit
)
# ✅ Only send the http.response.start message once, before the first body message
if not self.initial_message_sent:
await self.send(self.initial_message)
self.initial_message_sent = True
await self.send(message)Workaround
We've implemented a fixed version locally (FixedSlowAPIASGIMiddleware) that includes this fix. However, it would be better to have this fixed upstream.
Additional Context
- This bug affects any endpoint using
StreamingResponsewithSlowAPIASGIMiddleware - The bug occurs regardless of whether rate limit headers are being injected
- Non-streaming responses work fine because they only send one
http.response.bodymessage
References
Note: I'm happy to submit a pull request with this fix if the maintainers approve of the approach.