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:
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user