Spaces:
Running
Running
Add error handling for session bootstrap failures: Implement a client-safe error response payload in session_bootstrap.py and update Streamlit routes to use this new error handling.
Browse files- src/api/session_bootstrap.py +10 -1
- src/api/streamlit_routes.py +5 -2
- src/core/settings.py +28 -9
- tests/test_session_bootstrap.py +10 -0
src/api/session_bootstrap.py
CHANGED
|
@@ -25,6 +25,7 @@ RETRYABLE_TWIRP_CODES = {
|
|
| 25 |
livekit_api.TwirpErrorCode.UNAVAILABLE,
|
| 26 |
livekit_api.TwirpErrorCode.UNKNOWN,
|
| 27 |
}
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
@dataclass(frozen=True)
|
|
@@ -110,6 +111,14 @@ def _is_retryable_bootstrap_error(exc: Exception) -> bool:
|
|
| 110 |
return False
|
| 111 |
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
class _SessionBootstrapHandler(BaseHTTPRequestHandler):
|
| 114 |
server_version = "OpenVoiceAgentBootstrap/1.0"
|
| 115 |
protocol_version = "HTTP/1.1"
|
|
@@ -124,7 +133,7 @@ class _SessionBootstrapHandler(BaseHTTPRequestHandler):
|
|
| 124 |
except Exception as exc: # pragma: no cover - exercised by integration flow
|
| 125 |
logger.exception("Failed to create session bootstrap payload: %s", exc)
|
| 126 |
self._write_json(
|
| 127 |
-
|
| 128 |
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
| 129 |
)
|
| 130 |
return
|
|
|
|
| 25 |
livekit_api.TwirpErrorCode.UNAVAILABLE,
|
| 26 |
livekit_api.TwirpErrorCode.UNKNOWN,
|
| 27 |
}
|
| 28 |
+
CLIENT_BOOTSTRAP_ERROR_MESSAGE = "Could not initialize voice session. Please try again."
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass(frozen=True)
|
|
|
|
| 111 |
return False
|
| 112 |
|
| 113 |
|
| 114 |
+
def build_bootstrap_error_payload(_: Exception | None = None) -> dict[str, str]:
|
| 115 |
+
"""Build a client-safe error response payload without internal details."""
|
| 116 |
+
return {
|
| 117 |
+
"error": "bootstrap_failed",
|
| 118 |
+
"message": CLIENT_BOOTSTRAP_ERROR_MESSAGE,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
class _SessionBootstrapHandler(BaseHTTPRequestHandler):
|
| 123 |
server_version = "OpenVoiceAgentBootstrap/1.0"
|
| 124 |
protocol_version = "HTTP/1.1"
|
|
|
|
| 133 |
except Exception as exc: # pragma: no cover - exercised by integration flow
|
| 134 |
logger.exception("Failed to create session bootstrap payload: %s", exc)
|
| 135 |
self._write_json(
|
| 136 |
+
build_bootstrap_error_payload(exc),
|
| 137 |
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
| 138 |
)
|
| 139 |
return
|
src/api/streamlit_routes.py
CHANGED
|
@@ -12,7 +12,10 @@ from streamlit.web.server.server_util import make_url_path_regex
|
|
| 12 |
from tornado.routing import PathMatches, Rule
|
| 13 |
from tornado.web import Application, RequestHandler
|
| 14 |
|
| 15 |
-
from src.api.session_bootstrap import
|
|
|
|
|
|
|
|
|
|
| 16 |
from src.core.logger import logger
|
| 17 |
|
| 18 |
SESSION_BOOTSTRAP_ENDPOINT = "session/bootstrap"
|
|
@@ -46,7 +49,7 @@ class SessionBootstrapRequestHandler(RequestHandler):
|
|
| 46 |
except Exception as exc: # pragma: no cover - integration behavior
|
| 47 |
logger.exception("Failed to create session bootstrap payload: %s", exc)
|
| 48 |
self._write_json(
|
| 49 |
-
|
| 50 |
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
| 51 |
)
|
| 52 |
return
|
|
|
|
| 12 |
from tornado.routing import PathMatches, Rule
|
| 13 |
from tornado.web import Application, RequestHandler
|
| 14 |
|
| 15 |
+
from src.api.session_bootstrap import (
|
| 16 |
+
build_bootstrap_error_payload,
|
| 17 |
+
build_session_bootstrap_payload,
|
| 18 |
+
)
|
| 19 |
from src.core.logger import logger
|
| 20 |
|
| 21 |
SESSION_BOOTSTRAP_ENDPOINT = "session/bootstrap"
|
|
|
|
| 49 |
except Exception as exc: # pragma: no cover - integration behavior
|
| 50 |
logger.exception("Failed to create session bootstrap payload: %s", exc)
|
| 51 |
self._write_json(
|
| 52 |
+
build_bootstrap_error_payload(exc),
|
| 53 |
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
| 54 |
)
|
| 55 |
return
|
src/core/settings.py
CHANGED
|
@@ -14,21 +14,32 @@ ENV_FILE = BASE_DIR / ".env"
|
|
| 14 |
load_dotenv(ENV_FILE, override=True)
|
| 15 |
logger.info(f"Loaded environment from: {ENV_FILE}")
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
def mask_sensitive_data(data: dict) -> dict:
|
| 19 |
masked = {}
|
| 20 |
-
sensitive_keys = ["key", "token", "secret", "password"]
|
| 21 |
|
| 22 |
for key, value in data.items():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
if isinstance(value, dict):
|
| 24 |
masked[key] = mask_sensitive_data(value)
|
| 25 |
-
elif isinstance(value, str) and any(s in key.lower() for s in sensitive_keys):
|
| 26 |
-
if not value:
|
| 27 |
-
masked[key] = "<not set>"
|
| 28 |
-
elif len(value) <= 4:
|
| 29 |
-
masked[key] = "***"
|
| 30 |
-
else:
|
| 31 |
-
masked[key] = f"{value[:4]}...{value[-4:]}"
|
| 32 |
else:
|
| 33 |
masked[key] = value
|
| 34 |
|
|
@@ -227,5 +238,13 @@ try:
|
|
| 227 |
logger.info(f"Settings loaded: {json.dumps(masked_settings, indent=2)}")
|
| 228 |
|
| 229 |
except ValidationError as e:
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
raise
|
|
|
|
| 14 |
load_dotenv(ENV_FILE, override=True)
|
| 15 |
logger.info(f"Loaded environment from: {ENV_FILE}")
|
| 16 |
|
| 17 |
+
SENSITIVE_KEY_MARKERS = ("key", "token", "secret", "password")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _is_sensitive_key(key: str) -> bool:
|
| 21 |
+
key_lower = key.lower()
|
| 22 |
+
return any(marker in key_lower for marker in SENSITIVE_KEY_MARKERS)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _redact_sensitive_value(value: object) -> str:
|
| 26 |
+
if value is None:
|
| 27 |
+
return "<not set>"
|
| 28 |
+
if isinstance(value, str) and not value:
|
| 29 |
+
return "<not set>"
|
| 30 |
+
return "<redacted>"
|
| 31 |
+
|
| 32 |
|
| 33 |
def mask_sensitive_data(data: dict) -> dict:
|
| 34 |
masked = {}
|
|
|
|
| 35 |
|
| 36 |
for key, value in data.items():
|
| 37 |
+
if _is_sensitive_key(key):
|
| 38 |
+
masked[key] = _redact_sensitive_value(value)
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
if isinstance(value, dict):
|
| 42 |
masked[key] = mask_sensitive_data(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
else:
|
| 44 |
masked[key] = value
|
| 45 |
|
|
|
|
| 238 |
logger.info(f"Settings loaded: {json.dumps(masked_settings, indent=2)}")
|
| 239 |
|
| 240 |
except ValidationError as e:
|
| 241 |
+
safe_errors = e.errors(
|
| 242 |
+
include_url=False,
|
| 243 |
+
include_context=False,
|
| 244 |
+
include_input=False,
|
| 245 |
+
)
|
| 246 |
+
logger.exception(
|
| 247 |
+
"Error validating settings: %s",
|
| 248 |
+
json.dumps(safe_errors),
|
| 249 |
+
)
|
| 250 |
raise
|
tests/test_session_bootstrap.py
CHANGED
|
@@ -173,3 +173,13 @@ def test_build_session_bootstrap_payload_does_not_retry_non_retryable_twirp_erro
|
|
| 173 |
|
| 174 |
assert attempts == 1
|
| 175 |
assert sleep_calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
assert attempts == 1
|
| 175 |
assert sleep_calls == []
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_build_bootstrap_error_payload_hides_exception_details() -> None:
|
| 179 |
+
payload = session_bootstrap.build_bootstrap_error_payload(
|
| 180 |
+
RuntimeError("NVIDIA_API_KEY=super-secret")
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
assert payload["error"] == "bootstrap_failed"
|
| 184 |
+
assert payload["message"] == session_bootstrap.CLIENT_BOOTSTRAP_ERROR_MESSAGE
|
| 185 |
+
assert "super-secret" not in payload["message"]
|