fix(web): make the SSE streams' 200/min rate limit actually apply (#691)

app.py called limiter.limit("200 per minute")(stream_x) after the routes
were registered and discarded the result. flask-limiter 3.x enforces a
decorated limit in the wrapper limit() returns, and marks the original
function so the before_request middleware skips it, so the streams had
no limit at all -- not even the 1000/min default. Register the wrapper
as the view instead.

The new test (skipped without flask-limiter) reconnects to each stream
201 times and expects the last to get a 429; it fails on the old code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:12:23 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c8a0ddcf7b
commit 15c61def67
2 changed files with 58 additions and 3 deletions
+51
View File
@@ -0,0 +1,51 @@
"""The SSE streams must actually enforce their 200-per-minute reconnect limit.
flask-limiter enforces a decorated limit in the wrapper ``limit()`` returns and
marks the original function so the default limit skips it. app.py once called
``limiter.limit(...)(stream_stats)`` and threw the wrapper away, which left the
streams with no limit at all, not even the 1000-per-minute default. These pin
that a client reconnecting in a tight loop gets a 429.
flask-limiter is optional (app.py runs without it), so skip when it's missing.
"""
import pytest
pytest.importorskip("flask_limiter")
from flask import Response
STREAMS = ["/api/v3/stream/stats", "/api/v3/stream/display", "/api/v3/stream/logs"]
@pytest.fixture
def client(monkeypatch):
import web_interface.app as web_app
assert web_app.limiter is not None
# A real stream subscribes to a broadcaster and starts its generator
# thread; only the connect matters here, so answer it with an empty body.
monkeypatch.setattr(
web_app, "_sse_stream",
lambda broadcaster: Response("", mimetype="text/event-stream"),
)
web_app.app.config["TESTING"] = True
web_app.limiter.reset()
try:
with web_app.app.test_client() as c:
yield c
finally:
web_app.limiter.reset()
@pytest.mark.parametrize("url", STREAMS)
def test_reconnecting_more_than_200_times_a_minute_gets_429(client, url):
statuses = [client.get(url).status_code for _ in range(200)]
assert statuses == [200] * 200
assert client.get(url).status_code == 429
def test_each_stream_has_its_own_budget(client):
for _ in range(200):
client.get(STREAMS[0])
assert client.get(STREAMS[0]).status_code == 429
assert client.get(STREAMS[1]).status_code == 200
+7 -3
View File
@@ -925,10 +925,14 @@ def stream_logs():
# Each SSE stream is one long-lived request, so only a (re)connect counts
# against a limit. The streams get their own 200 per minute, tighter than the
# 1000 per minute default, which bounds a client stuck reconnecting.
# flask-limiter enforces a decorated limit in the wrapper limit() returns, and
# marks the original function exempt from the default, so the wrapper has to
# replace the registered view: discarding it leaves the streams unlimited.
if limiter:
limiter.limit("200 per minute")(stream_stats)
limiter.limit("200 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs)
for _endpoint in ('stream_stats', 'stream_display', 'stream_logs'):
app.view_functions[_endpoint] = limiter.limit("200 per minute")(
app.view_functions[_endpoint]
)
@app.route('/favicon.ico')
def favicon():