From e5841e1fa18a318386ecd2d38a0be62bfe3c03cb Mon Sep 17 00:00:00 2001 From: janhavitupe Date: Fri, 3 Apr 2026 10:49:50 +0530 Subject: [PATCH 1/2] Improve tunnel reliability with retries, validation, and structured error handling --- bindu/penguin/bindufy.py | 142 ++++++++++++++++++----- bindu/utils/display.py | 31 +++++ tests/unit/penguin/test_setup_tunnel.py | 148 ++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 tests/unit/penguin/test_setup_tunnel.py diff --git a/bindu/penguin/bindufy.py b/bindu/penguin/bindufy.py index cfb4337ba..ef806b7b4 100644 --- a/bindu/penguin/bindufy.py +++ b/bindu/penguin/bindufy.py @@ -204,55 +204,135 @@ def _register_in_hydra( return credentials +class TunnelError(RuntimeError): + """Raised when tunnel creation fails and fail_on_tunnel_error=True.""" + + +_TUNNEL_MAX_ATTEMPTS = 3 +_TUNNEL_BASE_BACKOFF = 1 # seconds; doubles each retry: 1s, 2s, 4s +_TUNNEL_HEALTH_TIMEOUT = 3 # seconds for health check request + + def _setup_tunnel( tunnel_config: Any, port: int, manifest: AgentManifest, bindu_app: Any, -) -> str | None: - """Set up tunnel if enabled and update URLs. + fail_on_tunnel_error: bool = True, +) -> tuple[str | None, str | None]: + """Set up tunnel if enabled, validate it, and update URLs. + + Retries tunnel creation + health check up to _TUNNEL_MAX_ATTEMPTS times + with exponential backoff (1s, 2s, 4s) before giving up. + + After obtaining a tunnel URL, a GET request is sent to {tunnel_url}/health + (falling back to tunnel_url itself) with a short timeout. A non-200 response + or any request error is treated as a tunnel failure and triggers a retry. Args: tunnel_config: Tunnel configuration port: Local port number manifest: Agent manifest to update bindu_app: Bindu application to update + fail_on_tunnel_error: If True (default), raise TunnelError on failure. + If False, log a warning and continue with local-only server. Returns: - Tunnel URL if successful, None otherwise + Tuple of (tunnel_url, failure_reason). tunnel_url is None on failure or + when tunneling is not requested. failure_reason is set only on failure. + + Raises: + TunnelError: If all retries fail and fail_on_tunnel_error is True. """ + import time + + import httpx + if not (tunnel_config and tunnel_config.enabled): - return None + return None, None from bindu.tunneling.manager import TunnelManager logger.info("Tunnel enabled, creating public URL...") tunnel_config.local_port = port - try: - tunnel_manager = TunnelManager() - tunnel_url = tunnel_manager.create_tunnel( - local_port=port, - config=tunnel_config, - subdomain=tunnel_config.subdomain, - ) - logger.info(f"✅ Tunnel created: {tunnel_url}") - - # Update manifest URL to use tunnel URL - manifest.url = tunnel_url + tunnel_manager = TunnelManager() + failure_reason = "Unknown error" - # Update BinduApplication URL to use tunnel URL - bindu_app.url = tunnel_url - - # Invalidate cached agent card so it gets regenerated with new URL - bindu_app._agent_card_json_schema = None + for attempt in range(1, _TUNNEL_MAX_ATTEMPTS + 1): + try: + tunnel_url = tunnel_manager.create_tunnel( + local_port=port, + config=tunnel_config, + subdomain=tunnel_config.subdomain, + ) + logger.info( + f"Tunnel URL obtained (attempt {attempt}/{_TUNNEL_MAX_ATTEMPTS}): " + f"{tunnel_url} — validating..." + ) - return tunnel_url + # Health check: prefer /health, fall back to root + health_url = f"{tunnel_url.rstrip('/')}/health" + root_url = tunnel_url + + try: + response = httpx.get(health_url, timeout=_TUNNEL_HEALTH_TIMEOUT, follow_redirects=True) + if response.status_code != 200: + raise ValueError(f"Health check returned HTTP {response.status_code} from {health_url}") + except (httpx.RequestError, ValueError): + # Fallback to root URL + try: + response = httpx.get(root_url, timeout=_TUNNEL_HEALTH_TIMEOUT, follow_redirects=True) + if response.status_code != 200: + raise ValueError(f"Health check returned HTTP {response.status_code} from {root_url}") + except httpx.RequestError as exc: + raise ValueError(f"Health check requests failed for both /health and root: {exc}") from exc + + logger.info(f"✅ Tunnel created and validated: {tunnel_url}") + + manifest.url = tunnel_url + bindu_app.url = tunnel_url + bindu_app._agent_card_json_schema = None + + return tunnel_url, None + + except Exception as e: + failure_reason = str(e) + tunnel_manager.stop_tunnel() # Cleanup failed tunnel + if attempt < _TUNNEL_MAX_ATTEMPTS: + backoff = _TUNNEL_BASE_BACKOFF * (2 ** (attempt - 1)) + logger.info( + f"Tunnel attempt {attempt}/{_TUNNEL_MAX_ATTEMPTS} failed " + f"({failure_reason}). Retrying in {backoff}s..." + ) + time.sleep(backoff) + else: + logger.error( + f"Tunnel attempt {attempt}/{_TUNNEL_MAX_ATTEMPTS} failed " + f"({failure_reason}). All retries exhausted." + ) + + # Cleanup any remaining tunnel after all attempts + tunnel_manager.stop_tunnel() + + if fail_on_tunnel_error: + raise TunnelError( + f"Tunnel creation failed after {_TUNNEL_MAX_ATTEMPTS} attempts: {failure_reason}\n" + "Your agent was NOT started. To start without a public URL, " + "set fail_on_tunnel_error=False or remove launch=True." + ) - except Exception as e: - logger.error(f"Failed to create tunnel: {e}") - logger.warning("Continuing with local-only server...") - return None + logger.warning( + "=" * 60 + "\n" + "⚠️ TUNNEL FAILURE — AGENT IS NOT PUBLICLY ACCESSIBLE\n" + f" Reason : {failure_reason}\n" + f" Attempts : {_TUNNEL_MAX_ATTEMPTS}/{_TUNNEL_MAX_ATTEMPTS} exhausted\n" + " Status : Running on LOCAL network only\n" + " Action : Check tunnel config, network, or remove launch=True\n" + " Suppress : Pass fail_on_tunnel_error=False to bindufy() to silence this\n" + + "=" * 60 + ) + return None, failure_reason def _create_telemetry_config(validated_config: Dict[str, Any]) -> TelemetryConfig: @@ -354,6 +434,7 @@ def _bindufy_core( skills_override: list | None = None, skip_handler_validation: bool = False, run_server_in_background: bool = False, + fail_on_tunnel_error: bool = True, ) -> AgentManifest: """Core bindufy logic shared by both Python and gRPC registration paths. @@ -377,6 +458,8 @@ def _bindufy_core( Used for gRPC path where handler is a GrpcAgentClient. run_server_in_background: If True, start uvicorn in a background thread instead of blocking. Used by gRPC service so RegisterAgent can return. + fail_on_tunnel_error: If True (default), raise TunnelError when tunnel + creation fails. If False, log a warning and continue locally. Returns: AgentManifest: The manifest for the bindufied agent. @@ -568,7 +651,7 @@ def _bindufy_core( host, port = _parse_deployment_url(deployment_config) # Create tunnel if enabled - tunnel_url = _setup_tunnel(tunnel_config, port, _manifest, bindu_app) + tunnel_url, tunnel_failure_reason = _setup_tunnel(tunnel_config, port, _manifest, bindu_app, fail_on_tunnel_error) # Start server if requested if run_server: @@ -581,6 +664,8 @@ def _bindufy_core( client_id=credentials.client_id if credentials else None, client_secret=credentials.client_secret if credentials else None, tunnel_url=tunnel_url, + tunnel_requested=launch, + tunnel_failure_reason=tunnel_failure_reason, ) if run_server_in_background: @@ -613,6 +698,7 @@ def bindufy( run_server: bool = True, key_dir: str | Path | None = None, launch: bool = False, + fail_on_tunnel_error: bool = True, ) -> AgentManifest: """Transform an agent handler into a Bindu microservice. @@ -653,6 +739,9 @@ def bindufy( directory (may fail in REPL/notebooks). Falls back to current working directory. launch: If True, creates a public tunnel via FRP to expose the server to the internet with an auto-generated subdomain (default: False) + fail_on_tunnel_error: If True (default), raise TunnelError when tunnel creation fails + so the agent does not silently start without a public URL. Set to False to + fall back to local-only with a warning instead (default: True) Returns: AgentManifest: The manifest for the bindufied agent @@ -692,4 +781,5 @@ def my_handler(messages: list[dict[str, str]]) -> str: key_dir=key_dir, launch=launch, caller_dir=caller_dir, + fail_on_tunnel_error=fail_on_tunnel_error, ) diff --git a/bindu/utils/display.py b/bindu/utils/display.py index bdd3410cc..cf1989efc 100644 --- a/bindu/utils/display.py +++ b/bindu/utils/display.py @@ -20,6 +20,8 @@ def prepare_server_display( client_id: str | None = None, client_secret: str | None = None, tunnel_url: str | None = None, + tunnel_requested: bool = False, + tunnel_failure_reason: str | None = None, ) -> None: """Prepare a beautiful display for the server using rich. @@ -31,6 +33,8 @@ def prepare_server_display( client_id: OAuth client ID for token retrieval client_secret: OAuth client secret for token retrieval tunnel_url: Public tunnel URL if tunneling is enabled + tunnel_requested: Whether launch=True was passed (used to show failure warning) + tunnel_failure_reason: Reason tunnel creation failed, if applicable """ # Force UTF-8 output on Windows to avoid cp1252 UnicodeEncodeError when # rich renders emoji (e.g. in Panel titles). reconfigure() changes the @@ -129,6 +133,33 @@ def prepare_server_display( highlight=False, ) + # Display tunnel failure warning if launch was requested but failed + if tunnel_requested and not tunnel_url and tunnel_failure_reason: + warning_lines = Text() + warning_lines.append("⚠️ TUNNEL FAILED\n", style="bold red") + warning_lines.append("Agent is running on LOCAL network only.\n", style="red") + warning_lines.append("It is NOT publicly accessible.\n\n", style="red") + warning_lines.append("Reason: ", style="bold yellow") + warning_lines.append(f"{tunnel_failure_reason}\n\n", style="yellow") + warning_lines.append("Fix: ", style="bold white") + warning_lines.append( + "Check tunnel config/network, or remove launch=True.\n", + style="white", + ) + warning_lines.append("Suppress: ", style="bold white") + warning_lines.append( + "Pass fail_on_tunnel_error=False to bindufy() to continue locally without this warning.", + style="white", + ) + console.print( + Panel( + warning_lines, + title="[bold red]TUNNEL FAILURE[/bold red]", + border_style="red", + padding=(1, 2), + ) + ) + console.print() if agent_id: diff --git a/tests/unit/penguin/test_setup_tunnel.py b/tests/unit/penguin/test_setup_tunnel.py new file mode 100644 index 000000000..9398062f8 --- /dev/null +++ b/tests/unit/penguin/test_setup_tunnel.py @@ -0,0 +1,148 @@ +"""Tests for _setup_tunnel retry logic and failure handling.""" + +from unittest.mock import MagicMock, patch, call +import pytest + +from bindu.penguin.bindufy import TunnelError, _setup_tunnel, _TUNNEL_MAX_ATTEMPTS + + +def _make_tunnel_config(): + cfg = MagicMock() + cfg.enabled = True + cfg.subdomain = None + return cfg + + +def _make_app_and_manifest(): + manifest = MagicMock() + manifest.url = "http://localhost:3773" + app = MagicMock() + app.url = "http://localhost:3773" + app._agent_card_json_schema = None + return manifest, app + + +# --------------------------------------------------------------------------- +# Retry succeeds on second attempt +# --------------------------------------------------------------------------- + +class TestTunnelRetrySuccess: + @patch("bindu.penguin.bindufy.TunnelManager") + @patch("httpx.get") + @patch("time.sleep") + def test_succeeds_on_second_attempt(self, mock_sleep, mock_httpx_get, mock_tm_cls): + """Tunnel fails once then succeeds — should return URL with one retry.""" + tunnel_url = "https://abc.tunnel.example.com" + + mock_manager = mock_tm_cls.return_value + mock_manager.create_tunnel.side_effect = [ + RuntimeError("connection refused"), # attempt 1 fails + tunnel_url, # attempt 2 succeeds + ] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_httpx_get.return_value = mock_response + + manifest, app = _make_app_and_manifest() + cfg = _make_tunnel_config() + + result_url, reason = _setup_tunnel(cfg, 3773, manifest, app, fail_on_tunnel_error=True) + + assert result_url == tunnel_url + assert reason is None + assert mock_manager.create_tunnel.call_count == 2 + mock_sleep.assert_called_once_with(1) # backoff after attempt 1 + + +# --------------------------------------------------------------------------- +# All retries fail — hard error +# --------------------------------------------------------------------------- + +class TestTunnelAllFailHardError: + @patch("bindu.penguin.bindufy.TunnelManager") + @patch("time.sleep") + def test_raises_tunnel_error_when_fail_on_error_true(self, mock_sleep, mock_tm_cls): + """All retries exhausted with fail_on_tunnel_error=True → TunnelError raised.""" + mock_manager = mock_tm_cls.return_value + mock_manager.create_tunnel.side_effect = RuntimeError("server unreachable") + + manifest, app = _make_app_and_manifest() + cfg = _make_tunnel_config() + + with pytest.raises(TunnelError) as exc_info: + _setup_tunnel(cfg, 3773, manifest, app, fail_on_tunnel_error=True) + + assert "server unreachable" in str(exc_info.value) + assert mock_manager.create_tunnel.call_count == _TUNNEL_MAX_ATTEMPTS + assert mock_sleep.call_count == _TUNNEL_MAX_ATTEMPTS - 1 # no sleep after last attempt + + +# --------------------------------------------------------------------------- +# All retries fail — soft fallback +# --------------------------------------------------------------------------- + +class TestTunnelAllFailSoftFallback: + @patch("bindu.penguin.bindufy.TunnelManager") + @patch("time.sleep") + def test_returns_none_when_fail_on_error_false(self, mock_sleep, mock_tm_cls): + """All retries exhausted with fail_on_tunnel_error=False → (None, reason) returned.""" + mock_manager = mock_tm_cls.return_value + mock_manager.create_tunnel.side_effect = RuntimeError("timeout") + + manifest, app = _make_app_and_manifest() + cfg = _make_tunnel_config() + + result_url, reason = _setup_tunnel(cfg, 3773, manifest, app, fail_on_tunnel_error=False) + + assert result_url is None + assert "timeout" in reason + assert mock_manager.create_tunnel.call_count == _TUNNEL_MAX_ATTEMPTS + + +# --------------------------------------------------------------------------- +# Health check failure triggers retry +# --------------------------------------------------------------------------- + +class TestTunnelHealthCheckFailure: + @patch("bindu.penguin.bindufy.TunnelManager") + @patch("httpx.get") + @patch("time.sleep") + def test_bad_health_status_triggers_retry(self, mock_sleep, mock_httpx_get, mock_tm_cls): + """Health check returning non-200 should count as a failed attempt.""" + tunnel_url = "https://abc.tunnel.example.com" + + mock_manager = mock_tm_cls.return_value + mock_manager.create_tunnel.return_value = tunnel_url + + bad_response = MagicMock() + bad_response.status_code = 502 + good_response = MagicMock() + good_response.status_code = 200 + + mock_httpx_get.side_effect = [bad_response, bad_response, bad_response, bad_response, bad_response, good_response] + + manifest, app = _make_app_and_manifest() + cfg = _make_tunnel_config() + + result_url, reason = _setup_tunnel(cfg, 3773, manifest, app, fail_on_tunnel_error=True) + + assert result_url == tunnel_url + assert reason is None + assert mock_httpx_get.call_count == 6 + + +# --------------------------------------------------------------------------- +# Tunnel disabled — no-op +# --------------------------------------------------------------------------- + +class TestTunnelDisabled: + def test_returns_none_when_not_enabled(self): + cfg = MagicMock() + cfg.enabled = False + manifest, app = _make_app_and_manifest() + + result_url, reason = _setup_tunnel(cfg, 3773, manifest, app) + + assert result_url is None + assert reason is None From ebfff2682a8b958a29146b05479e5efe2fffc7bd Mon Sep 17 00:00:00 2001 From: janhavitupe Date: Fri, 3 Apr 2026 14:18:30 +0530 Subject: [PATCH 2/2] Address review feedback: move tunnel config to settings, fix health check timing, update messaging, and correct test patching --- bindu/penguin/bindufy.py | 92 ++++++++++++------------- bindu/settings.py | 17 +++++ bindu/utils/display.py | 4 +- tests/unit/penguin/test_setup_tunnel.py | 63 +++++------------ 4 files changed, 80 insertions(+), 96 deletions(-) diff --git a/bindu/penguin/bindufy.py b/bindu/penguin/bindufy.py index ef806b7b4..df10831e0 100644 --- a/bindu/penguin/bindufy.py +++ b/bindu/penguin/bindufy.py @@ -208,11 +208,6 @@ class TunnelError(RuntimeError): """Raised when tunnel creation fails and fail_on_tunnel_error=True.""" -_TUNNEL_MAX_ATTEMPTS = 3 -_TUNNEL_BASE_BACKOFF = 1 # seconds; doubles each retry: 1s, 2s, 4s -_TUNNEL_HEALTH_TIMEOUT = 3 # seconds for health check request - - def _setup_tunnel( tunnel_config: Any, port: int, @@ -220,14 +215,12 @@ def _setup_tunnel( bindu_app: Any, fail_on_tunnel_error: bool = True, ) -> tuple[str | None, str | None]: - """Set up tunnel if enabled, validate it, and update URLs. - - Retries tunnel creation + health check up to _TUNNEL_MAX_ATTEMPTS times - with exponential backoff (1s, 2s, 4s) before giving up. + """Set up tunnel if enabled and update URLs. - After obtaining a tunnel URL, a GET request is sent to {tunnel_url}/health - (falling back to tunnel_url itself) with a short timeout. A non-200 response - or any request error is treated as a tunnel failure and triggers a retry. + Retries tunnel creation up to app_settings.tunnel.max_attempts times with + exponential backoff before giving up. Health validation is intentionally + deferred — it is performed separately after the HTTP server is accepting + requests (see _validate_tunnel_health). Args: tunnel_config: Tunnel configuration @@ -246,20 +239,21 @@ def _setup_tunnel( """ import time - import httpx - if not (tunnel_config and tunnel_config.enabled): return None, None from bindu.tunneling.manager import TunnelManager + max_attempts = app_settings.tunnel.max_attempts + base_backoff = app_settings.tunnel.base_backoff_seconds + logger.info("Tunnel enabled, creating public URL...") tunnel_config.local_port = port tunnel_manager = TunnelManager() failure_reason = "Unknown error" - for attempt in range(1, _TUNNEL_MAX_ATTEMPTS + 1): + for attempt in range(1, max_attempts + 1): try: tunnel_url = tunnel_manager.create_tunnel( local_port=port, @@ -267,29 +261,9 @@ def _setup_tunnel( subdomain=tunnel_config.subdomain, ) logger.info( - f"Tunnel URL obtained (attempt {attempt}/{_TUNNEL_MAX_ATTEMPTS}): " - f"{tunnel_url} — validating..." + f"✅ Tunnel created (attempt {attempt}/{max_attempts}): {tunnel_url}" ) - # Health check: prefer /health, fall back to root - health_url = f"{tunnel_url.rstrip('/')}/health" - root_url = tunnel_url - - try: - response = httpx.get(health_url, timeout=_TUNNEL_HEALTH_TIMEOUT, follow_redirects=True) - if response.status_code != 200: - raise ValueError(f"Health check returned HTTP {response.status_code} from {health_url}") - except (httpx.RequestError, ValueError): - # Fallback to root URL - try: - response = httpx.get(root_url, timeout=_TUNNEL_HEALTH_TIMEOUT, follow_redirects=True) - if response.status_code != 200: - raise ValueError(f"Health check returned HTTP {response.status_code} from {root_url}") - except httpx.RequestError as exc: - raise ValueError(f"Health check requests failed for both /health and root: {exc}") from exc - - logger.info(f"✅ Tunnel created and validated: {tunnel_url}") - manifest.url = tunnel_url bindu_app.url = tunnel_url bindu_app._agent_card_json_schema = None @@ -298,26 +272,22 @@ def _setup_tunnel( except Exception as e: failure_reason = str(e) - tunnel_manager.stop_tunnel() # Cleanup failed tunnel - if attempt < _TUNNEL_MAX_ATTEMPTS: - backoff = _TUNNEL_BASE_BACKOFF * (2 ** (attempt - 1)) + if attempt < max_attempts: + backoff = base_backoff * (2 ** (attempt - 1)) logger.info( - f"Tunnel attempt {attempt}/{_TUNNEL_MAX_ATTEMPTS} failed " + f"Tunnel attempt {attempt}/{max_attempts} failed " f"({failure_reason}). Retrying in {backoff}s..." ) time.sleep(backoff) else: logger.error( - f"Tunnel attempt {attempt}/{_TUNNEL_MAX_ATTEMPTS} failed " + f"Tunnel attempt {attempt}/{max_attempts} failed " f"({failure_reason}). All retries exhausted." ) - # Cleanup any remaining tunnel after all attempts - tunnel_manager.stop_tunnel() - if fail_on_tunnel_error: raise TunnelError( - f"Tunnel creation failed after {_TUNNEL_MAX_ATTEMPTS} attempts: {failure_reason}\n" + f"Tunnel creation failed after {max_attempts} attempts: {failure_reason}\n" "Your agent was NOT started. To start without a public URL, " "set fail_on_tunnel_error=False or remove launch=True." ) @@ -326,15 +296,43 @@ def _setup_tunnel( "=" * 60 + "\n" "⚠️ TUNNEL FAILURE — AGENT IS NOT PUBLICLY ACCESSIBLE\n" f" Reason : {failure_reason}\n" - f" Attempts : {_TUNNEL_MAX_ATTEMPTS}/{_TUNNEL_MAX_ATTEMPTS} exhausted\n" + f" Attempts : {max_attempts}/{max_attempts} exhausted\n" " Status : Running on LOCAL network only\n" " Action : Check tunnel config, network, or remove launch=True\n" - " Suppress : Pass fail_on_tunnel_error=False to bindufy() to silence this\n" + " Note : fail_on_tunnel_error=False allows local fallback on tunnel failure\n" + "=" * 60 ) return None, failure_reason +def _validate_tunnel_health(tunnel_url: str) -> str | None: + """Validate a tunnel URL by sending a health check request. + + This is called AFTER the HTTP server is already accepting requests so the + health endpoint actually exists. Separating creation from validation avoids + the race condition where the server hasn't started yet. + + Args: + tunnel_url: The public tunnel URL to validate. + + Returns: + None if healthy, or a failure reason string if the check fails. + """ + import httpx + + health_timeout = app_settings.tunnel.health_check_timeout_seconds + health_url = f"{tunnel_url.rstrip('/')}/health" + + try: + response = httpx.get(health_url, timeout=health_timeout, follow_redirects=True) + if response.status_code != 200: + return f"Health check returned HTTP {response.status_code} from {health_url}" + logger.info(f"✅ Tunnel health check passed: {health_url}") + return None + except httpx.RequestError as exc: + return f"Health check request failed: {exc}" + + def _create_telemetry_config(validated_config: Dict[str, Any]) -> TelemetryConfig: """Create telemetry configuration from validated config. diff --git a/bindu/settings.py b/bindu/settings.py index 9a2c8d1fc..ef5475a03 100644 --- a/bindu/settings.py +++ b/bindu/settings.py @@ -147,6 +147,23 @@ class TunnelSettings(BaseSettings): # FRP client version frpc_version: str = "0.61.0" + # Retry configuration + max_attempts: int = Field( + default=3, + validation_alias=AliasChoices("TUNNEL__MAX_ATTEMPTS"), + description="Maximum tunnel creation attempts before giving up", + ) + base_backoff_seconds: int = Field( + default=1, + validation_alias=AliasChoices("TUNNEL__BASE_BACKOFF_SECONDS"), + description="Base backoff in seconds; doubles each retry (1s, 2s, 4s)", + ) + health_check_timeout_seconds: int = Field( + default=3, + validation_alias=AliasChoices("TUNNEL__HEALTH_CHECK_TIMEOUT_SECONDS"), + description="Timeout in seconds for the post-creation health check request", + ) + class DeploymentSettings(BaseSettings): """Deployment and server configuration settings.""" diff --git a/bindu/utils/display.py b/bindu/utils/display.py index cf1989efc..4870558df 100644 --- a/bindu/utils/display.py +++ b/bindu/utils/display.py @@ -146,9 +146,9 @@ def prepare_server_display( "Check tunnel config/network, or remove launch=True.\n", style="white", ) - warning_lines.append("Suppress: ", style="bold white") + warning_lines.append("Fallback: ", style="bold white") warning_lines.append( - "Pass fail_on_tunnel_error=False to bindufy() to continue locally without this warning.", + "Pass fail_on_tunnel_error=False to bindufy() to continue locally when tunnel fails (instead of raising an error).", style="white", ) console.print( diff --git a/tests/unit/penguin/test_setup_tunnel.py b/tests/unit/penguin/test_setup_tunnel.py index 9398062f8..1af633c00 100644 --- a/tests/unit/penguin/test_setup_tunnel.py +++ b/tests/unit/penguin/test_setup_tunnel.py @@ -1,9 +1,12 @@ """Tests for _setup_tunnel retry logic and failure handling.""" -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch import pytest -from bindu.penguin.bindufy import TunnelError, _setup_tunnel, _TUNNEL_MAX_ATTEMPTS +from bindu.penguin.bindufy import TunnelError, _setup_tunnel +from bindu.settings import app_settings + +MAX_ATTEMPTS = app_settings.tunnel.max_attempts def _make_tunnel_config(): @@ -27,10 +30,9 @@ def _make_app_and_manifest(): # --------------------------------------------------------------------------- class TestTunnelRetrySuccess: - @patch("bindu.penguin.bindufy.TunnelManager") - @patch("httpx.get") + @patch("bindu.tunneling.manager.TunnelManager") @patch("time.sleep") - def test_succeeds_on_second_attempt(self, mock_sleep, mock_httpx_get, mock_tm_cls): + def test_succeeds_on_second_attempt(self, mock_sleep, mock_tm_cls): """Tunnel fails once then succeeds — should return URL with one retry.""" tunnel_url = "https://abc.tunnel.example.com" @@ -40,10 +42,6 @@ def test_succeeds_on_second_attempt(self, mock_sleep, mock_httpx_get, mock_tm_cl tunnel_url, # attempt 2 succeeds ] - mock_response = MagicMock() - mock_response.status_code = 200 - mock_httpx_get.return_value = mock_response - manifest, app = _make_app_and_manifest() cfg = _make_tunnel_config() @@ -52,7 +50,9 @@ def test_succeeds_on_second_attempt(self, mock_sleep, mock_httpx_get, mock_tm_cl assert result_url == tunnel_url assert reason is None assert mock_manager.create_tunnel.call_count == 2 - mock_sleep.assert_called_once_with(1) # backoff after attempt 1 + mock_sleep.assert_called_once_with( + app_settings.tunnel.base_backoff_seconds + ) # --------------------------------------------------------------------------- @@ -60,7 +60,7 @@ def test_succeeds_on_second_attempt(self, mock_sleep, mock_httpx_get, mock_tm_cl # --------------------------------------------------------------------------- class TestTunnelAllFailHardError: - @patch("bindu.penguin.bindufy.TunnelManager") + @patch("bindu.tunneling.manager.TunnelManager") @patch("time.sleep") def test_raises_tunnel_error_when_fail_on_error_true(self, mock_sleep, mock_tm_cls): """All retries exhausted with fail_on_tunnel_error=True → TunnelError raised.""" @@ -74,8 +74,9 @@ def test_raises_tunnel_error_when_fail_on_error_true(self, mock_sleep, mock_tm_c _setup_tunnel(cfg, 3773, manifest, app, fail_on_tunnel_error=True) assert "server unreachable" in str(exc_info.value) - assert mock_manager.create_tunnel.call_count == _TUNNEL_MAX_ATTEMPTS - assert mock_sleep.call_count == _TUNNEL_MAX_ATTEMPTS - 1 # no sleep after last attempt + assert mock_manager.create_tunnel.call_count == MAX_ATTEMPTS + # No sleep after the final attempt + assert mock_sleep.call_count == MAX_ATTEMPTS - 1 # --------------------------------------------------------------------------- @@ -83,7 +84,7 @@ def test_raises_tunnel_error_when_fail_on_error_true(self, mock_sleep, mock_tm_c # --------------------------------------------------------------------------- class TestTunnelAllFailSoftFallback: - @patch("bindu.penguin.bindufy.TunnelManager") + @patch("bindu.tunneling.manager.TunnelManager") @patch("time.sleep") def test_returns_none_when_fail_on_error_false(self, mock_sleep, mock_tm_cls): """All retries exhausted with fail_on_tunnel_error=False → (None, reason) returned.""" @@ -97,39 +98,7 @@ def test_returns_none_when_fail_on_error_false(self, mock_sleep, mock_tm_cls): assert result_url is None assert "timeout" in reason - assert mock_manager.create_tunnel.call_count == _TUNNEL_MAX_ATTEMPTS - - -# --------------------------------------------------------------------------- -# Health check failure triggers retry -# --------------------------------------------------------------------------- - -class TestTunnelHealthCheckFailure: - @patch("bindu.penguin.bindufy.TunnelManager") - @patch("httpx.get") - @patch("time.sleep") - def test_bad_health_status_triggers_retry(self, mock_sleep, mock_httpx_get, mock_tm_cls): - """Health check returning non-200 should count as a failed attempt.""" - tunnel_url = "https://abc.tunnel.example.com" - - mock_manager = mock_tm_cls.return_value - mock_manager.create_tunnel.return_value = tunnel_url - - bad_response = MagicMock() - bad_response.status_code = 502 - good_response = MagicMock() - good_response.status_code = 200 - - mock_httpx_get.side_effect = [bad_response, bad_response, bad_response, bad_response, bad_response, good_response] - - manifest, app = _make_app_and_manifest() - cfg = _make_tunnel_config() - - result_url, reason = _setup_tunnel(cfg, 3773, manifest, app, fail_on_tunnel_error=True) - - assert result_url == tunnel_url - assert reason is None - assert mock_httpx_get.call_count == 6 + assert mock_manager.create_tunnel.call_count == MAX_ATTEMPTS # ---------------------------------------------------------------------------