Skip to content

feat: v0.10.0 — resilience & dynamic ops (Phase 1) - #29

Merged
Lothnic merged 2 commits into
mainfrom
milestone/phase1-resilience-ops
Jul 28, 2026
Merged

feat: v0.10.0 — resilience & dynamic ops (Phase 1)#29
Lothnic merged 2 commits into
mainfrom
milestone/phase1-resilience-ops

Conversation

@Lothnic

@Lothnic Lothnic commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What / why

Phase 1 of the complete-product track (docs/FULL.md) — closes the remaining P0 items. v1.0 ships feature-complete, so resilience + dynamic ops land under an honest 0.10.0 tag first.

Deliverables (all FULL.md Phase 1 tracks)

Track Acceptance Status
Circuit breaker Per-backend: opens after failure_threshold consecutive failures; open → 503 meridian_circuit_open pre-flight (no upstream call); half-open probe after open_seconds; success closes / failure re-opens; state in /meridian/status + meridian_backend_circuit_open{backend}
Retry with backoff max_retries(default 0 = off) + retry_backoff_base on httpx.RequestError. Safe windows only: non-stream requests + the stream open phase (pre-first-byte); a started stream never retries. Counted in meridian_upstream_retries_total{backend}
Graceful stream endings Any upstream transport failure (open or mid-stream) yields SSE error event + [DONE]; stalled streams accounted 504 stream_read_timeout, other transport errors 502
Request timeouts Global timeouts.{connect,read,write,pool} + per-backend backends[].timeout merge; stream_read for SSE; defaults preserve current behavior
Dynamic config reload POST /meridian/reload + SIGHUP apply strategy/backends/weights/tiering/health/resilience/affinity/rate-limits/telemetry without restart; atomic (bad config → 400, state kept); keys-only fallback when no config file

Tests

  • 32 new tests (test_resilience.py, test_config_reload.py): breaker state machine incl. half-open transitions, proxy gate, retry counting/backoff (non-stream + stream-open), timeout merge, stream timeout + mid-stream protocol error end-to-end, reload atomicity + rollback.
  • Suite: 410 passed; coverage 87% on meridian/ (resilience.py 100%, forward.py 95%); ruff + mypy clean.

Manual validation (live process)

  • Dead backend: req1 → 502, req2 → 503 meridian_circuit_open; /meridian/status shows {"state":"open"}; gauge = 1.
  • POST /meridian/reload{"reloaded":true,"scope":"full"}, strategy swapped to ewma_latency without restart.
  • Bogus strategy on disk → 400, running strategy unchanged.
  • kill -HUPSIGHUP: reload complete ({'scope': 'full' ...}).
  • First live run with default health settings showed the checker masking the circuit (backend marked unhealthy) — expected interplay; documented behavior.

Docs

CHANGELOG [0.10.0], CONFIGURATION reference (timeouts, resilience), DEPLOY (Config reload (no restart)), ROADMAP/ship/FULL status flips (P0-1…P0-5 ✅).

Known limits (documented)

  • Retries cover only provably safe windows: no response bytes received. HTTP error statuses are never retried (transport errors only, per CLAUDE.md default-off retry rule). A started stream never retries — it ends gracefully instead.
  • Routing still selects an open-circuit backend when it's the only eligible one (spec behavior: 503, no silent failover to an excluded pool); health-gating handles the common case.
  • Reload keeps running budget/cost stores, audit bus, and JSONL handle (data continuity); changing those sections logs a restart-required warning.

- per-backend circuit breaker: open after N consecutive failures, 503
  meridian_circuit_open pre-flight, half-open probe after open_seconds;
  state in /meridian/status + meridian_backend_circuit_open gauge
- retry with exponential backoff on transport errors (max_retries,
  retry_backoff_base; default 0 = off); meridian_upstream_retries_total
- configurable timeouts: global timeouts block + per-backend
  backends[].timeout merge; stream_read for SSE
- graceful stream read timeout: SSE error event + [DONE], accounted 504
- full dynamic config reload via POST /meridian/reload + SIGHUP: strategy,
  backends, tiering, health, resilience, affinity, rate limits, telemetry;
  atomic (bad config → 400, running state kept); keys-only fallback when
  no config file backs the process
- 28 new tests (resilience state machine, proxy gates, retry/backoff,
  timeout merge, stream timeout e2e, reload atomicity); 406 total, 87% cov

Validated live: 502→circuit-open 503 on dead backend, full-scope reload
swaps strategy via endpoint + SIGHUP, invalid config rejected atomically.
Copilot AI review requested due to automatic review settings July 28, 2026 06:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

- forward_stream accepts resilience config: retries transport errors ONLY
  in the open phase (pre-first-byte) with the same backoff; counting in
  meridian_upstream_retries_total
- a started stream still never retries (delivered tokens can't be un-sent)
- any upstream transport failure on a stream — open or mid-stream — now
  ends well-formed: SSE error event + data: [DONE], accounted via
  StreamUpstreamError (504 stream_read_timeout / 502 <exc name>)
- replaces StreamReadTimeout with the general StreamUpstreamError
- +4 tests: open-phase retry-to-success, retry-exhausted graceful end,
  no-retry default single attempt, mid-stream protocol error

410 tests pass; forward.py 95% covered; ruff + mypy clean
@Lothnic
Lothnic merged commit 9edb0b1 into main Jul 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants