From 0b92db87d1930cfe5338418eb4db0f8917680a2c Mon Sep 17 00:00:00 2001 From: curo1305 Date: Tue, 14 Apr 2026 13:20:31 +0200 Subject: [PATCH] Fix proxy response causing false upload failures StreamingResponse + forwarded content-length header was causing a content-length mismatch (chunked vs explicit length), which made axios reject the response even though doc-service had already saved the file. Switch to Response, strip content-length/content-type from forwarded response headers (FastAPI recalculates them correctly), and strip accept-encoding from forwarded requests to prevent decompression mismatches. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/categories_proxy.py | 37 +++++++++--------- backend/app/routers/documents_proxy.py | 50 +++++++++++++------------ 2 files changed, 46 insertions(+), 41 deletions(-) diff --git a/backend/app/routers/categories_proxy.py b/backend/app/routers/categories_proxy.py index 4534f5a..562df77 100644 --- a/backend/app/routers/categories_proxy.py +++ b/backend/app/routers/categories_proxy.py @@ -8,7 +8,7 @@ import os import httpx from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import Response from app.deps import get_current_user from app.models.user import User @@ -19,19 +19,20 @@ _client = httpx.AsyncClient(base_url=DOC_SERVICE_URL, timeout=30.0) router = APIRouter() -_HOP_BY_HOP = frozenset( - [ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", - "host", - ] -) +_HOP_BY_HOP = frozenset([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "accept-encoding", +]) + +_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"]) def _forward_headers(request: Request, user_id: str) -> dict: @@ -50,7 +51,7 @@ async def proxy_categories( request: Request, current_user: User = Depends(get_current_user), path: str = "", -) -> StreamingResponse: +) -> Response: url = f"/categories/{path}" if path else "/categories" headers = _forward_headers(request, str(current_user.id)) body = await request.body() @@ -69,11 +70,11 @@ async def proxy_categories( resp_headers = { k: v for k, v in response.headers.items() - if k.lower() not in _HOP_BY_HOP + if k.lower() not in _STRIP_RESPONSE } - return StreamingResponse( - content=iter([response.content]), + return Response( + content=response.content, status_code=response.status_code, headers=resp_headers, media_type=response.headers.get("content-type"), diff --git a/backend/app/routers/documents_proxy.py b/backend/app/routers/documents_proxy.py index 2dbc0d4..4c5b359 100644 --- a/backend/app/routers/documents_proxy.py +++ b/backend/app/routers/documents_proxy.py @@ -3,37 +3,44 @@ Proxy all /api/documents/* requests to doc-service:8001/documents/*. Uses a module-level AsyncClient for connection pooling. Strips hop-by-hop headers that must not be forwarded. -File downloads (/file endpoint) are streamed. """ import os import httpx from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import Response from app.deps import get_current_user from app.models.user import User DOC_SERVICE_URL = os.environ.get("DOC_SERVICE_URL", "http://doc-service:8001") -# Module-level client — reused across requests for connection pooling _client = httpx.AsyncClient(base_url=DOC_SERVICE_URL, timeout=120.0) router = APIRouter() -_HOP_BY_HOP = frozenset( - [ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", - "host", - ] -) +# Headers that must not be forwarded in either direction. +# Also strip accept-encoding so doc-service never compresses responses — +# httpx decompresses transparently but the content-encoding header would +# then mismatch the already-decompressed body we forward to the browser. +_HOP_BY_HOP = frozenset([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "accept-encoding", +]) + +# Additional response headers we let FastAPI recalculate rather than forward. +# content-length is set automatically from the response body size. +# content-type is set via the media_type argument, so strip it from headers +# to avoid duplicates. +_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"]) def _forward_headers(request: Request, user_id: str) -> dict: @@ -52,11 +59,9 @@ async def proxy_documents( request: Request, current_user: User = Depends(get_current_user), path: str = "", -) -> StreamingResponse: +) -> Response: url = f"/documents/{path}" if path else "/documents" headers = _forward_headers(request, str(current_user.id)) - - # For multipart uploads, stream the body directly body = await request.body() try: @@ -70,15 +75,14 @@ async def proxy_documents( except httpx.RequestError as exc: raise HTTPException(status_code=502, detail=f"doc-service unreachable: {exc}") - # Strip hop-by-hop from response headers resp_headers = { k: v for k, v in response.headers.items() - if k.lower() not in _HOP_BY_HOP + if k.lower() not in _STRIP_RESPONSE } - return StreamingResponse( - content=iter([response.content]), + return Response( + content=response.content, status_code=response.status_code, headers=resp_headers, media_type=response.headers.get("content-type"),