Skip to content

Commit 21e1ad3

Browse files
Ambient Code Botclaude
andcommitted
test: add tests for tunnel reconnection retry logic
Add unit tests covering the new retry logic in handle_async (lease.py) and the channel_ready timeout in connect_router_stream (streams.py) to satisfy the 80% diff coverage requirement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8ec198d commit 21e1ad3

2 files changed

Lines changed: 358 additions & 0 deletions

File tree

python/packages/jumpstarter/jumpstarter/client/lease_test.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import asyncio
22
import logging
33
import sys
4+
from contextlib import asynccontextmanager
45
from datetime import datetime, timedelta, timezone
56
from unittest.mock import AsyncMock, Mock, patch
67

8+
import grpc
79
import pytest
10+
from grpc.aio import AioRpcError
811
from rich.console import Console
912

1013
from jumpstarter.client.exceptions import LeaseError
@@ -554,3 +557,270 @@ async def get_then_fail():
554557
callback.assert_called()
555558
_, remain_arg = callback.call_args[0]
556559
assert remain_arg == timedelta(0)
560+
561+
562+
def _make_aio_rpc_error(code, details="error"):
563+
"""Helper to construct an AioRpcError."""
564+
return AioRpcError(
565+
code=code,
566+
initial_metadata=grpc.aio.Metadata(),
567+
trailing_metadata=grpc.aio.Metadata(),
568+
details=details,
569+
debug_error_string=None,
570+
)
571+
572+
573+
def _make_lease_for_handle():
574+
"""Create a minimal Lease for testing handle_async."""
575+
lease = object.__new__(Lease)
576+
lease.name = "test-lease"
577+
lease.dial_timeout = 5.0
578+
lease.tls_config = Mock()
579+
lease.grpc_options = {}
580+
lease.controller = Mock()
581+
lease.lease_transferred = False
582+
return lease
583+
584+
585+
class TestHandleAsyncTransientDialRetry:
586+
"""Tests for transient gRPC error retry in handle_async Dial phase."""
587+
588+
@pytest.mark.anyio
589+
async def test_dial_retries_on_unavailable_then_succeeds(self):
590+
"""Dial should retry on UNAVAILABLE and succeed on the next attempt."""
591+
lease = _make_lease_for_handle()
592+
593+
dial_response = Mock(router_endpoint="ep", router_token="tok")
594+
call_count = 0
595+
596+
async def dial_side_effect(req):
597+
nonlocal call_count
598+
call_count += 1
599+
if call_count == 1:
600+
raise _make_aio_rpc_error(grpc.StatusCode.UNAVAILABLE, "tunnel dropped")
601+
return dial_response
602+
603+
lease.controller.Dial = AsyncMock(side_effect=dial_side_effect)
604+
605+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
606+
with patch("jumpstarter.client.lease.connect_router_stream") as mock_router:
607+
608+
@asynccontextmanager
609+
async def fake_router(*args, **kwargs):
610+
yield
611+
612+
mock_router.side_effect = fake_router
613+
await lease.handle_async(Mock())
614+
615+
assert call_count == 2
616+
617+
@pytest.mark.anyio
618+
async def test_dial_transient_error_returns_after_timeout(self):
619+
"""Dial should give up and return when dial_timeout is exceeded."""
620+
lease = _make_lease_for_handle()
621+
lease.dial_timeout = 0.0 # already expired
622+
623+
lease.controller.Dial = AsyncMock(
624+
side_effect=_make_aio_rpc_error(grpc.StatusCode.UNAVAILABLE, "tunnel dropped"),
625+
)
626+
627+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
628+
await lease.handle_async(Mock())
629+
630+
# Should return without raising
631+
lease.controller.Dial.assert_called_once()
632+
633+
@pytest.mark.anyio
634+
@pytest.mark.parametrize(
635+
"status_code",
636+
[grpc.StatusCode.RESOURCE_EXHAUSTED, grpc.StatusCode.ABORTED, grpc.StatusCode.INTERNAL],
637+
ids=["RESOURCE_EXHAUSTED", "ABORTED", "INTERNAL"],
638+
)
639+
async def test_dial_retries_multiple_transient_codes(self, status_code):
640+
"""Dial should retry on RESOURCE_EXHAUSTED, ABORTED, INTERNAL."""
641+
lease = _make_lease_for_handle()
642+
dial_response = Mock(router_endpoint="ep", router_token="tok")
643+
call_count = 0
644+
645+
async def dial_side_effect(req):
646+
nonlocal call_count
647+
call_count += 1
648+
if call_count == 1:
649+
raise _make_aio_rpc_error(status_code, "transient")
650+
return dial_response
651+
652+
lease.controller.Dial = AsyncMock(side_effect=dial_side_effect)
653+
654+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
655+
with patch("jumpstarter.client.lease.connect_router_stream") as mock_router:
656+
657+
@asynccontextmanager
658+
async def fake_router(*args, **kwargs):
659+
yield
660+
661+
mock_router.side_effect = fake_router
662+
await lease.handle_async(Mock())
663+
664+
assert call_count == 2, f"Expected 2 calls for {status_code}, got {call_count}"
665+
666+
667+
class TestHandleAsyncRouterRetry:
668+
"""Tests for router connection retry in handle_async."""
669+
670+
@pytest.mark.anyio
671+
async def test_router_retries_on_transient_error_then_succeeds(self):
672+
"""Router connection should retry on transient error, re-dial, then succeed."""
673+
lease = _make_lease_for_handle()
674+
dial_response = Mock(router_endpoint="ep", router_token="tok")
675+
lease.controller.Dial = AsyncMock(return_value=dial_response)
676+
677+
connect_count = 0
678+
679+
@asynccontextmanager
680+
async def fake_router(*args, **kwargs):
681+
nonlocal connect_count
682+
connect_count += 1
683+
if connect_count == 1:
684+
raise _make_aio_rpc_error(grpc.StatusCode.UNAVAILABLE, "router unreachable")
685+
yield
686+
687+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
688+
with patch("jumpstarter.client.lease.connect_router_stream", side_effect=fake_router):
689+
await lease.handle_async(Mock())
690+
691+
assert connect_count == 2
692+
# Dial called once for initial + once for re-dial
693+
assert lease.controller.Dial.call_count == 2
694+
695+
@pytest.mark.anyio
696+
async def test_router_non_transient_error_returns_immediately(self):
697+
"""Router connection should not retry on non-transient errors."""
698+
lease = _make_lease_for_handle()
699+
dial_response = Mock(router_endpoint="ep", router_token="tok")
700+
lease.controller.Dial = AsyncMock(return_value=dial_response)
701+
702+
@asynccontextmanager
703+
async def fail_router(*args, **kwargs):
704+
raise _make_aio_rpc_error(grpc.StatusCode.PERMISSION_DENIED, "no access")
705+
yield # pragma: no cover
706+
707+
with patch("jumpstarter.client.lease.connect_router_stream", side_effect=fail_router):
708+
await lease.handle_async(Mock())
709+
710+
# Only the initial Dial, no re-dial
711+
assert lease.controller.Dial.call_count == 1
712+
713+
@pytest.mark.anyio
714+
async def test_router_transient_error_returns_after_timeout(self):
715+
"""Router should give up when dial_timeout is exceeded."""
716+
lease = _make_lease_for_handle()
717+
lease.dial_timeout = 0.0 # already expired
718+
dial_response = Mock(router_endpoint="ep", router_token="tok")
719+
lease.controller.Dial = AsyncMock(return_value=dial_response)
720+
721+
@asynccontextmanager
722+
async def fail_router(*args, **kwargs):
723+
raise _make_aio_rpc_error(grpc.StatusCode.UNAVAILABLE, "unreachable")
724+
yield # pragma: no cover
725+
726+
with patch("jumpstarter.client.lease.connect_router_stream", side_effect=fail_router):
727+
await lease.handle_async(Mock())
728+
729+
# Only one Dial (initial), no retry
730+
assert lease.controller.Dial.call_count == 1
731+
732+
@pytest.mark.anyio
733+
async def test_router_redial_failure_is_swallowed(self):
734+
"""When re-dial fails during router retry, the error is logged and retry continues."""
735+
lease = _make_lease_for_handle()
736+
dial_response = Mock(router_endpoint="ep", router_token="tok")
737+
738+
dial_count = 0
739+
740+
async def dial_side_effect(req):
741+
nonlocal dial_count
742+
dial_count += 1
743+
if dial_count == 1:
744+
return dial_response
745+
if dial_count == 2:
746+
# Re-dial fails
747+
raise _make_aio_rpc_error(grpc.StatusCode.UNAVAILABLE, "re-dial failed")
748+
return dial_response
749+
750+
lease.controller.Dial = AsyncMock(side_effect=dial_side_effect)
751+
752+
connect_count = 0
753+
754+
@asynccontextmanager
755+
async def fake_router(*args, **kwargs):
756+
nonlocal connect_count
757+
connect_count += 1
758+
if connect_count <= 2:
759+
raise _make_aio_rpc_error(grpc.StatusCode.UNAVAILABLE, "router fail")
760+
yield
761+
762+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
763+
with patch("jumpstarter.client.lease.connect_router_stream", side_effect=fake_router):
764+
await lease.handle_async(Mock())
765+
766+
# Should have retried: connect fails, re-dial fails, connect fails again,
767+
# re-dial succeeds, third connect succeeds
768+
assert connect_count == 3
769+
assert dial_count == 3
770+
771+
@pytest.mark.anyio
772+
async def test_router_oserror_retries_then_succeeds(self):
773+
"""Router connection should retry on OSError, then succeed."""
774+
lease = _make_lease_for_handle()
775+
dial_response = Mock(router_endpoint="ep", router_token="tok")
776+
lease.controller.Dial = AsyncMock(return_value=dial_response)
777+
778+
connect_count = 0
779+
780+
@asynccontextmanager
781+
async def fake_router(*args, **kwargs):
782+
nonlocal connect_count
783+
connect_count += 1
784+
if connect_count == 1:
785+
raise OSError("Connection refused")
786+
yield
787+
788+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
789+
with patch("jumpstarter.client.lease.connect_router_stream", side_effect=fake_router):
790+
await lease.handle_async(Mock())
791+
792+
assert connect_count == 2
793+
794+
@pytest.mark.anyio
795+
async def test_router_oserror_returns_after_timeout(self):
796+
"""Router should give up on OSError when dial_timeout is exceeded."""
797+
lease = _make_lease_for_handle()
798+
lease.dial_timeout = 0.0 # already expired
799+
dial_response = Mock(router_endpoint="ep", router_token="tok")
800+
lease.controller.Dial = AsyncMock(return_value=dial_response)
801+
802+
@asynccontextmanager
803+
async def fail_router(*args, **kwargs):
804+
raise OSError("Connection refused")
805+
yield # pragma: no cover
806+
807+
with patch("jumpstarter.client.lease.connect_router_stream", side_effect=fail_router):
808+
await lease.handle_async(Mock())
809+
810+
# Only the initial Dial, no retry
811+
assert lease.controller.Dial.call_count == 1
812+
813+
814+
class TestTransientGrpcCodes:
815+
"""Tests for the _TRANSIENT_GRPC_CODES class attribute."""
816+
817+
def test_contains_expected_codes(self):
818+
assert grpc.StatusCode.UNAVAILABLE in Lease._TRANSIENT_GRPC_CODES
819+
assert grpc.StatusCode.RESOURCE_EXHAUSTED in Lease._TRANSIENT_GRPC_CODES
820+
assert grpc.StatusCode.ABORTED in Lease._TRANSIENT_GRPC_CODES
821+
assert grpc.StatusCode.INTERNAL in Lease._TRANSIENT_GRPC_CODES
822+
823+
def test_does_not_contain_non_transient_codes(self):
824+
assert grpc.StatusCode.PERMISSION_DENIED not in Lease._TRANSIENT_GRPC_CODES
825+
assert grpc.StatusCode.NOT_FOUND not in Lease._TRANSIENT_GRPC_CODES
826+
assert grpc.StatusCode.FAILED_PRECONDITION not in Lease._TRANSIENT_GRPC_CODES
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import asyncio
2+
from contextlib import asynccontextmanager
3+
from unittest.mock import AsyncMock, Mock, patch
4+
5+
import grpc
6+
import pytest
7+
from grpc.aio import AioRpcError
8+
9+
from jumpstarter.common.streams import connect_router_stream
10+
11+
12+
class TestConnectRouterStreamChannelReady:
13+
"""Tests for the channel_ready timeout logic in connect_router_stream."""
14+
15+
@pytest.mark.anyio
16+
async def test_raises_unavailable_on_channel_ready_timeout(self):
17+
"""When channel_ready() times out, an AioRpcError with UNAVAILABLE should be raised."""
18+
mock_channel = Mock()
19+
20+
# Make channel_ready() return a coroutine that never completes
21+
async def hang_forever():
22+
await asyncio.sleep(999)
23+
24+
mock_channel.channel_ready = Mock(return_value=hang_forever())
25+
26+
@asynccontextmanager
27+
async def fake_secure_channel(*args, **kwargs):
28+
yield mock_channel
29+
30+
with (
31+
patch("jumpstarter.common.streams.ssl_channel_credentials", new_callable=AsyncMock),
32+
patch("jumpstarter.common.streams.aio_secure_channel", side_effect=fake_secure_channel),
33+
patch("grpc.composite_channel_credentials", return_value=Mock()),
34+
patch("grpc.access_token_call_credentials", return_value=Mock()),
35+
):
36+
with pytest.raises(AioRpcError) as exc_info:
37+
async with connect_router_stream(
38+
"endpoint:443", "token", Mock(), Mock(), {}, channel_ready_timeout=0.01
39+
):
40+
pass # pragma: no cover
41+
42+
assert exc_info.value.code() == grpc.StatusCode.UNAVAILABLE
43+
assert "Timed out" in str(exc_info.value.details())
44+
45+
@pytest.mark.anyio
46+
async def test_proceeds_when_channel_ready_succeeds(self):
47+
"""When channel_ready() succeeds quickly, the stream should be set up normally."""
48+
mock_channel = Mock()
49+
50+
# channel_ready() resolves immediately
51+
async def ready_immediately():
52+
pass
53+
54+
mock_channel.channel_ready = Mock(return_value=ready_immediately())
55+
56+
mock_context = Mock()
57+
58+
@asynccontextmanager
59+
async def fake_secure_channel(*args, **kwargs):
60+
yield mock_channel
61+
62+
@asynccontextmanager
63+
async def fake_router_stream(*args, **kwargs):
64+
yield Mock()
65+
66+
@asynccontextmanager
67+
async def fake_forward(*args, **kwargs):
68+
yield
69+
70+
with (
71+
patch("jumpstarter.common.streams.ssl_channel_credentials", new_callable=AsyncMock),
72+
patch("jumpstarter.common.streams.aio_secure_channel", side_effect=fake_secure_channel),
73+
patch("grpc.composite_channel_credentials", return_value=Mock()),
74+
patch("grpc.access_token_call_credentials", return_value=Mock()),
75+
patch("jumpstarter.common.streams.router_pb2_grpc.RouterServiceStub") as mock_stub_cls,
76+
patch("jumpstarter.common.streams.RouterStream", side_effect=fake_router_stream),
77+
patch("jumpstarter.common.streams.forward_stream", side_effect=fake_forward),
78+
):
79+
mock_stub = Mock()
80+
mock_stub.Stream.return_value = mock_context
81+
mock_stub_cls.return_value = mock_stub
82+
83+
async with connect_router_stream(
84+
"endpoint:443", "token", Mock(), Mock(), {}, channel_ready_timeout=5
85+
):
86+
pass # Successfully entered the context
87+
88+
mock_channel.channel_ready.assert_called_once()

0 commit comments

Comments
 (0)