Skip to content

Feat/llm retry report:SDK retry-attempt observability for review (#785) - #790

Open
Gongyl01 wants to merge 5 commits into
alibaba:mainfrom
Gongyl01:feat/llm-retry-report
Open

Feat/llm retry report:SDK retry-attempt observability for review (#785)#790
Gongyl01 wants to merge 5 commits into
alibaba:mainfrom
Gongyl01:feat/llm-retry-report

Conversation

@Gongyl01

@Gongyl01 Gongyl01 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #785.

Part of #368.

Adds a versioned, immutable RetryReport to ocr review that records the real HTTP attempts made inside the official Anthropic and OpenAI SDK retry loops. OCR currently sees only the final return value of a logical LLM request, so a request that receives 429, then 529, and finally succeeds is indistinguishable from a first-attempt success.

The report is frozen once after the review run and its background work have finished. Text output renders a compact attempt chain, while --format json exposes the same frozen value under the optional top-level retry_report field. A clean first-attempt-success run emits no report, so the existing output remains unchanged.

This is the observability slice of #368. It explains what the existing SDK retry loops did; it does not take ownership of retry policy.

  • internal/llm/retry_report.go: adds the ocr.llm-retry-report/v1 value model and a per-run, concurrency-safe RetryCollector. The collector derives attempt numbers and timings, decides request outcomes exactly once, validates aggregate invariants, and freezes requests in deterministic logical_request_id order.
  • internal/llm/retry_observer.go: mounts one shared Middleware implementation on Anthropic, OpenAI Chat Completions, and OpenAI Responses clients. It observes each real HTTP attempt without reading response bodies or overriding SDK decisions, recording status, provider request ID, server retry hints, x-should-retry, time to response headers, and the measured gap between attempts.
  • internal/llm/retry_boundary.go + client boundaries: correct attempts whose failure becomes visible only after HTTP 200, including unexpected EOF or malformed decoding, interrupted/incomplete SSE streams, and unsuccessful Responses object statuses. Every logical request finalizes on success, error, cancellation, or panic.
  • internal/llm/retry_meta.go + review call sites: stamps stable, non-secret identity on plan, main_task, memory compression, re-location, and review filter requests. The identity joins the report to existing session task records; scan and llm test remain outside the report.
  • cmd/opencodereview: freezes the collector at the same run boundary as the manifest and publishes the report exactly once across normal and failure exits. Terminal and JSON output consume the same immutable snapshot.

Commits (5 layered slices)

Commit Slice
6c7525c feat(llm): add retry report data layer
5b9509b feat(llm): observe retry attempts via SDK middleware
4145d13 feat(llm): correct attempts and finalize requests at the client boundary
8fec2a3 feat(llm): stamp request identity on review LLM requests
e41741e feat(cmd): publish the frozen retry report at the run boundary

Design (key invariants)

  • Observation only. The official SDK remains the retry owner. This PR does not change WithMaxRetries(5), SDK backoff or jitter, Retry-After behavior, or retry admission.
  • One logical request, one final outcome. Request outcome is decided from the complete attempt sequence, the logical call's return value, and the parent context: succeeded, recovered, failed, or cancelled. It is never inferred from the last attempt alone.
  • Observed facts, not guessed text. Attempt classification reads HTTP status and Go error types only. Raw provider error text is never parsed. A non-2xx status is authoritative; failures discovered after HTTP 200 are revised only when the boundary has typed evidence about the phase.
  • Exactly-once publication. The collector freezes only after Agent.Run has joined background work. Normal result and failure-usage paths cannot publish the same report twice, and an invariant violation suppresses the self-contradictory report instead of emitting partial data.
  • Deterministic under concurrency. A per-run collector prevents state leakage between runs. Stable request identity, contiguous attempt numbering, and sorted logical_request_id output make concurrent runs reproducible.
  • Additive output contract. retry_report is optional and uses omitempty; a run with only clean first attempts keeps its previous terminal and JSON output.
  • Allowlisted diagnostics. The report may contain provider/model labels, file/task identity, status codes, provider request IDs, retry hints, and timings. It never contains credentials, authorization headers, prompts, request or response bodies, complete endpoint URLs, or raw provider error strings.

Attempt classification

error_class Evidence
rate_limited HTTP 429
overloaded HTTP 529
authentication HTTP 401/403
timeout HTTP 408/504 or an error matching context.DeadlineExceeded
network Transport failure or unexpected EOF
provider Other explicit non-2xx provider status, or a typed provider stream/status failure
cancelled An error matching context.Canceled, including explicit parent-context cancellation
unknown Stable fallback when a post-200 failure cannot be classified more specifically without parsing message text

Output contract

Example terminal output:

LLM retry report: 1/2 requests retried, 2 retries, 1 recovered, 1 failed
- internal/agent/agent.go / main_task #1: rate_limited(429) -> overloaded(529) -> success
- internal/llm/client.go / plan #1: authentication(401) -> failed

The JSON path is additive:

.retry_report.schema_version == "ocr.llm-retry-report/v1"

The terminal summary and retry_report are generated from the same frozen RetryReport; JSON mode emits one JSON document and never mixes in the terminal summary.

Upstream integration

This branch is synchronized through upstream/main@62e2b99. The retry report is published alongside #367's frozen run manifest without changing its coverage or terminal-state contract. Existing scan output, llm test, Resume/checkpoint behavior, budget reporting, and session persistence semantics remain unchanged.

How to test

make test
make vet

# A clean run keeps the existing contract: retry_report is absent.
ocr review --from main --to feature --format json | jq '.retry_report'

# On a run that encounters a retryable or terminal LLM failure,
# inspect the frozen report and its attempt chains.
ocr review --from main --to feature --format json | jq '.retry_report'

Checklist

  • make test passes locally (full non-extension package suite with -race -count=1)
  • make vet passes
  • git diff --check upstream/main...HEAD is clean
  • gofmt -l reports no changed Go files
  • Tests cover 429 recovery, 529 exhaustion, authentication behavior, timeout and cancellation semantics, transport and unexpected EOF recovery, post-200 decode/stream/status correction, panic finalization, concurrent collection, deterministic ordering, and exactly-once terminal/JSON publication
  • Clean first-attempt-success output is regression-locked with no retry_report
  • The emitted schema and terminal rendering are allowlist-tested against secret or raw provider content

Out of scope (deliberately)

Gongyl01 and others added 5 commits August 8, 2026 13:40
Add the internal data layer for an explicit LLM request retry report: request
identity, attempt classification, and a per-run collector that freezes into an
immutable report. No behavior change — nothing is mounted on any client and no
output is produced, so this is inert until the observer is wired up.

- RequestMeta identifies one logical request (provider, model, file path, task
  type, request no) and travels through the request context, so the
  single-method LLMClient interface and every call site stay unchanged.
- logical_request_id is SHA-256 over a canonical NUL-terminated encoding of
  run_id plus the meta. It is computed in Freeze, so the collector can be
  constructed before the session exists.
- classifyAttempt derives error_class and failure_phase from the HTTP status
  and the Go error type only, never from error message text. A non-2xx status
  outranks the error, since it is the stronger fact.
- RetryCollector is created per run with no package-level state, is safe for
  concurrent use, and drops attempts that carry no identity, which is how scan
  and llm test requests stay out of the report.
- The request outcome is decided once, in Finalize, from the attempt sequence
  plus the returned error and the parent context state, rather than inferred
  from the last attempt: cancelling during backoff produces no new attempt, so
  the sequence still ends in an error while the outcome is cancelled.
- Freeze recomputes every aggregate from the listed requests and returns a
  construction error instead of publishing self-contradictory numbers. Ordering
  bugs (double Finalize, mutation after Finalize) are recorded as violations
  and surface there.

The report has no free-text field, so there is nothing to redact: no bodies,
prompts, URLs or raw SDK error strings. A test pins the exact set of plain
string fields so adding one has to be argued for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: 艺临 <gongyiling.gyl@alibaba-inc.com>
Mount a shared observer on all three LLM clients (Anthropic, OpenAI Chat
Completions, OpenAI Responses) through option.WithMiddleware, so every real
HTTP attempt the SDK retry loop makes is recorded against the logical request
that issued it.

The observer reads response headers only -- status code, request-id /
x-request-id, Retry-After (all three forms, at the SDK's own precedence),
x-should-retry -- and never touches the body, which the SDK owns and closes
before retrying. Attempts without a RequestMeta on the context are dropped
whole, which is how scan and `ocr llm test` stay out of the report.

RecordAttempt now takes the attempt's start and end timestamps instead of
pre-computed durations. observed_backoff_ms spans two attempts, so only the
collector can derive it; deriving both durations there also means the observer
cannot desynchronize numbering from the real call order. No clock abstraction
is needed and the values stay deterministic in tests.

The collector is reached through an unexported ClientConfig field rather than
new constructor parameters, keeping the three exported constructors unchanged.
It is created per run in loadLLMRuntime, not package-level, so two runs in one
process cannot share data. Nothing consumes it yet -- P5 calls Freeze at the
run boundary.

The roadmap's X-Stainless-Retry-Count cross-check is deliberately not
implemented: the SDK stops maintaining that header once ExtraHeaders overrides
it, so the mismatch branch is only reachable from a legitimate configuration,
and the desync it guards against is already caught at build time by the
exhaustion and recovery tests asserting exact attempt counts.

WithMaxRetries(5) and WithRequestTimeout are untouched; the SDK's retry
decisions are observed, never overridden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The middleware can only observe real HTTP attempts, so an HTTP 200 that
carried a truncated body, undecodable JSON, a mid-stream failure, or a dead
Responses object was recorded as a success. Each client now corrects its last
attempt before returning and finalizes the logical request exactly once.

- add retry_boundary.go: classifyBoundaryError (unrecognized errors are left
  alone rather than bucketed as unknown, since the only way left to tell them
  apart would be message text), classifyStreamError, reviseAttempt,
  finalizeRequest, streamIntegrityError and the panic sentinel
- defer the boundary on all three CompletionsWithCtx, which now use named
  results; correction runs before Finalize, as the reverse order would be a
  "revised after Finalize" violation and drop the whole run's report
- correct both EOF branches ahead of their ctx early return, so a parent
  cancel between the two SDK calls cannot leave a truncated attempt as success
- split completionsStreaming into a wrapper with a single exit, so the four
  inner returns need no correction call of their own
- replace the three bare fmt.Errorf stream integrity errors with a dedicated
  type, messages unchanged
- parentCancelled reads only context.Canceled: the per-attempt deadline from
  WithRequestTimeout must surface as failed, not as a user abort
- drop finalizeForTest from the observer tests; every case now reaches Freeze
  through a client, so a missing defer fails that case instead of passing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
review 的五类逻辑请求在调用 SDK 前建立 RequestMeta,使 observer 能按请求身份收集 attempt;scan 的六类请求保持无 meta、不进报告。

- Deps 增加 NewRequestMeta 工厂字段:review 在 agent.New 注入闭包,scan 保持 nil;不用空 provider 当开关,空串是 unnamed endpoint 的合法值
- main_task / memory compression / re-location / plan / review filter 五个落点遵循固定顺序:AppendTaskRecord -> requestCtx -> 请求
- compression 的记录创建移到请求之前,使 request_no 在请求发起时即存在;orphan llm_request 对 resume 无害(applyResumeLine 无该分支),补回归断言
- ReLocateComment 拆出纯 prompt 构造 BuildReLocationMessages,internal/diff 不接触 session / meta;Duration 口径保持含 prompt 构造时间不变
- 导出 RequestMetaFromContext,供 llmloop / agent / scan 三包的测试跨包验收请求身份
在 review 运行边界冻结重试报告并经两个出口发布;scan 与 llm test 输出不变,session JSONL 与 run manifest 契约不动。

- Runner 增加后台 WaitGroup 与 WaitBackground():agent.Run 在 dispatchSubtasks 之后、finalizeManifest 之前收口 async compression,消除 Freeze 见到未 Finalize 请求而吞掉整份报告的竞态;不加第二个超时,等待依赖 SDK 遵守取消契约
- review_cmd.go 在 ag.Run 返回后调用 Freeze,run_id 取 session 内存 UUID 而非持久化门控的 SessionID();构造错误并入 emitErr 而非 runErr,不包装成 review failed、不触发失败 usage、不打 --resume 提示
- 报告以末位参数传给 emitRunResult / outputJSONWithWarnings,不扩展 ResultProvider;双出口去重:emitRunResult 已执行时 emitFailureUsage 不重复携带
- 终端摘要走 stdout,位于评审结果与项目摘要之间,全量渲染不截断,file_path / task_type 经 sanitizeTerminal 防控制字符注入
- JSON 在 jsonOutput 末位追加 retry_report(omitempty),直接复用 llm.RetryReport 的字段与 tag;首次成功运行输出逐字节不变
- 端到端:假 Anthropic server + 真 git 仓库驱动 runReview,覆盖干净运行、recovered+failed、全失败去重、Freeze 构造错误、session 持久化失败五个场景;manual_e2e tag 保留写码前的手工验证夹具
@wu21-web

wu21-web commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Thank you, but this is big and hard to review.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

OpenCodeReview: Review partially complete: 0 finding(s); 2 of 16 selected item(s) failed.

@Gongyl01

Gongyl01 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, but this is big and hard to review.

Hi @wu21-web, Thanks for the feedback! To help with the review, here's a breakdown — about 4,670 of the 6,197 added lines (~75%) are tests. The core implementation is only ~1,500 lines:

New core files (suggested reading order):

  1. internal/llm/retry_meta.go (+154) — retry metadata carried per request
  2. internal/llm/retry_boundary.go (+141) — retry boundary decisions
  3. internal/llm/retry_observer.go (+132) — observes retry attempts from the SDK
  4. internal/llm/retry_report.go (+658) — aggregates observations into the report

Integration points:

  • internal/llm/client.go (+97) and internal/llm/responses_client.go (+27) — hook observers into the SDK clients
  • internal/llmloop/loop.go (+70) — thread retry metadata through the loop
  • internal/agent/agent.go (+40) — propagate to agents
  • cmd/opencodereview/output.go (+79) and cmd/opencodereview/review_cmd.go (+32) — render the report

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.

Expose official SDK LLM retry attempts in review output

2 participants