Skip to content

Commit 1a90531

Browse files
fix(server): preserve encoded proxy response bodies
1 parent 4995407 commit 1a90531

2 files changed

Lines changed: 58 additions & 3 deletions

File tree

server/opensandbox_server/api/proxy.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,14 @@ def _schedule_proxy_renew(request: Request | WebSocket, sandbox_id: str) -> None
136136

137137
async def _stream_backend_response(resp: httpx.Response) -> AsyncIterator[bytes]:
138138
"""
139-
Yield backend body chunks and always close the httpx streaming response.
139+
Yield backend body chunks without httpx content decoding and always close the response.
140140
141141
httpx requires ``await resp.aclose()`` for ``stream=True`` responses so connections
142142
return to the pool; Starlette's StreamingResponse does not do this automatically.
143+
Use ``aiter_raw`` so forwarded ``content-encoding`` headers still match the body bytes.
143144
"""
144145
try:
145-
async for chunk in resp.aiter_bytes():
146+
async for chunk in resp.aiter_raw():
146147
yield chunk
147148
finally:
148149
await resp.aclose()

server/tests/test_routes_proxy.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import asyncio
16+
import gzip
1617
from typing import Any, cast
1718

1819
import httpx
@@ -29,17 +30,30 @@
2930

3031
class _FakeStreamingResponse:
3132
def __init__(
32-
self, status_code: int = 200, headers: dict | None = None, chunks: list[bytes] | None = None
33+
self,
34+
status_code: int = 200,
35+
headers: dict | None = None,
36+
chunks: list[bytes] | None = None,
37+
raw_chunks: list[bytes] | None = None,
3338
):
3439
self.status_code = status_code
3540
self.headers = httpx.Headers(headers or {})
3641
self._chunks = chunks or []
42+
self._raw_chunks = raw_chunks if raw_chunks is not None else self._chunks
3743
self.aclose_called = False
44+
self.aiter_bytes_called = False
45+
self.aiter_raw_called = False
3846

3947
async def aiter_bytes(self):
48+
self.aiter_bytes_called = True
4049
for chunk in self._chunks:
4150
yield chunk
4251

52+
async def aiter_raw(self):
53+
self.aiter_raw_called = True
54+
for chunk in self._raw_chunks:
55+
yield chunk
56+
4357
async def aclose(self):
4458
self.aclose_called = True
4559

@@ -425,6 +439,46 @@ def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) ->
425439
assert response.headers.get("x-hop-temp") is None
426440

427441

442+
def test_proxy_preserves_compressed_response_body(
443+
client: TestClient,
444+
auth_headers: dict,
445+
monkeypatch,
446+
) -> None:
447+
class StubService:
448+
@staticmethod
449+
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
450+
assert resolve_internal is True
451+
return Endpoint(endpoint="10.57.1.91:40109")
452+
453+
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
454+
455+
decoded_body = b"<html>vnc</html>"
456+
encoded_body = gzip.compress(decoded_body)
457+
fake_client = _FakeAsyncClient()
458+
fake_client.response = _FakeStreamingResponse(
459+
status_code=200,
460+
headers={
461+
"content-type": "text/html",
462+
"content-encoding": "gzip",
463+
},
464+
chunks=[decoded_body],
465+
raw_chunks=[encoded_body],
466+
)
467+
_set_http_client(client, fake_client)
468+
469+
response = client.get(
470+
"/v1/sandboxes/sbx-123/proxy/8080/vnc/index.html",
471+
headers={**auth_headers, "Accept-Encoding": "gzip"},
472+
)
473+
474+
assert response.status_code == 200
475+
assert response.headers.get("content-encoding") == "gzip"
476+
assert response.content == decoded_body
477+
assert fake_client.response.aiter_raw_called is True
478+
assert fake_client.response.aiter_bytes_called is False
479+
assert fake_client.response.aclose_called is True
480+
481+
428482
def test_proxy_rejects_websocket_upgrade(
429483
client: TestClient,
430484
auth_headers: dict,

0 commit comments

Comments
 (0)