Skip to content

feat(engine): retry transient draft failures with jittered backoff - #20

Open
jonasbrami wants to merge 1 commit into
mainfrom
feat/retry-backoff-generation
Open

feat(engine): retry transient draft failures with jittered backoff#20
jonasbrami wants to merge 1 commit into
mainfrom
feat/retry-backoff-generation

Conversation

@jonasbrami

Copy link
Copy Markdown
Owner

What

The agentic draft (llm.draft_quiz) is the one expensive call in quiz generation, and the one that fails transiently: rate limits, provider overload, a connection dropped mid-stream. Today any exception goes straight to the error page — a momentary 429 means the whole run is lost.

This wraps the draft in a capped, jittered retry loop (_draft_with_retry).

How it decides what to retry

The claude-agent adapter collapses every SDK failure into a RuntimeError carrying the upstream text, so retryability is classified on the message, not the exception type:

  • Retryablerate limit / 429 / overloaded / 529 / 503 / timed out / connection reset.
  • Never retried — a ValidationError (the model submitted a malformed quiz; deterministic), a missing claude binary, or an exhausted turn budget (maximum number of turns). Retrying those just burns the same tokens to the same dead end.

Backoff

Exponential with full jitter: each wait is a random value in [0, min(30s, 2s · 2**attempt)]. The jitter is load-bearing — fixed-schedule retries from a burst of clients re-collide on every wave (thundering herd); spreading each wait across the whole window decorrelates them.

Up to 3 attempts. The final attempt re-raises the original exception unchanged so the caller's error handling sees the real cause. Retry notices forward to the activity sink, so a streamed run shows the wait instead of going silent.

Tests

tests/engine/test_generate.py: retryable/fatal classification, jittered-cap bounds, retry-then-succeed, immediate raise on non-retryable, and attempt exhaustion.

🤖 Generated with Claude Code

The agentic draft is the one expensive call in generation and the one that
fails transiently — rate limits, provider overload, a dropped connection
mid-stream. Today any failure goes straight to the error page.

Wrap `llm.draft_quiz` in a capped retry loop (`_draft_with_retry`):

- Retry only transient failures. The claude-agent adapter collapses every
  SDK failure into a `RuntimeError` carrying the upstream text, so retryability
  is classified on the message (rate limit / overload / dropped connection),
  not the type. A `ValidationError` (malformed submission) and fatal markers
  (missing `claude` binary, exhausted turn budget) surface immediately.
- Exponential backoff with full jitter (`_backoff_delay`): a random wait in
  `[0, min(30s, 2s * 2**attempt)]`. The jitter decorrelates concurrent clients
  so a burst of retries doesn't re-collide on every wave.
- Up to 3 attempts; the final attempt re-raises the original exception
  unchanged, so the caller's error handling sees the real cause.
- Retry notices forward to the activity sink, so a streamed run shows the wait
  instead of going silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jonasbrami added a commit that referenced this pull request May 24, 2026
)

The old demo used a hand-authored stand-in quiz. This replaces it with a quiz
cognit actually generated — captured by running the live engine against a real
PR (#20, the retry-with-backoff change) — so the GIF shows genuine,
model-authored questions, including a true before/after mermaid flow comparison
of the retry exception path.

- `scripts/capture_demo_quiz.py`: runs the real generation engine against a PR
  and records the generated quiz + activity feed into `scripts/demo_data/`.
- `scripts/record_demo.py`: replays the captured quiz and feed. The Playwright
  driver is now data-driven — it computes and clicks the correct answer for each
  question from the quiz itself, so it adapts to whatever mix/order the model
  produced (no hard-coded option indices). Recording is higher resolution:
  Playwright records at the CSS-viewport resolution (device_scale_factor and an
  oversized record size don't add pixels), so the viewport is bumped to 1440x900
  and the GIF is emitted 1:1 at 1440px, 16fps — no downscale softening.
- `scripts/demo_data/{quiz.json,feed.json}`: the captured artifacts, so the demo
  stays offline and deterministic on re-record while the content is genuine.
- README alt-text updated to match (five questions: three MCQ, a mermaid-pick
  flow diagram, true/false).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant