Skip to content

feat(github): bound a review's waiting on the rate limit with a per-review budget - #827

Merged
devops-thiago merged 11 commits into
mainfrom
fix/734-write-retry-budget
Sep 15, 2026
Merged

devops-thiago merged 11 commits into
mainfrom
fix/734-write-retry-budget

Conversation

@devops-thiago

@devops-thiago devops-thiago commented Sep 7, 2026

Copy link
Copy Markdown
Owner

What type of PR is this?

  • ✨ Feature

Description

GitHubWriteRetry bounds one call at 90 seconds of waiting, and that bound is per call. A review posts each finding by up to three routes — the line-anchored comment, the same comment without its suggestion block, and the thread on the file (#721) — so a finding GitHub refuses throughout can wait 3 × 90 s, and a review of fifty findings refused throughout can hold its pull request's dispatcher slot for roughly 225 minutes. The dispatcher serializes per pull request, so nothing else starves (verified: virtual-thread-per-task executor, per-PR slot), but a review in that state is wedged for hours with nothing in the log saying it is waiting rather than hung. The issue's numbers check out against the code: MAX_ATTEMPTS = 4, MAX_DELAY_PER_ATTEMPT = 30s, TOTAL_BUDGET = 90s, three routes per finding in ReviewPublisher.postFindingCommentRoutes, max-review-comments = 50.

The budget. A review now publishes under a write-retry budget, GITHUB_WRITE_RETRY_BUDGET (thrillhousebot.github.write-retry-budget), default 5m, 0 turns it off. It sits beside the pacing keys in ThrillhouseConfig.GitHubConfig, is read by ReviewPublisher through the typed configuration when it opens a review's ledger, and is documented in the README configuration table, application.properties and .env.example in the existing style. Five minutes is room for a few of the 72-second content-creation blocks measured in #722 while staying far short of the hours a review refused throughout used to wait; a limit that outlasts it is the deployment writing too fast, which is the pacer's job, not a review holding its slot.

How it is charged. ReviewPublisher.postReview opens a ledger for the review's publication (GitHubWriteBudget.within), and GitHubWriteRetry.retryDelay asks the ledger before serving any wait (GitHubWriteBudget.admits). What is charged is the waiting the retry serves, not wall-clock time: the model calls and the HTTP round trips are not what #734 measured, and a pacing wait is bounded on its own. The wait that crosses the ceiling is still served — the overrun is bounded by one clamped wait, and refusing it would throw away waiting already paid for — and it is warned about once, naming the pull request, the budget and what was being posted. The ledger is thread state, for the reason GitHubLostWrites keeps its deliveries on the thread: the retry sits behind the REST client interface with no handle to be passed one through, and a review's routes run one after another on the thread publishing it. Nested publication rejoins the open ledger rather than restarting it, the scope closes on every exit path, and outside a review (on-demand commands, thread replies) nothing changes.

What happens to the rest. After the crossing a throttled write goes out once and is not repeated. The retry gives it up exactly as it does once the attempts are spent (with its own WARN line carrying the response's diagnostics), so the write takes the path a write GitHub outlasted already takes: tryPostInlineComment returns the rejection reason, postFindingCommentRoutes logs it and falls to postFileLevelComment, and when that is refused too the finding lands in InlineCommentResult.unanchored and the review body's "GitHub accepted no review thread for" section. That section now says the budget is the reason and asks for a re-run — stated once for the section, since the budget is the review's — and GitHubLostWrites still records the loss for the dropped-post notice, since the refusal is a throttle. Nothing is dropped silently. A first attempt is never withheld: GitHub may have reopened, and a write that lands is a finding saved.

Branched from fix/784-throttle-classification (#820) rather than main, because both change GitHubWriteRetry.retryDelay and its test; the diff here shows #820's commits until it merges.

Related Issues

Fixes #734. Builds on #820 (#784).

How Has This Been Tested?

  • Unit tests

Red, in two steps. First the budget does not exist:

[ERROR] /Users/thiago/repos/tb-784/src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewWriteBudgetTest.java:[31,47] cannot find symbol

Then, with GitHubWriteBudget and the publisher's scope in place but the retry not consulting it (the red run predates the follow-up commit that moved the ceiling onto the typed configuration; the assertions are unchanged):

[ERROR]   GitHubWriteRetryTest.aThrottledWriteIsNotRepeatedOnceTheReviewHasSpentItsBudget expected: <3> but was: <4>
[ERROR]   GitHubWriteRetryTest.theStopIsSilentWhenWarningsAreOff expected: <2> but was: <4>
[ERROR]   ReviewWriteBudgetTest.aReviewStopsRetryingOnceItsBudgetIsSpentAndSaysSoBesideTheFindingsItCouldNotPost:102 attempts: 16 ==> expected: <5> but was: <16>
[ERROR] Tests run: 47, Failures: 3, Errors: 0, Skipped: 0

A third red came from review: the #784 block-miss line ("retried, but without the floor") was written before the budget gate, so a write the budget stopped was logged as both retried and not retried:

[ERROR]   GitHubWriteRetryTest.aStoppedWriteIsNotAlsoReportedAsRetriedOnUnrecognisedBlockWording [...] ==> expected: <true> but was: <false>
[ERROR] Tests run: 45, Failures: 1, Errors: 0, Skipped: 0

ReviewWriteBudgetTest drives GitHubReviewClient's real default methods (the retry, the pacer and the lost-writes accounting all execute) for the reason RescuedFindingLostWriteTest does: a mocked client stubs the seam under test away. With a 1-second budget and Retry-After: 1, two findings cost five HTTP attempts where sixteen were possible, and the review body names both findings and the budget.

Green:

[INFO] BugInstance size is 0
[INFO] BUILD SUCCESS            (clean compile spotbugs:check spotless:check)
[INFO] Tests run: 3660, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS            (clean test)
GAPS: none                      (every added line and branch under src/main covered, #820's included)

Checklist

  • My code follows the project's coding standards
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • My changes generate no new warnings or errors

Screenshots / Logs

The crossing, once per review, and the per-write stop after it:

WARN The review of owner/repo #801 has spent its 1s write-retry budget waiting on GitHub's rate limit (1s, the wait for an inline comment on owner/repo #801 included) — later throttled writes in this review are not retried, and the review body names the findings they carried
WARN GitHub throttled an inline comment on owner/repo #801 after the review's write-retry budget was spent — not retried, so the content is lost unless a later route lands it. status=403 retry-after=1 body={"message":"You have exceeded a secondary rate limit."}

And the review body's section:

ThrillhouseBot found 2 issue(s) GitHub accepted no review thread for — this review spent its 1s write-retry budget waiting on GitHub's rate limit, so later writes were not retried; re-run /review to post them:

Additional Notes

Refused first attempts after the crossing still go through the pacer, so a review with many findings left costs about one second per remaining route in pacing rather than nothing; that is bounded, and it is the price of never withholding a first attempt. The summary comment, thread resolution and labels are posted by the orchestrator outside postReview and are single writes under the per-call bound, which is what the issue measured as fine.

…ation

GitHubApiError decides two things from the words of a 403 body: whether
the failure is a throttle worth repeating at all, and whether it is the
content-creation block the 30-second floor is sized against. Both are
fixed alternations of specific phrases, so a body GitHub words
differently matched neither. A missed block dropped the floor and spent
the whole budget on the linear 5s/10s/15s inside a window three times as
wide, and a missed throttle was worse: the call was read as a permission
refusal and failed fast with no repeat, so the generated content was
lost on the first refusal. Neither miss left anything in the log.

The headers GitHub documents for its limits are read before any wording
and decide the class on their own: Retry-After for a secondary limit and
x-ratelimit-remaining: 0 for a primary one. A 403 carrying either is a
throttle whatever the body says, and a Retry-After is honoured as the
wait directly. What the headers cannot decide is the 403 with neither,
which is how the measured block presented (remaining=4771): a permission
refusal presents identically, so a remaining count above zero rules the
primary limit out and nothing in, and the wording stays the deciding
signal there, which is what GitHub's own documentation says of a
secondary limit.

The phrase list gains only wording GitHub is on record as sending: the
secondary-rate-limits anchor of the documentation_url those responses
carry, which is the one part of the body sent as a token rather than
prose, and the "Request quota exhausted" primary-limit sentence. A 403
that still matches nothing while its body mentions a rate limit,
blocking or abuse is not retried, but the retry now names it and the
body at WARN; a throttle that names a block in unknown words is warned
about once per call when its wait falls back to the linear backoff. The
next wording GitHub adopts is added on evidence rather than guessed at.
…eview budget

GitHubWriteRetry bounds one call at 90 seconds of waiting, and that
bound is per call. A review posts each finding by up to three routes,
the line-anchored comment, the same comment without its suggestion
block and the thread on the file, so a finding GitHub refuses
throughout can wait 3 x 90 seconds, and a review of fifty findings
refused throughout can hold its pull request's dispatcher slot for
roughly 225 minutes. The dispatcher serializes per pull request, so
nothing else starves, but a review in that state is wedged for hours
with nothing in the log saying it is waiting rather than hung.

A review now publishes under a write-retry budget, configured by
GITHUB_WRITE_RETRY_BUDGET with a default of five minutes and turned off
by zero. Every wait the retry is about to serve during the review's
publication is charged to it first. The wait that crosses the ceiling
is still served, so the overrun is bounded by one clamped wait, and is
warned about once, naming the pull request and the budget. After it a
throttled write goes out once and is not repeated: the retry gives it
up exactly as it does once the attempts are spent, so the write takes
the path a write GitHub outlasted already takes, through the file-level
fallback to the review body, and the review body's list of findings
GitHub accepted no thread for says the budget is the reason and asks
for a re-run. A first attempt is never withheld, since GitHub may have
reopened and a write that lands is a finding saved.

The ledger is thread state, for the reason GitHubLostWrites keeps its
deliveries on the thread: the retry that charges it sits behind the
REST client interface with no handle to be passed one through, and a
review's routes run one after another on the thread publishing it. The
on-demand commands and thread replies post outside a review and keep
the per-call bound alone.
@devops-thiago devops-thiago added this to the v0.6.8 milestone Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@thrillhousebot

thrillhousebot Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

This PR adds a per-review write-retry budget (GITHUB_WRITE_RETRY_BUDGET, default 5m) so that a review throttled by GitHub on every finding stops retrying after the budget is crossed instead of holding its PR dispatcher slot for hours; the crossing wait is served once and warned about, later throttled writes go out once unretried, and the review body's unanchored-findings section names the budget and asks for a re-run. Alongside it (#784), 403s whose body reads like a throttle or block in wording the classification whitelists do not know are now written down at WARN with the response diagnostics instead of failing silently like permission refusals, without widening either whitelist.

Description vs. Implementation

No mismatch found between the PR description and the change.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
    A["postReview(request) called"] --> B["writeBudget.within(review)"]
    B --> C{"budget <= 0 or ledger already open?"}
    C -- "yes" --> D["publishReview(post) unchanged"]
    C -- "no" --> E["open ThreadLocal ledger for review"]
    E --> F["publishReview: post findings via routes"]
    F --> G["route write fails, GitHubWriteRetry.retryDelay() runs"]
    G --> H{"isThrottled()?"}
    H -- "no (refusal)" --> I["warn on unrecognised throttle wording; give up write"]
    H -- "yes" --> J{"attempt >= MAX_ATTEMPTS?"}
    J -- "yes" --> K["give-up WARN with diagnostics; write fails"]
    J -- "no" --> L["compute clamped wait"]
    L --> M{"GitHubWriteBudget.admits(wait)"}
    M -- "ledger exhausted" --> N["budget-spent WARN; no wait; write falls to next route or review body"]
    M -- "wait crosses ceiling" --> O["serve the wait; mark exhausted; warn once naming review"]
    M -- "within budget" --> P["sleep; retry write"]
    O --> P
    F --> Q["build review body: unanchored section reads exhausted()"]
    Q --> R["review created"]
    E --> S["finally: close ledger"]
Loading

Changes Overview

  • Files changed: 13
  • Lines added: +1280
  • Lines removed: -15

Changed Files

File Change Summary
.env.example Modified Documents optional GITHUB_WRITE_RETRY_BUDGET with ceiling semantics and 0-disables note.
CHANGELOG.md Modified Unreleased entries for the #784 wording-miss reporting fix and the #734 per-review write-retry budget.
README.md Modified Adds GITHUB_WRITE_RETRY_BUDGET row to the configuration table with behavior and default 5m.
src/main/java/dev/thiagogonzaga/thrillhousebot/config/ThrillhouseConfig.java Modified Declares write-retry-budget key in GitHubConfig namespace with 5m default and zero-disables semantics.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java Modified Widens THROTTLE_WORDING with documented secondary-limit anchor and request-quota phrases; adds THROTTLE_HINT/BLOCK_HINT and hasUnrecognisedThrottleWording()/hasUnrecognisedBlockWording() report predicates.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudget.java Added New thread-local per-review ledger: within() opens/closes a scope, admits() charges waits and flips exhausted on crossing, exhausted() feeds the review-body note; 0/negative budget disables.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java Modified retryDelay() now asks GitHubWriteBudget.admits() before serving any wait and gives unretried writes a distinct WARN; adds warnIfWordingWasMissed() for unrecognised throttle/block wording.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java Modified postReview(request) runs publishReview inside writeBudget.within(); unanchored-findings section appends a budget-spent explanation with re-run ask when exhausted() is set.
src/main/resources/application.properties Modified Adds GITHUB_WRITE_RETRY_BUDGET comment block and property with 5m default.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiErrorTest.java Modified Adds cases pinning secondary-limit anchor and request-quota classification plus unrecognised-wording miss reporting in both directions.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudgetTest.java Added Pins ledger arithmetic: outside-review admission, crossing, once-per-review warning, zero budget off-switch, nested rejoin, finally-close, shared default.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetryTest.java Modified New nested suites: unrecognised-throttle/block wording reports and per-review budget stop conditions with attempt/sleep counts.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewWriteBudgetTest.java Added End-to-end publisher test with real GitHubReviewClient default methods: budget exhaustion caps attempts and review body names findings and budget.

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 0
🔵 Low 1

Things to double-check

1 lower-confidence finding
  • LOW: Warn claims linear backoff lost the 30s floor even when Retry-After dictates the wait (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:302) (low confidence — verify before acting)

⚠️ Required CI Checks Status

Some required checks are still pending or have failed:

Check Type Status Detail
trivy check-run ⏳ Pending -
format check-run ⏳ Pending -
frontend check-run ⏳ Pending -
test check-run ⏳ Pending -
dependency-review check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • LOW: Warn claims linear backoff lost the 30s floor even when Retry-After dictates the wait (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:302)
    The #784 block-miss warning states: "GitHub throttled {} with wording that names a block but matched no known content-creation wording — retried on the linear backoff without the {}s floor; ...". The claim is emitted whenever error.hasUnrecognisedBlockWording() is true, but that predicate does not consider whether the response carried a Retry-After header. GitHubApiErrorTest (added in this diff) pins that the documented headers are read before any wording — a 403 with Retry-After: 45 yields retryDelay of 45s — so for a reworded block body that also carries Retry-After, the actual wait served is the header value (capped by MAX_DELAY_PER_ATTEMPT), not the linear 5s/10s/15s the log describes. Input not in the diff: a 403 whose body says something like "temporarily blocked from creating comments" (unknown block wording) accompanied by a Retry-After header. Consequence is limited to an inaccurate operator-facing log line, and the scenario may be rare, so risk is low; verify GitHubApiError.retryDelay() precedence for header-vs-linear before treating the message as ground truth. A fix would condition the message on the absence of a Retry-After response header.

@thrillhousebot thrillhousebot Bot added enhancement New feature or request java Pull requests that update java code testing Test coverage and test quality labels Sep 7, 2026
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…near backoff

The warning for a throttle that names a block in unknown wording said
the write was retried on the linear backoff. That is only what a body
with no headers gets: a Retry-After or a reset instant is honoured
before the wording is read, so the wait can be either of those. What is
true in every case is that the wait goes out without the 30-second
floor the block is sized against, so the line says that and no more.
…ation

The budget was an instance sized from the raw config, held on the
publisher through a second constructor so a test could hand in a
smaller one. That gave the publisher a nine-parameter constructor and
left the budget class shaped like a record for a single field, both of
which SonarCloud flagged. The publisher already holds the typed
configuration, so it reads the ceiling from there when it opens a
review's ledger, and the ledger itself is what it always was: thread
state, opened by whoever names the ceiling. The class keeps no
configuration and no instances; a test sizes the ceiling through the
mocked configuration like every other knob the publisher reads.
@devops-thiago

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:302 — Warn claims linear backoff lost the 30s floor even when Retry-After dictates the wait

Right: GitHubApiError.retryDelay honours a Retry-After (and a reset instant) before the linear fallback, and hasUnrecognisedBlockWording() says nothing about which of those produced the wait, so "retried on the linear backoff" was only true for a body with no headers. What holds in every case is that the wait goes out without the 30-second floor the block is sized against, so the line now says that and no more: "retried, but without the 30s floor that block is sized against". Fixed in 2a6b80d on #820, where the line originates, and carried here as 6bcf55b; the hasUnrecognisedBlockWording() javadoc was tightened the same way.

@thrillhousebot

thrillhousebot Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

The next review will close every previous finding this comment names by its path:line and title; anything it does not name stays open.

@thrillhousebot

thrillhousebot Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 1
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:302 — Warn claims linear backoff lost the 30s floor even when Retry-After dictates the wait
  • Previous findings still open: 0

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot closed 1 previous finding(s) this round:

  • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:302 — Warn claims linear backoff lost the 30s floor even when Retry-After dictates the wait

…s served

The block-miss line from #784 says the write is retried without the
floor, and it was written before the review's write-retry budget had
its say. Once the budget is spent the very next check refuses the wait,
so the log said the write was retried and then that it was not. The
report now runs only after the wait has been admitted. The budget line
carries the response diagnostics, so nothing is lost for the write it
stops.

The test that pins the ledger being closed after a failing review is
also reshaped so the assertThrows lambda holds one call.
@thrillhousebot

thrillhousebot Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 0
  • Previous findings resolved: 1
    • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:250 — Block-miss warning says 'retried' when the review budget refusal stops the retry
  • Previous findings still open: 0

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check frontend is pending
  • Check format is pending
  • Check trivy is pending
  • Check test is pending
  • Check dependency-review is pending

ThrillhouseBot closed 1 previous finding(s) this round:

  • src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:250 — Block-miss warning says 'retried' when the review budget refusal stops the retry

@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

thrillhousebot[bot]
thrillhousebot Bot previously approved these changes Sep 7, 2026

@thrillhousebot thrillhousebot Bot 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.

Everything's coming up Thrillhouse! 🎉

No issues found in this PR.

…udget

# Conflicts:
#	CHANGELOG.md
#	src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetryTest.java
Merging main brought #784's copy of warnIfWordingWasMissed alongside this
branch's, so the class carried the method twice and called it twice on the
throttled path: once before the review's write-retry budget was consulted and
once after. The early call is the one #784 shipped and the later one is what
this branch moved behind the budget check, so a write the budget stopped was
also reported as retried, and a throttle naming an unknown block was written
down twice. Only the post-budget call survives, with the javadoc that explains
why it sits there.
@thrillhousebot

thrillhousebot Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 2
  • Previous findings resolved: 0
  • Previous findings still open: 0

⚠️ Large PR — partial coverage. 13 file(s) were only partially reviewed because the model's response was cut at its length cap (max-output-tokens) — findings up to the cut were kept (src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewWriteBudgetTest.java, src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudgetTest.java, src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetryTest.java, src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudget.java, src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java, src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java, src/main/java/dev/thiagogonzaga/thrillhousebot/config/ThrillhouseConfig.java, src/main/resources/application.properties, src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisherTest.java, .env.example, +3 more), so this covers only part of the diff.

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot noted 2 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • LOW: Budget ledger is thread-local, so a review's writes on different threads bypass the ceiling (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudget.java:96)
    The ledger is stored in a ThreadLocal (OPEN). The PR description states 'a review's routes run one after another on the thread publishing it', but the diff does not show that postReview and all its routes are guaranteed to run on the same thread. If any route (e.g., the file-level fallback, or a nested call) executes on a different thread, admits sees no ledger and admits every wait, so the per-review budget is not enforced. The description's claim is a stated assumption, not a demonstrated invariant in the provided code. Verify that publishReview and all its downstream calls (inline comments, file-level fallbacks, review body) execute on the same thread as postReview; if not, the budget is bypassed for those writes.
  • LOW: ThreadLocal ledger is not cleared if the review's work spawns a new thread that outlives the review (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudget.java:96)
    The ledger is thread-local. If publishReview (or any route) spawns a new thread that performs a write after the review's within scope has closed, that thread will see no ledger and admit every wait, bypassing the budget. The PR description claims routes run on the publishing thread, but the diff does not show that no new threads are spawned. Verify that no route or downstream call creates a new thread; if it does, the budget is not enforced for those writes.

⚠️ Large PR — partial review. 13 file(s) were only partially reviewed because the model's response was cut at its length cap (max-output-tokens) — findings up to the cut were kept (src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewWriteBudgetTest.java, src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudgetTest.java, src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetryTest.java, src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudget.java, src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java, src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java, src/main/java/dev/thiagogonzaga/thrillhousebot/config/ThrillhouseConfig.java, src/main/resources/application.properties, src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisherTest.java, .env.example, +3 more); the findings and verdict below cover only the reviewed portion.

@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

@thrillhousebot thrillhousebot Bot 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.

No new issues in this revision, but 2 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check frontend is pending
  • Check test is pending
  • Check format is pending
  • Check trivy is pending
  • Check dependency-review is pending

Additionally, No new issues in this revision, but 2 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check format is pending
  • Check test is pending
  • Check frontend is pending
  • Check trivy is pending
  • Check dependency-review is pending

Additionally, No new issues in this revision, but 2 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.

@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 0
  • Previous findings still open: 2

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • LOW: Review body attributes every unanchored finding to the spent budget even when its cause differs (src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java:506)
    unanchoredFindingsBody produces a blanket causal sentence whenever the review's ledger reports exhaustion: it appends " — this review spent its ... write-retry budget waiting on GitHub's rate limit, so later writes were not retried; re-run /review to post them" over the whole section. The method's own javadoc (added in this diff) states the section carries two different causes — the finding "GitHub accepted no review thread for" can be "a diff and a post GitHub simply refused" — so a finding whose line and file routes were refused before the review crossed the budget, or refused with a non-throttle failure the retry never repeats regardless of any budget, lands in the same list and inherits the budget attribution. For that finding the appended text is false: its loss was not caused by the budget, and the "re-run /review to post them" guidance fails, because a re-run refuses the same anchoring again. The maintainer is sent re-running the bot for a defect a re-run cannot fix (and the javadoc's narrower claim — "every finding below it that was refused after the crossing shares the reason" — is true only for the subset, not for the section the sentence is attached to). The green ReviewWriteBudgetTest.aReviewStopsRetryingOnceItsBudgetIsSpentAndSaysSoBesideTheFindingsItCouldNotPost does not cover this: both of its findings are throttled throughout, so the section's cause is accurate in that fixture; a review with mixed causes would still render the same text. Restrict the sentence to findings refused after the crossing (track post-crossing unanchored results separately), or attribute it per finding.

devops-thiago added a commit that referenced this pull request Sep 15, 2026
…d to (#834)

## What type of PR is this?

- [x] 🐛 Bug fix
- [x] ✅ Test

## Description

`GitHubWritePacer.DEFAULT_MAX_WAIT` is 90 s and `GitHubWritePacerTest`
pins it to `GitHubWriteRetry.TOTAL_BUDGET` (#723: waiting for a pacing
slot must never be worse than being refused and repeated). But the pacer
reads `thrillhousebot.github.write-max-wait` first, and that property
shipped as `60s`; the constant only applies to a build with no
`application.properties`. So the pin guarded a value production never
used, and a caller gave up on its slot 30 s before the
refusal-and-repeat path would have.

- `application.properties`, `.env.example` and the README default now
say `90s`, with the reason next to the property.
- The constant's javadoc states where the effective value comes from.
- A second pin reads the shipped property through SmallRye with
Quarkus's `DurationConverter` (the `DevSchemaManagementTest` pattern)
and asserts it equals the constant, so the two cannot drift again.

The second half of #830 (5/10/15 s backoff on a generic secondary limit
with neither header, where GitHub's guidance is 60 s) is a recorded
decision on the issue and needs no code; #784's new WARN will show if
the case is ever observed.

## Related Issues

Fixes #830

## How Has This Been Tested?

- [x] Unit tests

Red on unfixed code:
`GitHubWritePacerTest.theShippedPropertyDefaultCarriesTheSameCeiling:189
expected: <PT1M30S> but was: <PT1M>`. Green after the property change.
`./mvnw -B clean compile spotbugs:check spotless:check` (`BugInstance
size is 0`) and the full `./mvnw -B clean test`: 3632 tests, 0 failures.
Patch coverage: no uncovered lines (the `src/main` change is javadoc
only).

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

N/A

## Additional Notes

Touches `application.properties` and `README.md` near the lines #827
adds for `GITHUB_WRITE_RETRY_BUDGET`; if #827 lands first this rebases
cleanly (different lines), and the `[Unreleased]` changelog section will
need the usual keep-both resolution.
…udget

# Conflicts:
#	.env.example
#	README.md
#	src/main/resources/application.properties
@devops-thiago

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java:506 — Review body attributes every unanchored finding to the spent budget even when its cause differs

Taken in 2c3e79b. The section does mix causes, and the sentence no longer claims the budget for all of it: it now says the review also spent its write-retry budget, that a finding refused after that was not retried, and that re-running /review may post it. Attributing the sentence per finding would need the budget state captured at every route's give-up and carried on the unanchored result; the rewording removes the false claim without that plumbing.

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudget.java:96 — Budget ledger is thread-local, so a review's writes on different threads bypass the ceiling

Checked, and there is no such write. ReviewPublisher.postReview opens the ledger with GitHubWriteBudget.within(...) and runs publishReview synchronously inside it, and nothing on the write path hops threads: no executor, CompletableFuture, async or parallel call in ReviewPublisher, GitHubWriteRetry, GitHubWritePacer, GitHubReviewClient or GitHubCommentClient. The retry that charges the ledger waits with Thread.sleep on the calling thread (the review's virtual thread), so every wait it serves is charged to the review that is publishing. The class javadoc records that invariant and why it is thread state (the same reason GitHubLostWrites keeps its deliveries on the thread: the retry sits behind the REST client interface with no handle to pass a ledger through).

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteBudget.java:96 — ThreadLocal ledger is not cleared if the review's work spawns a new thread that outlives the review

No route spawns a thread (see the previous item), and the ledger cannot be left behind: within removes it in a finally, and a thread other than the publishing one never sees it in the first place, because a ThreadLocal is not inherited. A write on such a thread would get no ledger, which is the per-call bound the class applies outside a review, not a stale budget.

@thrillhousebot

Copy link
Copy Markdown
Contributor

The next review will close every previous finding this comment names by its path:line and title; anything it does not name stays open.

@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 0
  • Previous findings resolved: 1
    • src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java:506 — Review body attributes every unanchored finding to the spent budget even when its cause differs
  • Previous findings still open: 0

@thrillhousebot thrillhousebot Bot 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.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check trivy is pending
  • Check frontend is pending
  • Check test is pending
  • Check dependency-review is pending
  • Check format is pending

ThrillhouseBot closed 1 previous finding(s) this round:

  • src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java:506 — Review body attributes every unanchored finding to the spent budget even when its cause differs

@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

@thrillhousebot thrillhousebot Bot 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.

Everything's coming up Thrillhouse! 🎉

No issues found in this PR.

@devops-thiago
devops-thiago merged commit 6fdeee4 into main Sep 15, 2026
20 checks passed
@devops-thiago
devops-thiago deleted the fix/734-write-retry-budget branch September 15, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request java Pull requests that update java code testing Test coverage and test quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Worst-case write-retry latency holds a PR's dispatcher slot for hours

1 participant