From 12188f3ad22c11aa2240299c5a40406aa4f8c585 Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 27 Apr 2026 16:56:17 -0700 Subject: [PATCH] feat(errors): incident-key safe error envelopes (v1.7.2) --- app.py | 93 +++++++++++++++++++++++++++++++++-- docs/wiki/current-wip.md | 2 +- readme.md | 4 +- tests/test_error_envelopes.py | 72 +++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 tests/test_error_envelopes.py diff --git a/app.py b/app.py index 310ce48..dc19fc1 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ import json import logging import math import os +import platform import sqlite3 import subprocess import hashlib @@ -55,7 +56,7 @@ syslog_handler.setFormatter( log.addHandler(syslog_handler) # --- Configuration --- -VERSION = "1.7.1" +VERSION = "1.7.2" OLLAMA_BASE = "http://localhost:11434" SEARXNG_BASE = "http://localhost:8888" BASE_DIR = Path(__file__).parent @@ -182,6 +183,53 @@ def audit_event( log.info(msg) +def create_incident_key() -> str: + """Create a readable unique key for customer-visible error lookup.""" + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + return f"INC-{ts}-{uuid.uuid4().hex[:8].upper()}" + + +def customer_error_envelope(message: str, incident_key: str) -> dict: + """Stable client-safe error contract with support lookup key.""" + return { + "detail": message, + "error_key": incident_key, + "error": { + "message": message, + "incident_key": incident_key, + "support_hint": "Share this incident key for exact diagnostics.", + }, + } + + +def log_incident( + event: str, + *, + message: str, + request: Optional[Request] = None, + exc: Optional[Exception] = None, +) -> str: + """Log internal failure details with traceback and system snapshot.""" + incident_key = create_incident_key() + payload = { + "event": event, + "incident_key": incident_key, + "message": message, + "app_version": VERSION, + "pid": os.getpid(), + "python": platform.python_version(), + "platform": platform.platform(), + "method": request.method if request else "", + "path": request.url.path if request else "", + "client_ip": get_client_ip(request) if request else "", + } + if exc: + log.exception("INCIDENT " + json.dumps(payload, separators=(",", ":"))) + else: + log.error("INCIDENT " + json.dumps(payload, separators=(",", ":"))) + return incident_key + + def parse_allowed_cidrs(raw: str) -> list[ipaddress._BaseNetwork]: """Parse comma-separated CIDR list into validated network objects.""" networks: list[ipaddress._BaseNetwork] = [] @@ -852,6 +900,24 @@ async def lifespan(app: FastAPI): app = FastAPI(title="JarvisChat", lifespan=lifespan) + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + incident_key = log_incident( + "unhandled_exception", + message="Unhandled server error", + request=request, + exc=exc, + ) + message = ( + "We could not complete that request right now. " + "Use the incident key for support lookup." + ) + return JSONResponse( + status_code=500, + content=customer_error_envelope(message, incident_key), + ) + # Mount static files static_dir = BASE_DIR / "static" if static_dir.exists(): @@ -1720,8 +1786,17 @@ async def explicit_search(request: Request): except json.JSONDecodeError: pass except Exception as e: - log.error(f"Ollama error during search summarization: {e}") - yield f"data: {json.dumps({'error': str(e)})}\n\n" + incident_key = log_incident( + "search_summarization_stream", + message="Ollama stream failure during explicit search summarization", + request=request, + exc=e, + ) + client_msg = ( + "Search summarization could not complete right now. " + "Use the incident key for support lookup." + ) + yield f"data: {json.dumps({'error': client_msg, 'error_key': incident_key})}\n\n" return summary = "".join(full_response) @@ -1987,7 +2062,17 @@ async def chat(request: Request): except httpx.ConnectError: yield f"data: {json.dumps({'error': 'Cannot connect to Ollama. Is it running?'})}\n\n" except Exception as e: - yield f"data: {json.dumps({'error': str(e)})}\n\n" + incident_key = log_incident( + "chat_stream", + message="Ollama stream failure during chat response", + request=request, + exc=e, + ) + client_msg = ( + "Chat response generation failed before completion. " + "Use the incident key for support lookup." + ) + yield f"data: {json.dumps({'error': client_msg, 'error_key': incident_key})}\n\n" return StreamingResponse(stream_response(), media_type="text/event-stream") diff --git a/docs/wiki/current-wip.md b/docs/wiki/current-wip.md index 8888dd2..94a21be 100644 --- a/docs/wiki/current-wip.md +++ b/docs/wiki/current-wip.md @@ -19,7 +19,7 @@ Total identified items: 26 4. [P0][DONE] Add rate limiting and request body size limits for chat/search/profile APIs. 5. [P1][DONE] Restrict settings updates to an allowlist of valid keys. 6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history). -7. [P1] Stop returning raw exception text to clients; use safe error envelopes. +7. [P1][DONE] Stop returning raw exception text to clients; use safe error envelopes. 8. [P1] Add automated tests for chat streaming, auto-search trigger, and memory command paths. 9. [P2] Implement skills/tool-call framework (MCP-style) with per-skill enable controls. 10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint. diff --git a/readme.md b/readme.md index 16bd868..b27c9ad 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# ⚡ JarvisChat v1.7.1 +# ⚡ JarvisChat v1.7.2 ![screenshot](docs/images/screenshot.png) @@ -64,7 +64,7 @@ Top 10 (brief): 4. P0 [DONE]: Add rate limiting and request size limits 5. P1 [DONE]: Restrict `/api/settings` updates to allowlisted keys 6. P1: Add pagination + hard caps for list APIs -7. P1: Replace raw exception leakage with safe client errors +7. P1 [DONE]: Replace raw exception leakage with safe client errors 8. P1: Add automated tests for streaming/search/memory paths 9. P2: Implement MCP-style skills/tool-call framework 10. P2: Implement heartbeat/check-in scheduler + summary endpoint diff --git a/tests/test_error_envelopes.py b/tests/test_error_envelopes.py new file mode 100644 index 0000000..cee77a9 --- /dev/null +++ b/tests/test_error_envelopes.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from fastapi.testclient import TestClient + +import app as app_module + + +def make_client(tmp_path: Path) -> TestClient: + os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" + app_module.DB_PATH = tmp_path / "jarvischat-errors.db" + app_module.SESSIONS.clear() + app_module.PIN_ATTEMPTS.clear() + app_module.RATE_EVENTS.clear() + app_module.init_db() + return TestClient(app_module.app, raise_server_exceptions=False) + + +def test_unhandled_api_exception_returns_friendly_error_with_incident_key( + tmp_path: Path, monkeypatch +): + with make_client(tmp_path) as client: + sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[ + "session_id" + ] + headers = {"X-Session-ID": sid} + + def boom(_topic=None): + raise RuntimeError("super secret db internals") + + monkeypatch.setattr(app_module, "get_all_memories", boom) + + resp = client.get("/api/memories", headers=headers) + assert resp.status_code == 500 + payload = resp.json() + assert payload.get("error_key", "").startswith("INC-") + assert "support lookup" in payload.get("detail", "").lower() + assert "super secret db internals" not in resp.text + + +def test_chat_stream_error_hides_internal_exception_and_emits_incident_key( + tmp_path: Path, monkeypatch +): + with make_client(tmp_path) as client: + sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[ + "session_id" + ] + headers = {"X-Session-ID": sid, "Origin": "http://testserver"} + + class BrokenStreamContext: + async def __aenter__(self): + raise RuntimeError("ultra secret model transport failure") + + async def __aexit__(self, exc_type, exc, tb): + return False + + def broken_stream(*args, **kwargs): + return BrokenStreamContext() + + monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", broken_stream) + + resp = client.post( + "/api/chat", + json={"message": "hello", "model": app_module.DEFAULT_MODEL}, + headers=headers, + ) + + assert resp.status_code == 200 + body = resp.text + assert "ultra secret model transport failure" not in body + assert "error_key" in body + assert "support lookup" in body.lower()