Skip to content

feat(github): tell the pull request when a throttled reply was dropped - #598

Open
devops-thiago wants to merge 3 commits into
release/v0.6.0from
fix/578-dropped-write-notice
Open

feat(github): tell the pull request when a throttled reply was dropped#598
devops-thiago wants to merge 3 commits into
release/v0.6.0from
fix/578-dropped-write-notice

Conversation

@devops-thiago

@devops-thiago devops-thiago commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What type of PR is this?

  • ✨ Feature

Description

The backoff from #568 is bounded, so a persistently throttled reply is still dropped once the
attempts are spent. Today that is logged and nothing else: from the pull request the command simply
never answered, which is indistinguishable from the silent-decline class #538 fixed. The user cannot
tell whether the bot ignored them, crashed, or lost the post — and nothing says the right move is to
run it again.

The catch the issue names. The obvious remedy — reply "this was throttled, please re-run" — is
itself a createComment: the exact call being throttled, sent at the exact moment GitHub is refusing
it. Giving it its own budget just spends more attempts on the same refusal.

So the notice is never posted on its own. GitHubLostWrites holds it, and the next content the
bot successfully lands on that pull request carries it up front:

Warning

An earlier reply on this pull request was never posted. GitHub was rate-limiting the bot and
the retries ran out, so work it had already finished was thrown away. If you were waiting on an
answer, run the command again.

That costs no additional content-creating request — which is the whole point of #579 — cannot be
throttled separately from the post it travels with, and appears on the pull request the loss
happened on rather than in a log the user cannot read. Of the three options the issue weighs, this
is the "persist the pending notice and post it on the next successful interaction" one; it also
needs no change in review/ or webhook/, so every posting path inherits it at the client boundary
instead of each fail-soft handler having to learn about throttling.

Which calls do what. createComment and createReview both land in the conversation, so they
carry a notice. An inline comment and a thread reply are anchored to a diff line and are a poor place
to announce an unrelated loss, so they leave a notice behind without carrying one — losing an inline
finding is still a loss the PR should hear about. updateComment does neither: it identifies its
target by comment id, so this layer cannot tell which pull request it belongs to.

What it deliberately does not claim.

  • Only a throttle counts. A permission 403 or a 422 is a defect to fix, not a command to re-run,
    and announcing those would put a warning on every single comment.
  • The wording does not name the command. This layer sees a comment on a pull request, not the
    /describe or /improve behind it. An honest "if you were waiting on an answer, run it again"
    beats a guess.
  • A notice is cleared only once the post carrying it has landed, so a post that is itself dropped
    does not take the notice with it — and a loss that arrives while a notice is in flight is still
    announced next time.
  • It cannot promise delivery on a pull request the bot never writes to again. That case is no worse
    than today's log-only behaviour.

Bounded, and quiet when stale. A notice is forgotten after six hours, because "a reply was
dropped" glued onto a comment days later is noise rather than a signal, and the registry holds at
most 200 pull requests so a sustained outage cannot grow it without end.

Why prepending is safe. ReviewContextLoader.isBotSummaryComment matches the heading on any
line, not at the start of the body — precisely because the truncation banner already precedes it —
and no code in the repository matches a comment body with startsWith.

How this composes with #568 and #579. #579's pacer keeps the burst from being produced, #568's
backoff absorbs the throttling pacing cannot prevent, and this is the last resort for when even that
runs out. The three are strictly ordered by cost, and this one is the only one the user ever sees.

Related Issues

Fixes #578

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

GitHubLostWritesTest drives the carrier directly on a hand-held clock: a quiet PR carrying nothing,
a dropped reply announced on the next comment, the notice said once rather than on everything
afterwards, a notice kept when the post meant to deliver it is itself dropped, a loss that lands
while a notice is in flight, scoping to the PR that lost the post, a permission refusal staying
silent, the six-hour staleness cut-off, the registry cap (including a PR already holding a notice
still counting at capacity) and an expired notice making room for a new one.

GitHubDroppedCommentNoticeTest is a @QuarkusTest driving a real REST client against a loopback
GitHub. It asserts on the body GitHub receives, because that is the only thing the user ever
sees; the log line the retry already writes is exactly what the issue says is not enough.

Red/green proof

GitHubDroppedCommentNoticeTest compiles unchanged against the base commit, so it was run there:

2026-08-12 00:39:08,950 WARN  [dev.thiagogonzaga.thrillhousebot.github.GitHubWriteRetry] (main) GitHub still throttling a comment on owner/repo #5882 after 3 attempts — the generated content is lost and the command has to be re-run. status=403 retry-after=0 x-ratelimit-remaining=0 body={"message":"You have exceeded a secondary rate limit. Please wait a few minutes before you try again.","documentation_url":"https://docs.github.com/rest"}
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.392 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.github.GitHubDroppedCommentNoticeTest
[ERROR] dev.thiagogonzaga.thrillhousebot.github.GitHubDroppedCommentNoticeTest.aReplyGitHubThrewAwayIsAnnouncedOnTheNextCommentThatLands -- Time elapsed: 0.452 s <<< FAILURE!
org.opentest4j.AssertionFailedError: {"body":"the answer to /describe"} ==> expected: <true> but was: <false>
	at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:232)
	at dev.thiagogonzaga.thrillhousebot.github.GitHubDroppedCommentNoticeTest.aReplyGitHubThrewAwayIsAnnouncedOnTheNextCommentThatLands(GitHubDroppedCommentNoticeTest.java:135)

The assertion message is the whole defect in one line: the very next comment GitHub receives is
{"body":"the answer to /describe"} and nothing else. The log knows a reply was thrown away and the
pull request never hears about it.

Gates

  • ./mvnw -B spotless:apply → BUILD SUCCESS
  • ./mvnw -B clean compile spotbugs:check spotless:checkBugInstance size is 0, BUILD SUCCESS
  • ./mvnw -B clean testTests run: 2787, Failures: 0, Errors: 0, Skipped: 0
  • JaCoCo ∩ git diff -U0 fda4bc7...HEAD0 uncovered lines, 0 uncovered branches across the 79
    trackable changed main-code lines (GitHubLostWrites 62, GitHubReviewClient 13,
    GitHubCommentClient 4)

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 loss still says so in the log, and now says what will happen about it:

WARN [GitHubWriteRetry] GitHub still throttling a comment on owner/repo #14 after 3 attempts — the generated content is lost and the command has to be re-run. status=403 retry-after=0 x-ratelimit-remaining=0 body={"message":"You have exceeded a secondary rate limit. ..."}
WARN [GitHubLostWrites] Lost a throttled post on owner/repo #14 — the next comment the bot lands there will say so (1 now pending)

Additional Notes

Every file touched is inside dev.thiagogonzaga.thrillhousebot.github; no caller signature changed
and no fail-soft handler in review/ or webhook/ needed to move.

The companion PR for #579 (pacing content-creating calls) touches a disjoint set of files —
GitHubWriteRetry, a new limiter, ThrillhouseConfig and application.properties — so the two
merge cleanly in either order. Once both land, the notice becomes rarer: the pacer stops most of the
bursts that produce the throttling in the first place.

Review follow-up

"Concurrent carrying posts can clear a notice that arrived in flight." Correct, reachable, and
fixed — this is the one direction the feature cannot afford to fail in.

The original settle subtracted each carrier's snapshot from one shared count. With two posts on
the same pull request in flight, both having read pending = 1, and a third post thrown away
between their reads and their completions:

step shared count what happened
both carriers read 1 each carries notice(1)
a third post is dropped 2 nobody has carried this one
carrier A settles (carried = 1) 1 2 > 1, so subtract
carrier B settles (carried = 1) 0 1 > 1 is false, so the entry is removed

The second loss is retired having been announced by nobody, and the user is never told their content
was dropped — precisely the silence this PR exists to remove. I had noted the over-announcing side
of this overlap as benign and accepted it; the review is right that the clearing side is the opposite
direction and is not acceptable.

The fix replaces the outstanding count with two monotonic watermarks, lost and announced
(pending = lost - announced). A delivered post advances announced towards the lost value it
actually carried, and the watermark never moves backwards, so a second carrier holding the same
snapshot finds announced >= carried.lost and leaves the entry alone. Replaying the table above:
carrier B advances announced to 1, carrier A is a no-op, and pending stays 1 — the third loss is
still waiting and rides the next comment. The overlap can now only ever repeat a notice, never drop
one, and repeating is the harmless direction.

No locking was added: serializing carrying per target would hold a lock across an HTTP call that
may itself back off for up to a minute.

Red/green proof

GitHubLostWritesTest.aLossThatLandsWhileTwoPostsCarryTheSameNoticeIsStillAnnouncedAfterwards
nests one carrying inside another so the inner post runs entirely between the outer post's read
and its completion — the interleaving described above, deterministically. Against this PR's previous
head (b0973e7), where the other 14 cases in the class still pass:

[ERROR] Tests run: 15, Failures: 1, Errors: 0, Skipped: 0 -- in dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest
org.opentest4j.AssertionFailedError: the loss recorded between the two overlapping posts was retired without ever being announced; the next comment carried: "" ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest.aLossThatLandsWhileTwoPostsCarryTheSameNoticeIsStillAnnouncedAfterwards(GitHubLostWritesTest.java:181)

the next comment carried: "" is the defect verbatim: the pull request says nothing at all.

Second round: "Announced (zero-pending) entries are never removed and can exhaust the registry cap"

Correct, and a regression the watermark fix above introduced. Before it, settle removed the entry
the moment nothing was outstanding; after it, a delivered entry sat at pending == 0 with lost == announced > 0 and only the TTL could retire it.

What evicted an entry, before this round. Two paths, both TTL:

snapshot() :169   pending.remove(target, loss)          — expired, on the read path
remember() :194   pending.values().removeIf(expired)    — expired, on the write path
announce() :184   computeIfPresent(...)                 — never removed anything

So the TTL does not cover it. It bounds growth — the map never exceeds the cap and a settled
entry is swept six hours after its last loss, since announce preserves loss.at(). What it does
not do is free the slot in the meantime. Once DEFAULT_MAX_TARGETS pull requests have lost a post
inside one six-hour window, every slot can be a settled entry, and the next pull request to lose one
takes the pending.size() >= maxTargets && !pending.containsKey(target) branch and is only logged —
the silence this PR removes, arriving from the other end, and with nothing outstanding to protect
the slots it was denied.

The fix drops an entry as soon as its last outstanding notice is delivered, so a slot is held
only while a pull request is genuinely still owed one — which is what DEFAULT_MAX_TARGETS claims
to bound.

The guard the finding asked for is real, and it is load-bearing. Both watermarks restart when a
later loss recreates an entry, so a carrier still in flight from the previous run holds a snapshot
whose lost can equal the new run's and would retire a loss it never carried. Each run of losses
now carries an id, and a delivery only counts when loss.id() == carried.id(). This is not
defensive coding: deleting that one condition and leaving everything else in place makes
aCarrierLeftOverFromASettledEntryCannotRetireALaterLoss fail.

Red/green proof

GitHubLostWritesTest.aRegistryFullOfAlreadyDeliveredNoticesStillHasRoomForANewLoss fills both
slots of the two-target fixture, has both notices delivered, then loses a post on a third pull
request. Against the previous head (c942633), with the other 16 cases in the class still passing:

[ERROR] Tests run: 17, Failures: 1, Errors: 0, Skipped: 0 -- in dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest
org.opentest4j.AssertionFailedError: the registry was full of already-delivered notices, so the new loss was only logged; the next comment carried: "" ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest.aRegistryFullOfAlreadyDeliveredNoticesStillHasRoomForANewLoss(GitHubLostWritesTest.java:240)

The backoff from #568 is bounded, so a persistently throttled reply is still
dropped once the attempts are spent. That was logged and nothing else: from
the PR the command simply never answered, which is indistinguishable from
the bot ignoring the user, and nothing told them the right move is to run it
again.

The obvious remedy — reply "this was throttled, please re-run" — is itself a
createComment: the exact call being throttled, sent at the moment GitHub is
refusing it. So the notice is not posted on its own. GitHubLostWrites holds
it, and the next content the bot successfully lands on that pull request
carries it up front. That costs no additional content-creating request,
cannot be throttled separately from the post it travels with, and appears
where the loss happened rather than in a log the user cannot read.

Comments and reviews carry a notice; inline comments and thread replies are
anchored to a diff line, so they leave one behind without carrying one. Only
a positively identified throttle counts — a permission refusal is a defect
to fix, not a command to re-run. A notice is cleared only once the post
carrying it has actually landed, is forgotten after six hours rather than
being glued onto a much later comment, and the registry is bounded so a
flood of losing pull requests cannot grow it without end.

Fixes #578
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

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

Scanned Files

None

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot PR Summary

What this PR does

Records throttled writes that exhaust the retry budget in a per-PR registry, prepends a Markdown warning to the next successful comment or review body, and records losses from inline comments and thread replies without carrying the notice.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
    A["Throttled post dropped after retry budget"]
    B["GitHubLostWrites.recording catches WebApplicationException"]
    C{"GitHubApiError.isThrottled?"}
    D["remember target in pending registry"]
    E["Next createComment or createReview on same PR"]
    F["carrying reads pendingCount"]
    G{"count greater than 0?"}
    H["prepend notice above body"]
    I["GitHubWriteRetry sends post"]
    J{"post succeeds?"}
    K["settle subtracts carried notices"]
    L["Notice stays pending for next post"]
    A --> B
    B --> C
    C -- "yes" --> D
    D --> E
    E --> F
    F --> G
    G -- "yes" --> H
    H --> I
    I --> J
    J -- "yes" --> K
    J -- "no" --> L
Loading

Changes Overview

  • Files changed: 5
  • Lines added: +659
  • Lines removed: -13

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubCommentClient.java Modified createComment now carries pending notices and records throttled losses.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubLostWrites.java Added Registry tracking throttled losses per PR with count, TTL and cap; carrying/recording wrappers.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubReviewClient.java Modified createReview carries notices; inline comments and thread replies only record losses.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubDroppedCommentNoticeTest.java Added Quarkus loopback test asserting the next received comment body carries the warning.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubLostWritesTest.java Added Unit tests for notice carry, once-only, expiry, cap, and PR scoping.

Risk Assessment

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

Key Findings

  • MEDIUM: Concurrent carrying posts can clear a notice that arrived in flight (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubLostWrites.java:102)

⚠️ CI Checks Status

Some checks are still pending or have failed:

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

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

Comment thread src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubLostWrites.java Outdated
Subtracting each carrier's snapshot from one shared count loses a notice
when two posts on the same pull request overlap. Both read pending=1 and
carry notice(1); a third post is thrown away between their reads, taking
the count to 2; the first carrier to settle subtracts to 1 and the second
finds 1 > 1 false and removes the entry. The loss recorded between them was
announced by nobody and is now gone, so the user is never told their content
was dropped — the exact silence this feature exists to remove.

Track two monotonic watermarks instead, lost and announced, with pending
their difference. A delivered post advances announced towards the lost value
it actually carried and the watermark never moves backwards, so a second
carrier holding the same snapshot leaves the entry alone. The overlap can
now only repeat a notice, never drop one, which is the harmless direction.

No lock: serializing carrying per target would hold it across an HTTP call
that may itself back off for up to a minute.
@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot — changes since the last review

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

@thrillhousebot thrillhousebot Bot added the testing Test coverage and test quality label Aug 12, 2026
Moving to watermarks left a fully announced entry in the map at zero
pending, where only the six-hour TTL could retire it. Until then it still
counted towards the cap, so once that many pull requests had been told about
a loss inside one window, the next pull request to lose a post was only
logged — silence again, reached from the other end, and with nothing
outstanding to protect the slots it was denied.

Drop an entry as soon as its last outstanding notice is delivered, so a slot
is held only while a pull request is genuinely still owed one.

Deleting it needs an identity guard, because both watermarks restart when a
later loss recreates the entry: a carrier still in flight from the previous
run holds a snapshot whose lost count can equal the new run's and would
retire a loss it never carried. Each run of losses now carries an id, and a
delivery only counts when the id still matches. Removing that check alone
makes aCarrierLeftOverFromASettledEntryCannotRetireALaterLoss fail, so it is
load-bearing rather than defensive.
@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot — changes since the last review

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

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown

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.

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.

1 participant