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 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-14 13:20:31 +02:00
parent 88c1ea297e
commit 0b92db87d1
2 changed files with 46 additions and 41 deletions
+10 -9
View File
@@ -8,7 +8,7 @@ import os
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request 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.deps import get_current_user
from app.models.user import User from app.models.user import User
@@ -19,8 +19,7 @@ _client = httpx.AsyncClient(base_url=DOC_SERVICE_URL, timeout=30.0)
router = APIRouter() router = APIRouter()
_HOP_BY_HOP = frozenset( _HOP_BY_HOP = frozenset([
[
"connection", "connection",
"keep-alive", "keep-alive",
"proxy-authenticate", "proxy-authenticate",
@@ -30,8 +29,10 @@ _HOP_BY_HOP = frozenset(
"transfer-encoding", "transfer-encoding",
"upgrade", "upgrade",
"host", "host",
] "accept-encoding",
) ])
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
def _forward_headers(request: Request, user_id: str) -> dict: def _forward_headers(request: Request, user_id: str) -> dict:
@@ -50,7 +51,7 @@ async def proxy_categories(
request: Request, request: Request,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
path: str = "", path: str = "",
) -> StreamingResponse: ) -> Response:
url = f"/categories/{path}" if path else "/categories" url = f"/categories/{path}" if path else "/categories"
headers = _forward_headers(request, str(current_user.id)) headers = _forward_headers(request, str(current_user.id))
body = await request.body() body = await request.body()
@@ -69,11 +70,11 @@ async def proxy_categories(
resp_headers = { resp_headers = {
k: v k: v
for k, v in response.headers.items() for k, v in response.headers.items()
if k.lower() not in _HOP_BY_HOP if k.lower() not in _STRIP_RESPONSE
} }
return StreamingResponse( return Response(
content=iter([response.content]), content=response.content,
status_code=response.status_code, status_code=response.status_code,
headers=resp_headers, headers=resp_headers,
media_type=response.headers.get("content-type"), media_type=response.headers.get("content-type"),
+18 -14
View File
@@ -3,26 +3,27 @@ Proxy all /api/documents/* requests to doc-service:8001/documents/*.
Uses a module-level AsyncClient for connection pooling. Uses a module-level AsyncClient for connection pooling.
Strips hop-by-hop headers that must not be forwarded. Strips hop-by-hop headers that must not be forwarded.
File downloads (/file endpoint) are streamed.
""" """
import os import os
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request 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.deps import get_current_user
from app.models.user import User from app.models.user import User
DOC_SERVICE_URL = os.environ.get("DOC_SERVICE_URL", "http://doc-service:8001") 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) _client = httpx.AsyncClient(base_url=DOC_SERVICE_URL, timeout=120.0)
router = APIRouter() router = APIRouter()
_HOP_BY_HOP = frozenset( # 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", "connection",
"keep-alive", "keep-alive",
"proxy-authenticate", "proxy-authenticate",
@@ -32,8 +33,14 @@ _HOP_BY_HOP = frozenset(
"transfer-encoding", "transfer-encoding",
"upgrade", "upgrade",
"host", "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: def _forward_headers(request: Request, user_id: str) -> dict:
@@ -52,11 +59,9 @@ async def proxy_documents(
request: Request, request: Request,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
path: str = "", path: str = "",
) -> StreamingResponse: ) -> Response:
url = f"/documents/{path}" if path else "/documents" url = f"/documents/{path}" if path else "/documents"
headers = _forward_headers(request, str(current_user.id)) headers = _forward_headers(request, str(current_user.id))
# For multipart uploads, stream the body directly
body = await request.body() body = await request.body()
try: try:
@@ -70,15 +75,14 @@ async def proxy_documents(
except httpx.RequestError as exc: except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"doc-service unreachable: {exc}") raise HTTPException(status_code=502, detail=f"doc-service unreachable: {exc}")
# Strip hop-by-hop from response headers
resp_headers = { resp_headers = {
k: v k: v
for k, v in response.headers.items() for k, v in response.headers.items()
if k.lower() not in _HOP_BY_HOP if k.lower() not in _STRIP_RESPONSE
} }
return StreamingResponse( return Response(
content=iter([response.content]), content=response.content,
status_code=response.status_code, status_code=response.status_code,
headers=resp_headers, headers=resp_headers,
media_type=response.headers.get("content-type"), media_type=response.headers.get("content-type"),