@@ -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