From 023e576185f49efeb42849615321b63905278f50 Mon Sep 17 00:00:00 2001 From: ayushag Date: Tue, 18 Aug 2026 13:56:33 -0700 Subject: [PATCH 1/2] fix: better api_env_key missing errors Signed-off-by: ayushag --- crates/switchyard-py/src/errors.rs | 7 +++- crates/switchyard-py/src/server_bindings.rs | 6 +++- switchyard/cli/launchers/native_server.py | 13 +++++-- switchyard/cli/switchyard_cli.py | 6 +++- switchyard_rust/server.py | 9 +++-- tests/test_launchers.py | 39 +++++++++++++++++++-- 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/crates/switchyard-py/src/errors.rs b/crates/switchyard-py/src/errors.rs index 8f5e44e47..e6a3eae2e 100644 --- a/crates/switchyard-py/src/errors.rs +++ b/crates/switchyard-py/src/errors.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Error mapping for the libsy Python binding. +//! Error mapping for the Python bindings. use pyo3::create_exception; use pyo3::exceptions::PyRuntimeError; @@ -9,6 +9,7 @@ use pyo3::prelude::*; create_exception!(_switchyard_rust, LibsyError, PyRuntimeError); create_exception!(_switchyard_rust, ContextWindowExceededError, PyRuntimeError); +create_exception!(_switchyard_rust, ServerConfigError, PyRuntimeError); /// Converts libsy execution failures into one stable Python exception. pub(crate) fn py_libsy_error(error: impl std::fmt::Display) -> PyErr { @@ -20,5 +21,9 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add( "ContextWindowExceededError", module.py().get_type::(), + )?; + module.add( + "ServerConfigError", + module.py().get_type::(), ) } diff --git a/crates/switchyard-py/src/server_bindings.rs b/crates/switchyard-py/src/server_bindings.rs index 862eea2ad..12b4395d6 100644 --- a/crates/switchyard-py/src/server_bindings.rs +++ b/crates/switchyard-py/src/server_bindings.rs @@ -19,6 +19,8 @@ use switchyard_server::{ use tokio::sync::oneshot; use tokio::task::JoinHandle; +use crate::errors::ServerConfigError; + const DEFAULT_SHUTDOWN_TIMEOUT_SECS: f64 = 2.0; /// Running loopback server backed entirely by the native Rust implementation. @@ -38,7 +40,8 @@ impl PyServer { #[pyo3(signature = (config, port=0))] fn new(config: PathBuf, port: u16) -> PyResult { initialize_observability().map_err(server_error)?; - let state = load_server_state(config).map_err(server_error)?; + let state = load_server_state(config) + .map_err(|error| ServerConfigError::new_err(error.to_string()))?; let caller_auth_by_model = state .models() .map(|model| { @@ -177,6 +180,7 @@ fn server_error(error: impl std::fmt::Display) -> PyErr { pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { let server_module = PyModule::new(module.py(), "server")?; + server_module.add("ServerConfigError", module.getattr("ServerConfigError")?)?; server_module.add_class::()?; module.add_submodule(&server_module)?; Ok(()) diff --git a/switchyard/cli/launchers/native_server.py b/switchyard/cli/launchers/native_server.py index d33e987c5..98a01cb25 100644 --- a/switchyard/cli/launchers/native_server.py +++ b/switchyard/cli/launchers/native_server.py @@ -16,6 +16,10 @@ _LOCAL_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) +class NativeServerConfigError(RuntimeError): + """Raised when a native server deployment configuration is invalid.""" + + class HttpStatsSource(StatsSource): """Read launcher statistics from the native server.""" @@ -39,9 +43,12 @@ class NativeServer: """Host one TOML deployment through the PyO3 Rust server binding.""" def __init__(self, config: Path) -> None: - from switchyard_rust.server import Server + from switchyard_rust.server import Server, ServerConfigError - self._server = Server(config, port=0) + try: + self._server = Server(config, port=0) + except ServerConfigError as exc: + raise NativeServerConfigError(str(exc)) from exc self.port: int = self._server.port self.base_url: str = self._server.base_url self.stats: StatsSource = HttpStatsSource(self.base_url) @@ -55,4 +62,4 @@ def close(self) -> None: self._server.close() -__all__ = ["HttpStatsSource", "NativeServer"] +__all__ = ["HttpStatsSource", "NativeServer", "NativeServerConfigError"] diff --git a/switchyard/cli/switchyard_cli.py b/switchyard/cli/switchyard_cli.py index 49ed84bdd..edacc0e65 100644 --- a/switchyard/cli/switchyard_cli.py +++ b/switchyard/cli/switchyard_cli.py @@ -12,6 +12,7 @@ cmd_launch_codex, cmd_launch_openclaw, ) +from switchyard.cli.launchers.native_server import NativeServerConfigError def _add_launch_parser( @@ -73,7 +74,10 @@ def main() -> None: if not hasattr(args, "func"): parser.print_help() raise SystemExit(1) - args.func(args) + try: + args.func(args) + except NativeServerConfigError as exc: + parser.exit(1, f"error: {exc}\n") if __name__ == "__main__": diff --git a/switchyard_rust/server.py b/switchyard_rust/server.py index a30577eca..87b67320d 100644 --- a/switchyard_rust/server.py +++ b/switchyard_rust/server.py @@ -12,6 +12,9 @@ if TYPE_CHECKING: + class ServerConfigError(RuntimeError): + """Raised when a native server deployment configuration is invalid.""" + @final class Server: """Running loopback instance of the native Switchyard server.""" @@ -39,10 +42,10 @@ def __exit__( def __getattr__(name: str) -> object: - if name == "Server": + if name in {"Server", "ServerConfigError"}: native: Any = load_native() - return native.server.Server + return getattr(native.server, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -__all__ = ["Server"] +__all__ = ["Server", "ServerConfigError"] diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 6873c7b75..accaf6cb1 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -4,6 +4,7 @@ """Contract tests for the minimal coding-agent launcher surface.""" import argparse +import sys from pathlib import Path import pytest @@ -11,8 +12,8 @@ from switchyard.cli.launch_command import _config_path from switchyard.cli.launchers.claude_code_launcher import _claude_env from switchyard.cli.launchers.codex_cli_launcher import _codex_env, _provider_overrides -from switchyard.cli.launchers.native_server import NativeServer -from switchyard.cli.switchyard_cli import _build_parser +from switchyard.cli.launchers.native_server import NativeServer, NativeServerConfigError +from switchyard.cli.switchyard_cli import _build_parser, main def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]: @@ -123,6 +124,40 @@ def caller_auth_kind(self, model: str) -> str | None: assert config.exists() +def test_native_server_identifies_missing_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + with pytest.raises(NativeServerConfigError, match="OPENROUTER_API_KEY"): + NativeServer(_config_path(None)) + + +def test_cli_prints_configuration_error_without_traceback( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def invalid_config(_args: argparse.Namespace) -> None: + raise NativeServerConfigError( + "OPENROUTER_API_KEY: environment variable not found" + ) + + monkeypatch.setattr("switchyard.cli.switchyard_cli.cmd_launch_claude", invalid_config) + monkeypatch.setattr( + sys, + "argv", + ["switchyard", "launch", "claude", "--model", "switchyard"], + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + assert capsys.readouterr().err == ( + "error: OPENROUTER_API_KEY: environment variable not found\n" + ) + + def test_missing_explicit_config_is_a_cli_error(tmp_path: Path) -> None: missing = tmp_path / "missing.toml" with pytest.raises(SystemExit, match="config file not found"): From 3c9d82147e15c4631500e465715e4d77d3187d7e Mon Sep 17 00:00:00 2001 From: ayushag Date: Tue, 18 Aug 2026 14:04:50 -0700 Subject: [PATCH 2/2] chore: remove test Signed-off-by: ayushag --- tests/test_launchers.py | 39 ++------------------------------------- 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/tests/test_launchers.py b/tests/test_launchers.py index accaf6cb1..6873c7b75 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -4,7 +4,6 @@ """Contract tests for the minimal coding-agent launcher surface.""" import argparse -import sys from pathlib import Path import pytest @@ -12,8 +11,8 @@ from switchyard.cli.launch_command import _config_path from switchyard.cli.launchers.claude_code_launcher import _claude_env from switchyard.cli.launchers.codex_cli_launcher import _codex_env, _provider_overrides -from switchyard.cli.launchers.native_server import NativeServer, NativeServerConfigError -from switchyard.cli.switchyard_cli import _build_parser, main +from switchyard.cli.launchers.native_server import NativeServer +from switchyard.cli.switchyard_cli import _build_parser def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]: @@ -124,40 +123,6 @@ def caller_auth_kind(self, model: str) -> str | None: assert config.exists() -def test_native_server_identifies_missing_api_key( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) - - with pytest.raises(NativeServerConfigError, match="OPENROUTER_API_KEY"): - NativeServer(_config_path(None)) - - -def test_cli_prints_configuration_error_without_traceback( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - def invalid_config(_args: argparse.Namespace) -> None: - raise NativeServerConfigError( - "OPENROUTER_API_KEY: environment variable not found" - ) - - monkeypatch.setattr("switchyard.cli.switchyard_cli.cmd_launch_claude", invalid_config) - monkeypatch.setattr( - sys, - "argv", - ["switchyard", "launch", "claude", "--model", "switchyard"], - ) - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 1 - assert capsys.readouterr().err == ( - "error: OPENROUTER_API_KEY: environment variable not found\n" - ) - - def test_missing_explicit_config_is_a_cli_error(tmp_path: Path) -> None: missing = tmp_path / "missing.toml" with pytest.raises(SystemExit, match="config file not found"):