Skip to content

Commit ff08a64

Browse files
committed
test: replace mocks in test_exceptions.py with real HTTP server + sockets
Honors AGENTS.md "do not use mocks or mock things in general unless you are asked to do that directly" (also flagged by CodeRabbit on PR #261). Two transport-wrapping tests previously used httpx.MockTransport. They now drive real httpx errors: - Connection-refused goes via a freshly-bound-and-closed loopback port. - Read-timeout goes via a real pytest-httpserver instance that sleeps past the client's request_timeout. Six wait_for_task tests previously used _Fake* stand-in client + response classes. They now point a real Stream/AsyncStream client at a pytest- httpserver that returns get-task JSON in the real wire format (nanosecond-epoch created_at/updated_at, ErrorResult payload). Adds pytest-httpserver as a dev dependency.
1 parent f9ab013 commit ff08a64

3 files changed

Lines changed: 202 additions & 89 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ dev = [
8282
"prometheus-client>=0.23.1",
8383
"torch>=2.7.0", # Only for scripts/create_test_assets.py
8484
"torchaudio>=2.7.0", # Only for scripts/create_test_assets.py
85+
"pytest-httpserver>=1.1.5",
8586
]
8687

8788
[tool.uv.workspace]

tests/test_exceptions.py

Lines changed: 175 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -326,23 +326,28 @@ def test_transport_exception_cause_chain_preserved():
326326
assert caught.error_type == TRANSPORT_ERROR_CONNECTION_RESET
327327

328328

329-
def test_transport_wrapping_via_mock_transport_sync(monkeypatch):
330-
"""End-to-end: an ``httpx`` transport error inside the SDK sync path is
331-
re-raised as ``StreamTransportException`` with the original on
332-
``__cause__``."""
329+
def _closed_port() -> int:
330+
"""Bind a loopback socket, close it, and return the now-closed port.
331+
Connecting to it triggers a real ``httpx.ConnectError``."""
332+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
333+
s.bind(("127.0.0.1", 0))
334+
port = s.getsockname()[1]
335+
s.close()
336+
return port
337+
338+
339+
def test_transport_wrapping_sync_connection_refused(monkeypatch):
340+
"""End-to-end: a real ``httpx.ConnectError`` (loopback port closed) is
341+
re-raised by the SDK as ``StreamTransportException`` with the original
342+
on ``__cause__``."""
333343
from getstream import Stream
334344

335-
def boom(request: httpx.Request) -> httpx.Response:
336-
raise httpx.ConnectError("network down", request=request)
337-
338-
transport = httpx.MockTransport(boom)
339345
monkeypatch.delenv("STREAM_API_KEY", raising=False)
340346
monkeypatch.delenv("STREAM_API_SECRET", raising=False)
341347
client = Stream(
342348
api_key="k",
343349
api_secret="s",
344-
base_url="https://example.invalid/",
345-
transport=transport,
350+
base_url=f"http://127.0.0.1:{_closed_port()}/",
346351
)
347352
try:
348353
with pytest.raises(StreamTransportException) as info:
@@ -356,28 +361,37 @@ def boom(request: httpx.Request) -> httpx.Response:
356361
client.close()
357362

358363

359-
async def test_transport_wrapping_via_mock_transport_async(monkeypatch):
360-
"""End-to-end async path mirrors the sync test."""
364+
async def test_transport_wrapping_async_read_timeout(httpserver, monkeypatch):
365+
"""A real local server that delays the response longer than the client
366+
read-timeout triggers ``httpx.ReadTimeout``, wrapped by the SDK as
367+
``StreamTransportException`` with ``error_type='timeout'``."""
368+
import time
369+
361370
from getstream import AsyncStream
362371

363-
def boom(request: httpx.Request) -> httpx.Response:
364-
raise httpx.ReadTimeout("read timed out", request=request)
372+
from werkzeug.wrappers import Response as WerkzeugResponse
373+
374+
def slow_handler(_request):
375+
time.sleep(1.0) # exceed the client's read timeout below
376+
return WerkzeugResponse("{}", status=200, content_type="application/json")
377+
378+
httpserver.expect_request("/api/v2/app").respond_with_handler(slow_handler)
365379

366-
transport = httpx.MockTransport(boom)
367380
monkeypatch.delenv("STREAM_API_KEY", raising=False)
368381
monkeypatch.delenv("STREAM_API_SECRET", raising=False)
369-
370382
client = AsyncStream(
371383
api_key="k",
372384
api_secret="s",
373-
base_url="https://example.invalid/",
374-
transport=transport,
385+
base_url=httpserver.url_for("/"),
386+
request_timeout=0.2,
375387
)
376388
try:
377389
with pytest.raises(StreamTransportException) as info:
378390
await client.get_app()
379391
assert info.value.error_type == TRANSPORT_ERROR_TIMEOUT
380-
assert isinstance(info.value.__cause__, httpx.ReadTimeout)
392+
assert isinstance(
393+
info.value.__cause__, (httpx.ReadTimeout, httpx.TimeoutException)
394+
)
381395
finally:
382396
await client.aclose()
383397

@@ -415,69 +429,99 @@ def test_legacy_alias_catches_new_exception():
415429
# ── wait_for_task (§8) ────────────────────────────────────────────────
416430

417431

418-
class _FakeError:
419-
def __init__(self, type_: str, description: str, stack=None, version=None):
420-
self.type = type_
421-
self.description = description
422-
self.stacktrace = stack
423-
self.version = version
424-
432+
_FIXED_NS = int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1e9)
425433

426-
class _FakeTaskData:
427-
def __init__(self, status: str, error: Optional[_FakeError] = None):
428-
self.status = status
429-
self.error = error
430434

435+
def _task_response(status: str, error: Optional[dict] = None) -> dict:
436+
"""Build a get-task response body matching the real API shape.
437+
`created_at` / `updated_at` are nanosecond Unix timestamps per the
438+
backend's wire format."""
439+
body: dict = {
440+
"duration": "1ms",
441+
"task_id": "t",
442+
"status": status,
443+
"created_at": _FIXED_NS,
444+
"updated_at": _FIXED_NS,
445+
}
446+
if error is not None:
447+
body["error"] = error
448+
return body
431449

432-
class _FakeResponse:
433-
def __init__(self, status: str, error: Optional[_FakeError] = None):
434-
self.data = _FakeTaskData(status, error)
435450

451+
def _client_against(httpserver, monkeypatch):
452+
"""Real ``Stream`` instance pointing at the loopback ``httpserver``."""
453+
from getstream import Stream
436454

437-
class _FakeSyncClient:
438-
"""Sync stand-in for a ``Stream`` client; ``get_task`` returns the next
439-
scripted response, repeating the last one indefinitely once the list is
440-
drained so timeout tests don't run out of mock data."""
455+
monkeypatch.delenv("STREAM_API_KEY", raising=False)
456+
monkeypatch.delenv("STREAM_API_SECRET", raising=False)
457+
return Stream(
458+
api_key="k",
459+
api_secret="s",
460+
base_url=httpserver.url_for("/"),
461+
)
441462

442-
def __init__(self, responses):
443-
self._responses = list(responses)
444-
self.calls = 0
445463

446-
def get_task(self, *, id):
447-
self.calls += 1
448-
if len(self._responses) > 1:
449-
return self._responses.pop(0)
450-
return self._responses[0]
464+
def _async_client_against(httpserver, monkeypatch):
465+
from getstream import AsyncStream
451466

467+
monkeypatch.delenv("STREAM_API_KEY", raising=False)
468+
monkeypatch.delenv("STREAM_API_SECRET", raising=False)
469+
return AsyncStream(
470+
api_key="k",
471+
api_secret="s",
472+
base_url=httpserver.url_for("/"),
473+
)
452474

453-
class _FakeAsyncClient:
454-
def __init__(self, responses):
455-
self._responses = list(responses)
456-
self.calls = 0
457475

458-
async def get_task(self, *, id):
459-
self.calls += 1
460-
if len(self._responses) > 1:
461-
return self._responses.pop(0)
462-
return self._responses[0]
476+
def test_wait_for_task_sync_returns_on_completed(httpserver, monkeypatch):
477+
"""The helper exits the polling loop the first time the server reports
478+
``status='completed'``."""
479+
bodies = iter(
480+
[
481+
_task_response("waiting"),
482+
_task_response("completed"),
483+
]
484+
)
485+
from werkzeug.wrappers import Response as WerkzeugResponse
463486

487+
def handler(_r):
488+
return WerkzeugResponse(
489+
json.dumps(next(bodies)),
490+
status=200,
491+
content_type="application/json",
492+
)
464493

465-
def test_wait_for_task_sync_returns_on_completed():
466-
from getstream.tasks import wait_for_task_sync
494+
httpserver.expect_request("/api/v2/tasks/task-1").respond_with_handler(handler)
467495

468-
client = _FakeSyncClient([_FakeResponse("waiting"), _FakeResponse("completed")])
469-
result = wait_for_task_sync(client, "task-1", poll_interval=0.0, timeout=5.0)
496+
client = _client_against(httpserver, monkeypatch)
497+
try:
498+
result = client.wait_for_task("task-1", poll_interval=0.0, timeout=5.0)
499+
finally:
500+
client.close()
470501
assert result.data.status == "completed"
471-
assert client.calls == 2
472502

473503

474-
def test_wait_for_task_sync_raises_on_failed():
475-
from getstream.tasks import wait_for_task_sync
504+
def test_wait_for_task_sync_raises_on_failed(httpserver, monkeypatch):
505+
"""A ``status='failed'`` response surfaces as ``StreamTaskException``
506+
populated from the task's ``ErrorResult``."""
507+
httpserver.expect_request("/api/v2/tasks/task-fail").respond_with_json(
508+
_task_response(
509+
"failed",
510+
error={
511+
"type": "ImportFailed",
512+
"description": "bad rows",
513+
"stacktrace": "trace",
514+
"version": "v1",
515+
},
516+
)
517+
)
476518

477-
err = _FakeError("ImportFailed", "bad rows", stack="trace", version="v1")
478-
client = _FakeSyncClient([_FakeResponse("failed", err)])
479-
with pytest.raises(StreamTaskException) as info:
480-
wait_for_task_sync(client, "task-fail", poll_interval=0.0, timeout=5.0)
519+
client = _client_against(httpserver, monkeypatch)
520+
try:
521+
with pytest.raises(StreamTaskException) as info:
522+
client.wait_for_task("task-fail", poll_interval=0.0, timeout=5.0)
523+
finally:
524+
client.close()
481525
exc = info.value
482526
assert exc.task_id == "task-fail"
483527
assert exc.error_type == "ImportFailed"
@@ -486,44 +530,86 @@ def test_wait_for_task_sync_raises_on_failed():
486530
assert exc.version == "v1"
487531

488532

489-
def test_wait_for_task_sync_times_out_raises_transport_exception():
490-
from getstream.tasks import wait_for_task_sync
533+
def test_wait_for_task_sync_times_out_raises_transport_exception(
534+
httpserver, monkeypatch
535+
):
536+
"""A perpetually-waiting task causes the helper to raise
537+
``StreamTransportException`` with ``error_type='timeout'``."""
538+
httpserver.expect_request("/api/v2/tasks/task-timeout").respond_with_json(
539+
_task_response("waiting")
540+
)
491541

492-
# Always 'waiting' — must time out.
493-
client = _FakeSyncClient([_FakeResponse("waiting") for _ in range(50)])
494-
with pytest.raises(StreamTransportException) as info:
495-
wait_for_task_sync(client, "task-timeout", poll_interval=0.0, timeout=0.01)
542+
client = _client_against(httpserver, monkeypatch)
543+
try:
544+
with pytest.raises(StreamTransportException) as info:
545+
client.wait_for_task("task-timeout", poll_interval=0.05, timeout=0.15)
546+
finally:
547+
client.close()
496548
assert info.value.error_type == TRANSPORT_ERROR_TIMEOUT
497549

498550

499-
async def test_wait_for_task_async_returns_on_completed():
500-
from getstream.tasks import wait_for_task_async
551+
async def test_wait_for_task_async_returns_on_completed(httpserver, monkeypatch):
552+
bodies = iter(
553+
[
554+
_task_response("waiting"),
555+
_task_response("completed"),
556+
]
557+
)
558+
from werkzeug.wrappers import Response as WerkzeugResponse
501559

502-
client = _FakeAsyncClient([_FakeResponse("waiting"), _FakeResponse("completed")])
503-
result = await wait_for_task_async(client, "task-1", poll_interval=0.0, timeout=5.0)
504-
assert result.data.status == "completed"
560+
def handler(_r):
561+
return WerkzeugResponse(
562+
json.dumps(next(bodies)),
563+
status=200,
564+
content_type="application/json",
565+
)
505566

567+
httpserver.expect_request("/api/v2/tasks/task-1").respond_with_handler(handler)
568+
569+
client = _async_client_against(httpserver, monkeypatch)
570+
try:
571+
result = await client.wait_for_task("task-1", poll_interval=0.0, timeout=5.0)
572+
finally:
573+
await client.aclose()
574+
assert result.data.status == "completed"
506575

507-
async def test_wait_for_task_async_raises_on_failed():
508-
from getstream.tasks import wait_for_task_async
509576

510-
err = _FakeError("ImportFailed", "async bad", stack=None, version=None)
511-
client = _FakeAsyncClient([_FakeResponse("failed", err)])
577+
async def test_wait_for_task_async_raises_on_failed(httpserver, monkeypatch):
578+
httpserver.expect_request("/api/v2/tasks/task-fail").respond_with_json(
579+
_task_response(
580+
"failed",
581+
error={
582+
"type": "ImportFailed",
583+
"description": "async bad",
584+
"stacktrace": None,
585+
"version": None,
586+
},
587+
)
588+
)
512589

513-
with pytest.raises(StreamTaskException) as info:
514-
await wait_for_task_async(client, "task-fail", poll_interval=0.0, timeout=5.0)
590+
client = _async_client_against(httpserver, monkeypatch)
591+
try:
592+
with pytest.raises(StreamTaskException) as info:
593+
await client.wait_for_task("task-fail", poll_interval=0.0, timeout=5.0)
594+
finally:
595+
await client.aclose()
515596
assert info.value.task_id == "task-fail"
516597
assert info.value.description == "async bad"
517598

518599

519-
async def test_wait_for_task_async_times_out_raises_transport_exception():
520-
from getstream.tasks import wait_for_task_async
600+
async def test_wait_for_task_async_times_out_raises_transport_exception(
601+
httpserver, monkeypatch
602+
):
603+
httpserver.expect_request("/api/v2/tasks/task-timeout").respond_with_json(
604+
_task_response("waiting")
605+
)
521606

522-
client = _FakeAsyncClient([_FakeResponse("waiting") for _ in range(50)])
523-
with pytest.raises(StreamTransportException) as info:
524-
await wait_for_task_async(
525-
client, "task-timeout", poll_interval=0.0, timeout=0.01
526-
)
607+
client = _async_client_against(httpserver, monkeypatch)
608+
try:
609+
with pytest.raises(StreamTransportException) as info:
610+
await client.wait_for_task("task-timeout", poll_interval=0.05, timeout=0.15)
611+
finally:
612+
await client.aclose()
527613
assert info.value.error_type == TRANSPORT_ERROR_TIMEOUT
528614

529615

0 commit comments

Comments
 (0)