Skip to content

Commit 177bb36

Browse files
authored
Add HTTP_POST_TIMEOUT to TimeoutMiddleware config (#5941) (#6150)
## Summary Adds `HTTP_POST_TIMEOUT` to the `methods_timeout` dict in `backend/main.py`, enabling per-method timeout control for POST requests. Fixes #5941 — `sync-local-files` 504 timeouts on large payloads. POST was the only HTTP method missing from the timeout config (omitted since the original `ac328e5b` commit in Jan 2025). ## Changes - `backend/main.py`: Add `"POST": os.environ.get('HTTP_POST_TIMEOUT')` to `methods_timeout` - `backend/tests/unit/test_timeout_middleware.py`: 3 new tests covering POST timeout override, None fallback with `{"POST": None}`, and AST verification of both key and env var name ## Deployment After merge, set on **backend-sync** Cloud Run: ``` HTTP_POST_TIMEOUT=600 ``` This matches `backend` (main) which already has `HTTP_DEFAULT_TIMEOUT=600` — POST there already gets 600s via fallback. The fix is specifically for `backend-sync` (and any other service) where the default is 120s. **Current env var state** (from mon's audit): | Service | HTTP_DEFAULT_TIMEOUT | HTTP_POST_TIMEOUT (after this PR) | |---------|---------------------|----------------------------------| | backend (main) | 600s | set to 600s | | backend-sync | 120s (hardcoded) | **set to 600s** ← fixes #5941 | | All others | 120s (hardcoded) | unset (120s fallback) | ## WebSocket impact: None HTTP_POST_TIMEOUT does NOT affect WebSocket connections (`/v4/listen`): 1. WebSocket upgrade requests are GET, not POST 2. Starlette `BaseHTTPMiddleware` only handles HTTP requests — WebSocket connections go through a different ASGI path 3. `NO_SOCKET_TIMEOUT` in helm values is for a separate layer ## Risks - **Scope**: `HTTP_POST_TIMEOUT` applies to ALL POST routes, not just `/v1/sync-local-files`. Consistent with GET/PUT/PATCH/DELETE. - **No behavior change without env var**: `None` is filtered by `_parse_methods_timeout` → POST falls back to `HTTP_DEFAULT_TIMEOUT`. ## Tests 16/16 pass (`pytest backend/tests/unit/test_timeout_middleware.py`): - 13 existing tests unchanged - `test_post_timeout_override`: POST with explicit timeout → 504 on slow handler - `test_post_timeout_none_falls_back_to_default`: `{"POST": None}` → skipped → default timeout - `test_main_methods_timeout_includes_post`: AST-parses main.py, asserts POST key + HTTP_POST_TIMEOUT env var ## Live Test Evidence ### L1 (standalone backend) ``` Parsed methods_timeout: {'POST': 2.0} Default timeout: 120.0 POST resolved: 2.0 Without env var, parsed: {} POST falls back to default: 120.0 Fast POST: 200 (expect 200) ✓ Slow POST: 504 (expect 504) ✓ ``` ### L2 (backend + client integrated) ``` GET /health: 200 {'status': 'ok'} ✓ POST /upload-fast: 200 {'received': 1500} ✓ POST /upload-slow: 504 ✓ ``` ### Changed-path coverage | Path ID | Changed path | Happy-path | Non-happy-path | L1 | L2 | |---|---|---|---|---|---| | P1 | `main.py:121` POST in methods_timeout | POST completes within timeout → 200 | POST exceeds timeout → 504 | PASS | PASS | | P2 | `test_timeout_middleware.py` tests | 16/16 pass | N/A (test code) | PASS | N/A | _by AI for @beastoin_
2 parents e4a179a + 5d4a452 commit 177bb36

2 files changed

Lines changed: 76 additions & 0 deletions

File tree

backend/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118

119119
methods_timeout = {
120120
"GET": os.environ.get('HTTP_GET_TIMEOUT'),
121+
"POST": os.environ.get('HTTP_POST_TIMEOUT'),
121122
"PUT": os.environ.get('HTTP_PUT_TIMEOUT'),
122123
"PATCH": os.environ.get('HTTP_PATCH_TIMEOUT'),
123124
"DELETE": os.environ.get('HTTP_DELETE_TIMEOUT'),

backend/tests/unit/test_timeout_middleware.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,78 @@ def test_timeout_returns_504():
182182
client = TestClient(app)
183183
response = client.get("/slow")
184184
assert response.status_code == 504
185+
186+
187+
def test_post_timeout_override():
188+
"""POST requests use HTTP_POST_TIMEOUT when set (#5941)."""
189+
190+
async def slow_post(request):
191+
await asyncio.sleep(10)
192+
return PlainTextResponse("slow post")
193+
194+
app = Starlette(
195+
routes=[
196+
Route("/upload", slow_post, methods=["POST"]),
197+
],
198+
)
199+
app.add_middleware(TimeoutMiddleware, methods_timeout={"POST": 0.1})
200+
client = TestClient(app)
201+
response = client.post("/upload")
202+
assert response.status_code == 504
203+
204+
205+
def test_post_timeout_none_falls_back_to_default(monkeypatch):
206+
"""POST with None timeout falls back to HTTP_DEFAULT_TIMEOUT (#5941)."""
207+
monkeypatch.setenv("HTTP_DEFAULT_TIMEOUT", "0.1")
208+
209+
async def slow_post(request):
210+
await asyncio.sleep(10)
211+
return PlainTextResponse("slow post")
212+
213+
app = Starlette(
214+
routes=[
215+
Route("/upload", slow_post, methods=["POST"]),
216+
],
217+
)
218+
# POST: None mirrors main.py when HTTP_POST_TIMEOUT env var is unset.
219+
# _parse_methods_timeout skips None → falls back to default (0.1s)
220+
app.add_middleware(TimeoutMiddleware, methods_timeout={"POST": None})
221+
client = TestClient(app)
222+
response = client.post("/upload")
223+
assert response.status_code == 504
224+
225+
226+
def test_main_methods_timeout_includes_post():
227+
"""backend/main.py methods_timeout dict includes POST wired to HTTP_POST_TIMEOUT (#5941).
228+
229+
AST-parses main.py to verify the POST entry exists in the methods_timeout
230+
assignment and is wired to os.environ.get('HTTP_POST_TIMEOUT'), without
231+
importing the full app (avoids startup side effects).
232+
"""
233+
import ast
234+
from pathlib import Path
235+
236+
main_py = Path(__file__).resolve().parents[2] / "main.py"
237+
tree = ast.parse(main_py.read_text())
238+
for node in ast.walk(tree):
239+
if isinstance(node, ast.Assign):
240+
for target in node.targets:
241+
if isinstance(target, ast.Name) and target.id == "methods_timeout":
242+
assert isinstance(node.value, ast.Dict)
243+
key_value_pairs = dict(zip(node.value.keys, node.value.values))
244+
post_key = None
245+
post_value = None
246+
for k, v in zip(node.value.keys, node.value.values):
247+
if isinstance(k, ast.Constant) and k.value == "POST":
248+
post_key = k
249+
post_value = v
250+
break
251+
assert post_key is not None, "POST missing from methods_timeout keys"
252+
# Verify value is os.environ.get('HTTP_POST_TIMEOUT')
253+
assert isinstance(post_value, ast.Call), "POST value should be a function call"
254+
assert isinstance(post_value.args[0], ast.Constant), "First arg should be a string constant"
255+
assert (
256+
post_value.args[0].value == "HTTP_POST_TIMEOUT"
257+
), f"POST env var should be HTTP_POST_TIMEOUT, got {post_value.args[0].value}"
258+
return
259+
raise AssertionError("methods_timeout assignment not found in main.py")

0 commit comments

Comments
 (0)