fix(api): best-effort call logging + non-JSON 200 → ModelError (final-review)

- call_log._default_write: swallow any exception from the default file/
  stdout sink and report to stderr, so a log-write failure (disk full,
  bad permissions, misconfigured CALL_LOG_PATH) never turns a successful
  narration into a 500 (charter §13). Injected write= sinks (used by
  tests) are left to surface their own errors.
- ollama_client.chat: catch ValueError alongside httpx.HTTPError in the
  retry loop so a 200 response with a non-JSON body (JSONDecodeError is
  a ValueError subclass) counts as a failed attempt and falls through to
  ModelError after the one retry, instead of escaping chat() uncaught
  (charter §12 — one retry, then ModelError, nothing else escapes).
- main.py: update stale docstrings — /dm/narrate is now fully wired
  (routing + model call + logging); the other four roles remain stubs.

Regression tests added for both fixes (TDD: watched RED, then GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 08:45:39 -05:00
parent 470ccb2935
commit f7c51b79da
5 changed files with 60 additions and 10 deletions

View File

@@ -16,12 +16,21 @@ from . import config
def _default_write(line: str) -> None:
path = config.call_log_path()
if path:
with open(path, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
else:
sys.stdout.write(line + "\n")
"""Best-effort (charter §13): a good model call must never become a 500
because the log write failed (disk full, bad permissions, a misconfigured
CALL_LOG_PATH). Any failure here is swallowed and reported to stderr —
never stdout, since stdout may itself be the default sink and must stay
clean JSON-lines for downstream tooling.
"""
try:
path = config.call_log_path()
if path:
with open(path, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
else:
sys.stdout.write(line + "\n")
except Exception as exc: # noqa: BLE001 - logging must never break a request
print(f"call_log: failed to write log line: {exc}", file=sys.stderr)
def record(

View File

@@ -3,7 +3,9 @@
Skeleton: a health check plus the five role endpoints. Each role endpoint now
validates the posted canon log against the contract (charter §11) before doing
anything else — an invalid log is rejected with 422 and never reaches a prompt.
Prompt routing, model selection, and logging land later.
`/dm/narrate` is fully wired: prompt routing, the Ollama model call, and
call-log logging. The other four roles remain stubs until they get the same
treatment.
"""
from fastapi import Depends, FastAPI, HTTPException
@@ -48,8 +50,9 @@ def health() -> dict:
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
# The client knows these paths and nothing about which model or prompt serves
# them. Bodies are validated against the canon log contract; the AI half is a
# stub until prompt routing lands.
# them. Bodies are validated against the canon log contract. /dm/narrate is
# fully wired (routing + model call + logging); the other four roles remain
# stubs until prompt routing, model selection, and logging land for them too.
@app.post("/dm/narrate")

View File

@@ -38,7 +38,11 @@ def chat(
if content.strip():
return content
last = "empty response from model"
except httpx.HTTPError as exc: # TimeoutException, TransportError, HTTPStatusError
except (httpx.HTTPError, ValueError) as exc:
# httpx.HTTPError: TimeoutException, TransportError, HTTPStatusError.
# ValueError: resp.json() raises json.JSONDecodeError (a ValueError
# subclass) on a 200 with a non-JSON body (e.g. a proxy error page) —
# treat that as a failed attempt too, not an uncaught escape (§12/§13).
last = f"{type(exc).__name__}: {exc}"
raise ModelError(last)
finally: