fix(ai): bound how many attempts of one call may end at the streaming deadline - #865
Conversation
… deadline A streaming attempt waits THRILLHOUSEBOT_REVIEW_AI_TIMEOUT_SECONDS, 900 seconds in production, and AiReviewService then retried that timeout like any other transient failure, up to THRILLHOUSEBOT_REVIEW_MAX_AI_RETRIES. One call could therefore spend 75 minutes of wall clock, all of it holding its pull request's dispatcher slot, and the pull request is pushed to often enough that the review is superseded and the wait starts again. Production saw 20 timed-out attempts in 24 hours, all on one 503-file pull request. A timeout says something about the request and not only about the provider: a prompt the model did not finish in 15 minutes is the same prompt on the next attempt. At most two attempts of one logical call may now end at the deadline. The second one fails the call instead of spending the attempts that are left, and the decision is logged at WARN with the session id, the attempt and the wait, so a pull request that reliably times out is visible without reading every line. Every other transient failure keeps the whole budget, and a timeout followed by a successful attempt still succeeds. The count lives on the logical call, so the reasoning step-down's repeat shares it rather than getting a second pair of waits. The repeat keeps the full deadline: it is there for the attempt whose first token never arrived because the provider queued the request, and a shorter deadline would take that away while the bound already brings the ceiling from 75 minutes to 30. The setting keeps its meaning of one attempt's wait. The final summary call shares the loop and is bounded the same way; its failure still degrades to the counts-only summary that keeps the paid findings. To the batch lane the ended call is an ordinary spent call: the sequential pass gives the batch the one fresh attempt it gives any transient failure, since the parallel pass sends every batch at once and a deadline missed under that contention can be the contention, and the files are disclosed as not reviewed when that attempt fails too. Fixes #862
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
🤖 ThrillhouseBot PR SummaryWhat this PR doesBounds how many streaming attempts of one AI review call may end at the streaming deadline: a second timed-out attempt in one logical call now fails the call (WARN-logged with session, attempt and wait) instead of spending the remaining retry budget, while every other transient failure keeps the full budget. The timeout is typed as a new AiReviewTimeoutException carrying the attempt's wait, and the budget is shared by both passes of the reasoning step-down so the ceiling per call drops from 75 minutes to 30. Description vs. ImplementationNo mismatch found between the PR description and the change. Control-Flow Diagram🔀 Show diagramflowchart TD
A["runWithRetries: create TimeoutBudget"] --> B["attemptWithRetries retry loop"]
B --> C["streamInSlot: result.get(deadline)"]
C --> D{"TimeoutException?"}
D -->|"yes"| E["throw AiReviewTimeoutException carrying the wait"]
D -->|"no"| F["provider error, parse or success"]
E --> G["catch: broadcast streamFailed"]
G --> H{"timeout and budget spent?"}
H -->|"no — first timeout"| I["backoff, next attempt"]
H -->|"yes — second timeout"| J["WARN log: session, attempt, wait"]
J --> K["throw call-ending AiReviewTimeoutException"]
I --> B
F -->|"success"| L["return ReviewResponse"]
Changes Overview
Changed Files
Risk Assessment
No new issues found in this PR, but the review cannot be approved until required CI is confirmed green.
|
| Check | Type | Status | Detail |
|---|---|---|---|
| test | check-run | ⏳ Pending | - |
| format | check-run | ⏳ Pending | - |
Automated review by ThrillhouseBot. Reply with /review to re-run.
There was a problem hiding this comment.
Required CI is now green for c507432, so the approval the earlier review held back is posted. The code was not re-reviewed: that review found no issues, and only the CI gate held its approval.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
/review |
|
/pause |
|
⏸️ ThrillhouseBot is now paused on this PR — automatic and manual reviews are silenced. Comment |
|



What type of PR is this?
Description
A streaming attempt waits
THRILLHOUSEBOT_REVIEW_AI_TIMEOUT_SECONDS(900 in production,300by default) andAiReviewServicethen retried that timeout like any other transient failure, up toTHRILLHOUSEBOT_REVIEW_MAX_AI_RETRIES(5). One logical call could spend 75 minutes of wall clock, all of it holding its pull request's dispatcher slot, and the pull request is pushed to often enough that the review is superseded and the replacement starts the same wait.Every claim in the issue holds against the code:
streamInSlotwaitedresult.get(streamTimeout().toMillis(), …)and turned theTimeoutExceptioninto a plainAiReviewException, whichattemptWithRetriescaught in its generalcatch (RuntimeException e)branch and retried like a connection reset. The deterministic failures each have their own escape (AiResponseTruncatedException,AiContextWindowExceededException), the deadline had none.maxAttemptsisconfig.review().maxAiRetries(), shipped as5inapplication.properties;ai-timeout-secondsships as300there and production overrides it to 900.summarizegoes through the samerunWithRetries, so the summary lane had the same behaviour.ReviewDispatcher.runSerializedserializes work perPrKey, so the wait does hold that pull request's slot.One correction to the issue's framing:
THRILLHOUSEBOT_REVIEW_AI_TIMEOUT_SECONDSis300in the shipped defaults; the 900 in the report is the production override, and the issue's arithmetic is about that deployment.The fix names the deadline apart from the rest.
AiReviewTimeoutExceptioncarries the wait the attempt spent;runWithRetriesopens aTimeoutBudgetfor the logical call and both of its passes share it;attemptWithRetrieslogs and broadcasts the attempt exactly as before, thenfailIfTimeoutBudgetSpentends the call when it was the second attempt to end at the deadline. Every other transient failure keeps the whole budget, and a timeout followed by a successful attempt still succeeds. The WARN line names the session, the attempt and the wait:Should the repeat wait a shorter deadline? It should not, and it does not here. The repeat exists for the one shape of timeout that is not about the request: a first token that never arrived because the provider queued the call. That attempt needs the full deadline to come back, and a halved one would turn a review that was going to succeed into a failure the bot still paid for — on exactly the large pull requests that legitimately take ten-plus minutes to review. The issue also asks that the setting keep its meaning for a single attempt, which a shorter second deadline would break. The wall-clock complaint is answered by the count alone: the ceiling per call goes from 75 minutes to 30, and nothing in the change makes a call that would have succeeded fail.
Scope kept to the retry loop, as the issue asks. The length-stop repeat (#839) is untouched apart from sharing the count — the bound is on the logical call, so a call that already waited out one deadline before its length stop has one attempt left, not two more. The slot-refusal backoff and the concurrency gate (#838) are untouched: a call that found no free slot was never sent and stays an ordinary transient failure, and the timed-out stream is still cancelled and its slot still released. The parse-failure handling (#850/#851) is untouched, and because
AiReviewTimeoutExceptionis anAiReviewException, a summary call ended this way still degrades to the counts-only summary that keeps the paid findings. On the batch lane the ended call is an ordinary spent call: the sequential pass still gives the batch the one fresh attempt it gives any transient failure — the parallel pass sends every batch at once, and a deadline missed under that contention can be the contention — and the batch's files are still disclosed as not reviewed when that attempt fails too. There is a test for that.README.mdand.env.examplesay how many attempts may spend the wait.Related Issues
Fixes #862
How Has This Been Tested?
Red first, against the unfixed code, with the two new bounding tests in place (the two guard tests passed then, as they must):
Both
<5>readings are the shipped behaviour: the call spent every configured attempt on the deadline.New tests:
repeatedTimeoutsEndTheCallInsteadOfSpendingTheRetryBudget— repeated timeouts stop after two attempts of five.aTimeoutFollowedByASuccessfulAttemptStillSucceeds— the repeat is still made and still counts.aTimeoutDoesNotShortenTheBudgetOfOtherTransientFailures— one timeout mixed with provider errors leaves all five attempts for the errors.repeatedTimeoutsEndTheSummaryCallOnTheSameTerms— the summary lane takes the same path.theCallEndedByRepeatedTimeoutsIsLoggedWithSessionAttemptAndWait— the WARN names the session, the attempt and the wait.FindingPipelineTest.multiCallDisclosesABatchWhoseCallGaveUpOnItsTimedOutAttempts— a batch whose call ended this way is retried once by the sequential pass and its files are disclosed as not reviewed.Gates, from the worktree root on JDK 25:
Patch coverage of the
src/maindiff againstorigin/main, lines and branches, read fromtarget/site/jacoco/jacoco.xml:GAPS: none.Checklist
Additional Notes
No configuration change is needed on upgrade and no setting changes meaning. A deployment that wants the old behaviour has none — the bound is not configurable, in the same way the length-stop repeat of #839 is not.
MAX_TIMED_OUT_ATTEMPTSis one constant with the reasoning next to it if that ever needs to move.