Repository navigation
improvement(interception): keep tunnels serving with prime-tunnel #3041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
94fd920
improvement(interception): keep tunnels serving with prime-tunnel
kcoopermiller dd2bf28
fix(interception): free retired servers in the elastic pool
kcoopermiller 0cce892
fix(interception): finish freeing a retired server if the rollout is …
kcoopermiller a05ff44
chore(deps): require prime-tunnel 0.1.17
kcoopermiller 7aef005
Merge remote-tracking branch 'origin/main' into improvement/keep-tunn…
kcoopermiller 10ffdd0
Merge remote-tracking branch 'origin/main' into improvement/keep-tunn…
kcoopermiller c81e4f0
improvement(interception): serve the health route at /vf/health
kcoopermiller 6e2dbf9
improvement(interception): explain why a static pool has no servers left
kcoopermiller b60e598
Merge remote-tracking branch 'origin/main' into improvement/keep-tunn…
kcoopermiller File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,107 +1,171 @@ | ||
| """The prime tunnel watchdog restarts frpc when the tunnel service has the tunnel | ||
| disconnected or frpc has exited, leaves it alone otherwise, and stops watching a tunnel | ||
| whose registration is gone.""" | ||
| """A prime tunnel is kept serving by the SDK through the service's health route. A tunnel | ||
| that can never work again retires its server: its rollouts fail and the pools skip it.""" | ||
|
|
||
| import asyncio | ||
| import logging | ||
| from contextlib import asynccontextmanager | ||
| from types import SimpleNamespace | ||
|
|
||
| import httpx | ||
| import pytest | ||
| from prime_tunnel import TunnelGoneError, TunnelStatus | ||
|
|
||
| from prime_tunnel import TunnelGoneError | ||
|
|
||
| from verifiers.v1.configs.interception import ( | ||
| InterceptionServerConfig, | ||
| StaticInterceptionPoolConfig, | ||
| ) | ||
| from verifiers.v1.errors import TunnelError | ||
| from verifiers.v1.interception.pool import ( | ||
| ElasticInterceptionPool, | ||
| StaticInterceptionPool, | ||
| ) | ||
| from verifiers.v1.interception.server import InterceptionServer | ||
| from verifiers.v1.interception.tunnel import prime | ||
|
|
||
| GONE = (None, "terminated", "expired") | ||
| from verifiers.v1.interception.tunnel.base import HEALTH_PATH | ||
|
|
||
|
|
||
| class FakeClient: | ||
| def __init__(self, statuses, running=True, restart_error=None): | ||
| self.statuses = list(statuses) | ||
| self.is_running = running | ||
| self.is_gone = False | ||
| self.tunnel_id = "t-0-test" | ||
| self.restart_error = restart_error | ||
| self.restarts = 0 | ||
| self.checks = 0 | ||
|
|
||
| async def restart(self): | ||
| self.restarts += 1 | ||
| if self.restart_error is not None: | ||
| raise self.restart_error | ||
| self.is_running = True | ||
|
|
||
| async def status(self): | ||
| self.checks += 1 | ||
| registration = self.statuses.pop(0) if self.statuses else "connected" | ||
| if isinstance(registration, Exception): | ||
| raise registration | ||
| self.is_gone = self.is_gone or registration in GONE | ||
| return TunnelStatus( | ||
| tunnel_id=self.tunnel_id, | ||
| running=self.is_running, | ||
| registration=registration, | ||
| gone=self.is_gone, | ||
| gone = False | ||
|
|
||
| def __init__(self, local_port): | ||
| self.health_paths = [] | ||
| self.stops = 0 | ||
| type(self).instance = self | ||
|
|
||
| async def start(self): | ||
| return "https://t.example/" | ||
|
|
||
| async def keep_serving(self, health_path): | ||
| self.health_paths.append(health_path) | ||
| if self.gone: | ||
| raise TunnelGoneError("inactive") | ||
| await asyncio.Future() | ||
|
|
||
| def sync_stop(self): | ||
| self.stops += 1 | ||
|
|
||
|
|
||
| @asynccontextmanager | ||
| async def unlimited(): | ||
| yield | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def tunnel(monkeypatch): | ||
| monkeypatch.setattr(prime, "ensure_prime_auth", lambda: None) | ||
| monkeypatch.setattr(prime, "tunnel_limiter", unlimited) | ||
| monkeypatch.setattr("prime_tunnel.Tunnel", FakeClient) | ||
| return prime.PrimeTunnel() | ||
|
|
||
|
|
||
| async def test_held_tunnel_is_kept_serving_and_stopped_on_exit(tunnel): | ||
| async with tunnel.expose(1234) as url: | ||
| assert url == "https://t.example" | ||
| await asyncio.sleep(0) | ||
| assert FakeClient.instance.health_paths == [HEALTH_PATH] | ||
| assert FakeClient.instance.stops == 0 | ||
| assert FakeClient.instance.stops == 1 | ||
|
|
||
|
|
||
| async def test_gone_tunnel_reports_once_and_is_still_stopped_on_exit( | ||
| tunnel, monkeypatch | ||
| ): | ||
| monkeypatch.setattr(FakeClient, "gone", True) | ||
| reports = [] | ||
| tunnel.on_gone = lambda: reports.append(True) | ||
| async with tunnel.expose(1234): | ||
| await asyncio.sleep(0) | ||
| await asyncio.sleep(0) | ||
| assert reports == [True] | ||
| assert FakeClient.instance.stops == 1 | ||
|
|
||
|
|
||
| async def test_sdk_restarts_reach_the_run_logs(caplog): | ||
| with caplog.at_level(logging.WARNING, logger=prime.logger.name): | ||
| logging.getLogger("prime_tunnel.tunnel").warning( | ||
| "Tunnel %s: frpc restarted", "https://t" | ||
| ) | ||
| assert "tunnel https://t: frpc restarted" in caplog.messages | ||
|
|
||
|
|
||
| async def watch(client, checks, monkeypatch): | ||
| """Run the watchdog for about `checks` checks; how many restarts it made.""" | ||
| monkeypatch.setattr(prime, "CHECK_SECONDS", 0.01) | ||
| task = asyncio.create_task(prime._watch(client, "https://t")) | ||
| await asyncio.sleep(0.0125 * checks) | ||
| task.cancel() | ||
| await asyncio.wait({task}) | ||
| return client.restarts | ||
| async def test_server_answers_the_health_route(): | ||
| async with InterceptionServer() as server, httpx.AsyncClient() as client: | ||
| response = await client.get(f"{server.base_url}{HEALTH_PATH}") | ||
| assert response.status_code == 200 | ||
|
|
||
|
|
||
| async def stops(client, monkeypatch): | ||
| """Run the watchdog until it gives the tunnel up.""" | ||
| monkeypatch.setattr(prime, "CHECK_SECONDS", 0.01) | ||
| await asyncio.wait_for(prime._watch(client, "https://t"), 1) | ||
| def test_gone_tunnel_fails_the_servers_rollouts_and_refuses_new_ones(): | ||
| server = InterceptionServer() | ||
| released = [] | ||
| session = SimpleNamespace(error=None, release=lambda: released.append(True)) | ||
| server.sessions["secret"] = session | ||
|
|
||
| server._handle_tunnel_gone() | ||
|
|
||
| async def test_connected_is_left_alone(monkeypatch): | ||
| assert await watch(FakeClient(["connected"] * 10), 8, monkeypatch) == 0 | ||
| assert isinstance(session.error, TunnelError) | ||
| assert released == [True] | ||
| with pytest.raises(TunnelError): | ||
| server.register(SimpleNamespace()) | ||
|
|
||
|
|
||
| async def test_one_disconnected_check_is_not_enough(monkeypatch): | ||
| client = FakeClient(["disconnected", "connected"] * 5) | ||
| assert await watch(client, 8, monkeypatch) == 0 | ||
| async def test_elastic_pool_replaces_a_retired_server_and_frees_it(): | ||
| async with ElasticInterceptionPool() as pool: | ||
| retired = await pool._warm_task | ||
| health = f"{retired.base_url}{HEALTH_PATH}" | ||
| retired._handle_tunnel_gone() | ||
|
|
||
| replacement = await pool._server() | ||
|
|
||
| async def test_disconnected_twice_restarts_once(monkeypatch): | ||
| client = FakeClient(["disconnected", "disconnected"]) | ||
| assert await watch(client, 8, monkeypatch) == 1 | ||
| assert replacement is not retired | ||
| assert pool.servers == [replacement] | ||
| async with httpx.AsyncClient() as client: | ||
| with pytest.raises(httpx.ConnectError): | ||
| await client.get(health) | ||
|
|
||
|
|
||
| async def test_exited_frpc_restarts_without_asking(monkeypatch): | ||
| client = FakeClient([RuntimeError("api down")] * 10, running=False) | ||
| assert await watch(client, 4, monkeypatch) == 1 | ||
| async def test_elastic_pool_keeps_a_retired_server_until_its_rollouts_leave(): | ||
| async with ElasticInterceptionPool() as pool: | ||
| retired = await pool._warm_task | ||
| retired.sessions["secret"] = SimpleNamespace(error=None, release=lambda: None) | ||
| retired._handle_tunnel_gone() | ||
|
|
||
| replacement = await pool._server() | ||
| assert pool.servers == [retired, replacement] | ||
|
|
||
| async def test_unreachable_service_is_no_verdict(monkeypatch): | ||
| client = FakeClient([RuntimeError("api down")] * 10) | ||
| assert await watch(client, 8, monkeypatch) == 0 | ||
| retired.sessions.clear() | ||
| assert await pool._server() is replacement | ||
| assert pool.servers == [replacement] | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("registration", GONE) | ||
| async def test_gone_registration_stops_watching(registration, monkeypatch): | ||
| client = FakeClient([registration]) | ||
| await stops(client, monkeypatch) | ||
| assert client.restarts == 0 | ||
| async def test_cancelling_a_rollout_does_not_interrupt_freeing_a_retired_server(): | ||
| stopping = asyncio.Event() | ||
| finish = asyncio.Event() | ||
| stopped = [] | ||
|
|
||
| async def stop(): | ||
| stopping.set() | ||
| await finish.wait() | ||
| stopped.append(True) | ||
|
|
||
| async def test_gone_reported_by_frpc_stops_watching(monkeypatch): | ||
| client = FakeClient([]) | ||
| client.is_gone = True | ||
| await stops(client, monkeypatch) | ||
| assert (client.checks, client.restarts) == (0, 0) | ||
| pool = ElasticInterceptionPool() | ||
| pool.servers = [SimpleNamespace(healthy=False, load=0, stop=stop)] | ||
| freeing = asyncio.create_task(pool._free_retired()) | ||
| await asyncio.wait_for(stopping.wait(), 1) | ||
|
|
||
| freeing.cancel() | ||
| await asyncio.sleep(0) | ||
| finish.set() | ||
|
|
||
| async def test_restart_refused_as_gone_stops_watching(monkeypatch): | ||
| client = FakeClient([], running=False, restart_error=TunnelGoneError("inactive")) | ||
| await stops(client, monkeypatch) | ||
| assert client.restarts == 1 | ||
| with pytest.raises(asyncio.CancelledError): | ||
| await freeing | ||
| assert stopped == [True] | ||
|
|
||
|
|
||
| async def test_failed_restart_is_tried_again(monkeypatch): | ||
| client = FakeClient([], running=False, restart_error=RuntimeError("frpc")) | ||
| assert await watch(client, 6, monkeypatch) >= 2 | ||
| async def test_static_pool_with_only_retired_servers_fails_the_rollout(): | ||
| pool = StaticInterceptionPool( | ||
| StaticInterceptionPoolConfig(servers=[InterceptionServerConfig()]) | ||
| ) | ||
| pool.servers[0].healthy = False | ||
| with pytest.raises(TunnelError): | ||
| async with pool.acquire(SimpleNamespace()): | ||
| pass |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.