feat(errors): incident-key safe error envelopes (v1.7.2)
This commit is contained in:
93
app.py
93
app.py
@@ -20,6 +20,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -55,7 +56,7 @@ syslog_handler.setFormatter(
|
|||||||
log.addHandler(syslog_handler)
|
log.addHandler(syslog_handler)
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
VERSION = "1.7.1"
|
VERSION = "1.7.2"
|
||||||
OLLAMA_BASE = "http://localhost:11434"
|
OLLAMA_BASE = "http://localhost:11434"
|
||||||
SEARXNG_BASE = "http://localhost:8888"
|
SEARXNG_BASE = "http://localhost:8888"
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
@@ -182,6 +183,53 @@ def audit_event(
|
|||||||
log.info(msg)
|
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]:
|
def parse_allowed_cidrs(raw: str) -> list[ipaddress._BaseNetwork]:
|
||||||
"""Parse comma-separated CIDR list into validated network objects."""
|
"""Parse comma-separated CIDR list into validated network objects."""
|
||||||
networks: list[ipaddress._BaseNetwork] = []
|
networks: list[ipaddress._BaseNetwork] = []
|
||||||
@@ -852,6 +900,24 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="JarvisChat", lifespan=lifespan)
|
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
|
# Mount static files
|
||||||
static_dir = BASE_DIR / "static"
|
static_dir = BASE_DIR / "static"
|
||||||
if static_dir.exists():
|
if static_dir.exists():
|
||||||
@@ -1720,8 +1786,17 @@ async def explicit_search(request: Request):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Ollama error during search summarization: {e}")
|
incident_key = log_incident(
|
||||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
"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
|
return
|
||||||
|
|
||||||
summary = "".join(full_response)
|
summary = "".join(full_response)
|
||||||
@@ -1987,7 +2062,17 @@ async def chat(request: Request):
|
|||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
yield f"data: {json.dumps({'error': 'Cannot connect to Ollama. Is it running?'})}\n\n"
|
yield f"data: {json.dumps({'error': 'Cannot connect to Ollama. Is it running?'})}\n\n"
|
||||||
except Exception as e:
|
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")
|
return StreamingResponse(stream_response(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Total identified items: 26
|
|||||||
4. [P0][DONE] Add rate limiting and request body size limits for chat/search/profile APIs.
|
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.
|
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).
|
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.
|
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.
|
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.
|
10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ⚡ JarvisChat v1.7.1
|
# ⚡ JarvisChat v1.7.2
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Top 10 (brief):
|
|||||||
4. P0 [DONE]: Add rate limiting and request size limits
|
4. P0 [DONE]: Add rate limiting and request size limits
|
||||||
5. P1 [DONE]: Restrict `/api/settings` updates to allowlisted keys
|
5. P1 [DONE]: Restrict `/api/settings` updates to allowlisted keys
|
||||||
6. P1: Add pagination + hard caps for list APIs
|
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
|
8. P1: Add automated tests for streaming/search/memory paths
|
||||||
9. P2: Implement MCP-style skills/tool-call framework
|
9. P2: Implement MCP-style skills/tool-call framework
|
||||||
10. P2: Implement heartbeat/check-in scheduler + summary endpoint
|
10. P2: Implement heartbeat/check-in scheduler + summary endpoint
|
||||||
|
|||||||
72
tests/test_error_envelopes.py
Normal file
72
tests/test_error_envelopes.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user