Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 109 additions & 21 deletions bindu/penguin/bindufy.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,55 +204,133 @@ def _register_in_hydra(
return credentials


class TunnelError(RuntimeError):
"""Raised when tunnel creation fails and fail_on_tunnel_error=True."""


def _setup_tunnel(
tunnel_config: Any,
port: int,
manifest: AgentManifest,
bindu_app: Any,
) -> str | None:
fail_on_tunnel_error: bool = True,
) -> tuple[str | None, str | None]:
"""Set up tunnel if enabled and update URLs.

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).
Comment on lines +220 to +223
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

The deferred health check never actually runs.

The docstring says validation happens later, but _bindufy_core() never calls _validate_tunnel_health() after _setup_tunnel() returns. An unreachable or 503 tunnel is therefore still passed to prepare_server_display() and treated as public, so health-check-triggered retries, TunnelError, and the fallback warning never execute. Downstream, prepare_server_display() only renders the warning panel when tunnel_url is None and tunnel_failure_reason is set (bindu/utils/display.py:136-161), so this path still presents the agent as publicly reachable. This also makes run_server=False + launch=True look successful even though nothing is listening behind the tunnel. Call the validator after the HTTP server reaches readiness and before displaying or returning the public URL.

Also applies to: 308-333, 651-685

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bindu/penguin/bindufy.py` around lines 220 - 223, _bindufy_core() currently
never invokes _validate_tunnel_health() after _setup_tunnel(), so a dead or 503
tunnel can be treated as public; update _bindufy_core() to call
_validate_tunnel_health() once the HTTP server reports readiness and before
calling prepare_server_display() or returning the tunnel URL, and ensure that
any TunnelError or retry logic from _validate_tunnel_health() is handled
(falling back to setting tunnel_url=None and tunnel_failure_reason) so
prepare_server_display() will render the warning panel; apply the same insertion
point for the other similar paths that call _setup_tunnel() (the blocks
referenced around the other ranges) so health validation always runs after
server readiness and before exposing the tunnel URL.


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

if not (tunnel_config and tunnel_config.enabled):
return None
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

try:
tunnel_manager = TunnelManager()
tunnel_url = tunnel_manager.create_tunnel(
local_port=port,
config=tunnel_config,
subdomain=tunnel_config.subdomain,
tunnel_manager = TunnelManager()
failure_reason = "Unknown error"

for attempt in range(1, max_attempts + 1):
try:
tunnel_url = tunnel_manager.create_tunnel(
local_port=port,
config=tunnel_config,
subdomain=tunnel_config.subdomain,
)
logger.info(
f"✅ Tunnel created (attempt {attempt}/{max_attempts}): {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)
if attempt < max_attempts:
backoff = base_backoff * (2 ** (attempt - 1))
logger.info(
f"Tunnel attempt {attempt}/{max_attempts} failed "
f"({failure_reason}). Retrying in {backoff}s..."
)
time.sleep(backoff)
else:
logger.error(
f"Tunnel attempt {attempt}/{max_attempts} failed "
f"({failure_reason}). All retries exhausted."
)
Comment on lines +256 to +286
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "bindufy.py" -type f

Repository: GetBindu/Bindu

Length of output: 84


🏁 Script executed:

cat -n ./bindu/penguin/bindufy.py | sed -n '250,290p'

Repository: GetBindu/Bindu

Length of output: 1797


🏁 Script executed:

cat -n ./bindu/penguin/bindufy.py | sed -n '200,260p'

Repository: GetBindu/Bindu

Length of output: 2482


🏁 Script executed:

rg -t py "class AgentManifest" --max-count=5

Repository: GetBindu/Bindu

Length of output: 101


🏁 Script executed:

cat -n ./bindu/common/models.py | grep -A 50 "class AgentManifest:"

Repository: GetBindu/Bindu

Length of output: 2076


🏁 Script executed:

cat -n ./bindu/common/models.py | sed -n '145,175p'

Repository: GetBindu/Bindu

Length of output: 1104


Restructure exception handling to avoid creating duplicate tunnels.

The current code wraps both TunnelManager.create_tunnel() and the subsequent manifest/app mutations in the same try-except block. If the tunnel is successfully allocated but a mutation fails (e.g., manifest.url = tunnel_url), the retry loop creates another tunnel and misattributes a local bug to a transient tunnel failure. Move the mutations outside the retry block or into a separate try-except that doesn't trigger another tunnel creation.

🧰 Tools
🪛 Ruff (0.15.7)

[warning] 273-273: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bindu/penguin/bindufy.py` around lines 256 - 286, The try-except currently
covers both tunnel allocation and the post-allocation mutations, causing a new
tunnel to be created if a mutation (e.g., setting manifest.url or bindu_app.url
or resetting bindu_app._agent_card_json_schema) fails; refactor so
tunnel_manager.create_tunnel(...) is the only call inside the retry loop (the
block that retries on transient create failures), then after a successful
create_tunnel returns (tunnel_url) perform the mutations (manifest.url =
tunnel_url, bindu_app.url = tunnel_url, bindu_app._agent_card_json_schema =
None) outside that retry loop or inside a separate try-except that does not
re-enter the create_tunnel retry logic, and ensure only create_tunnel failures
trigger the exponential backoff / retry logic.


if fail_on_tunnel_error:
raise TunnelError(
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."
)
logger.info(f"✅ Tunnel created: {tunnel_url}")

# Update manifest URL to use tunnel URL
manifest.url = tunnel_url
logger.warning(
"=" * 60 + "\n"
"⚠️ TUNNEL FAILURE — AGENT IS NOT PUBLICLY ACCESSIBLE\n"
f" Reason : {failure_reason}\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"
" 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.

# Update BinduApplication URL to use tunnel URL
bindu_app.url = tunnel_url
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.

# Invalidate cached agent card so it gets regenerated with new URL
bindu_app._agent_card_json_schema = None
Args:
tunnel_url: The public tunnel URL to validate.

return tunnel_url
Returns:
None if healthy, or a failure reason string if the check fails.
"""
import httpx

except Exception as e:
logger.error(f"Failed to create tunnel: {e}")
logger.warning("Continuing with local-only server...")
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:
Expand Down Expand Up @@ -354,6 +432,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.

Expand All @@ -377,6 +456,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.
Expand Down Expand Up @@ -568,7 +649,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)

Comment on lines 651 to 653
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Tunnel validation runs before the server exists.

_setup_tunnel() now performs live httpx.get() checks, but this call still happens before start_uvicorn_server() is invoked. With launch=True, the health check is probing a server that isn't listening yet, so otherwise-valid tunnels will be retried as failures and the default fail_on_tunnel_error=True path will abort startup. This also makes run_server=False + launch=True unsatisfiable. Split tunnel creation from validation, or defer validation until the HTTP server is accepting requests.

Also applies to: 657-686

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bindu/penguin/bindufy.py` around lines 653 - 655, The tunnel creation
currently calls _setup_tunnel (which performs live httpx.get() checks) before
start_uvicorn_server, causing premature health checks; change the flow so
_setup_tunnel only establishes/returns the tunnel endpoint without doing live
HTTP probes (e.g., add a parameter validate=False or split out a new
validate_tunnel(tunnel_url) function), then call the validation step only after
start_uvicorn_server has started accepting requests (or when run_server is False
but launch=True handle validation accordingly); update callers around the
existing _setup_tunnel call and the code paths noted (including the block
covering lines 657-686) to perform validation after server start and respect
fail_on_tunnel_error semantics.

# Start server if requested
if run_server:
Expand All @@ -581,6 +662,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:
Expand Down Expand Up @@ -613,6 +696,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.

Expand Down Expand Up @@ -653,6 +737,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
Expand Down Expand Up @@ -692,4 +779,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,
)
17 changes: 17 additions & 0 deletions bindu/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Comment on lines +151 to +165
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the retry knobs when settings load.

These values come from env, but invalid inputs fail late inside the tunnel path: max_attempts=0 skips all attempts, base_backoff_seconds<0 makes time.sleep() raise, and health_check_timeout_seconds<=0 turns the health check into a config/runtime error instead of a clean tunnel failure. Add bounds here so bad config is rejected during settings parsing.

Suggested bounds
     max_attempts: int = Field(
         default=3,
+        ge=1,
         validation_alias=AliasChoices("TUNNEL__MAX_ATTEMPTS"),
         description="Maximum tunnel creation attempts before giving up",
     )
     base_backoff_seconds: int = Field(
         default=1,
+        ge=0,
         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,
+        gt=0,
         validation_alias=AliasChoices("TUNNEL__HEALTH_CHECK_TIMEOUT_SECONDS"),
         description="Timeout in seconds for the post-creation health check request",
     )

As per coding guidelines, bindu/**/*.py: "Validate all external input and use type hints for input validation in Python files".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bindu/settings.py` around lines 151 - 165, The three tunnel-related settings
max_attempts, base_backoff_seconds, and health_check_timeout_seconds must be
validated at settings parse time: enforce max_attempts >= 1,
base_backoff_seconds >= 0, and health_check_timeout_seconds > 0 so bad env
values raise immediately. Fix by adding Pydantic constraints/validators on those
fields in the settings model (either use conint/ge/gt or add `@validator` methods
for the model) to validate the values and raise a clear ValidationError during
parsing; update the Field(...) declarations for max_attempts,
base_backoff_seconds, and health_check_timeout_seconds (or add validators named
for those fields) accordingly so invalid config is rejected early.



class DeploymentSettings(BaseSettings):
"""Deployment and server configuration settings."""
Expand Down
31 changes: 31 additions & 0 deletions bindu/utils/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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("Fallback: ", style="bold white")
warning_lines.append(
"Pass fail_on_tunnel_error=False to bindufy() to continue locally when tunnel fails (instead of raising an error).",
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:
Expand Down
Loading