Skip to content

fix(github): classify a throttle from the body, not from the line the log gets - #753

Merged
devops-thiago merged 1 commit into
fix/746-credential-shapesfrom
fix/747-classify-collapsed
Aug 16, 2026
Merged

fix(github): classify a throttle from the body, not from the line the log gets#753
devops-thiago merged 1 commit into
fix/746-credential-shapesfrom
fix/747-classify-collapsed

Conversation

@devops-thiago

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

Copy link
Copy Markdown
Owner

Stacked on #750 (fix/746-credential-shapes). Based against that branch so the diff reads clean; re-target to main once #750 merges. Only the last commit (afb2d41) belongs to this PR. ci.yml triggers on pull requests into main/develop/release/** only, so the full CI run happens on re-target — the gates below were run locally on this exact tree.

What type of PR is this?

  • 🐛 Bug fix

Description

One change closing two issues, because they are the same coupling seen twice.

isThrottled() and blocksContentCreation() matched against this.body — the string diagnostics() prints. So every narrowing the log line asks for narrowed the retry decision with it, and the consequence is not a shorter wait: GitHubWriteRetry.retryDelay returns Optional.empty() on !isThrottled(), the WebApplicationException is rethrown on the first attempt, and the write is not repeated at all — the pre-#495 behaviour this area exists to prevent. The 30-second floor #738 just widened is never consulted.

#732 is the 512-character cap doing it: a body with a long documentation_url or echoed headers ahead of the message classifies as a refusal. Equally true at v0.6.3.

#747 is the bound #740 put on the redaction input. v0.6.3 had exactly one path by which wording deeper than the cap still survived — redaction compressing a long credential-shaped prefix to *** and carrying the message forward — and cutting to 1024 before redacting closed it. Threshold sweep from the audit, body = "Bearer " + "a".repeat(n) + " " + <content-creation block>:

prefix v0.6.3 v0.6.4
900 chars throttled, PT30S throttled, PT30S
1010 chars throttled not throttled, PT5S
4000 chars throttled not throttled, PT5S

Same remedy for both, so they land together.

The fix. One collapse pass now feeds two readings that are kept apart in a small Body record. The logged line is unchanged — same bounded → redacted → capped order, same ellipsis, same 512 characters. Classification reads the collapsed body bounded at 8 KB and nothing else. The bound is still wanted (the entity is read with no size limit of its own, and #731 is about not letting the configured host set the cost of explaining a failed write), but both patterns are flat literal alternations with no backtracking, so widening the window costs a linear scan; the quadratic shape #731 found is in the credential redaction, which still sees only its own 1024.

Deliberately the unredacted text. Masking runs before the classifier could read it, and a mask that swallowed the word blocked turned a content-creation block into a permission refusal — the classification tail of the JWT over-match in #746. Nothing in that window is ever logged or returned; the two patterns answer yes or no and the string is dropped.

Also, the Instant.ofEpochSecond guard #732 asks for in its second half. Long.parseLong accepts values Instant.ofEpochSecond rejects, and the resulting DateTimeException was thrown from inside derivedDelay, out through GitHubWriteRetry.retryDelay and call, past every catch (WebApplicationException) in the write path. GitHubLostWrites.recording catches that type specifically, so a write that died this way was not remembered as lost either — the write and the record of its loss went together, over one header from an intermediary. A value that cannot be an instant now means what a non-numeric header already means here: unspecified, and the linear fallback takes over.

Neither classification bug is reachable against api.github.com, whose error bodies are ~300 characters with the message first; both need a large body from the configured API host. The header case needs an intermediary sending a ~10^17 reset.

Related Issues

Fixes #747
Fixes #732

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Eight new behavioural assertions, all red on the parent branch (1e0d7fc) in exactly the claimed way and green after. Verbatim, from ./mvnw -o test -Dtest=GitHubApiErrorTest,GitHubWriteRetryTest with only the test files applied:

[ERROR] Failures:
[ERROR]   GitHubApiErrorTest.isStillReadWhenItSitsPastTheLengthCapTheLogLineUses body={"documentation_url":"xxxxx…xxx… ==> expected: <true> but was: <false>
[ERROR]   GitHubApiErrorTest.isStillReadWhenMoreCredentialShapedMaterialPrecedesItThanTheRedactionBoundHolds body=***… ==> expected: <true> but was: <false>
[ERROR]   GitHubApiErrorTest.isNotSomethingTheCredentialMaskCanDeleteBeforeItIsRead body={"message":"prefix *** from content creation"} ==> expected: <true> but was: <false>
[ERROR]   GitHubApiErrorTest.isReadFromABoundedWindowRatherThanFromAnUnboundedBody expected: <true> but was: <false>
[ERROR]   GitHubWriteRetryTest.anOutOfRangeResetHeaderStillFailsAsTheExceptionEveryCallerCatches:332 Unexpected exception type thrown, expected: <jakarta.ws.rs.WebApplicationException> but was: <java.time.DateTimeException>
[ERROR] Errors:
[ERROR]   GitHubApiErrorTest.aRateLimitResetTooLargeToBeAnInstantIsTreatedAsUnspecified » DateTime Instant exceeds minimum or maximum instant
[ERROR]   GitHubApiErrorTest.aRateLimitResetTooSmallToBeAnInstantIsTreatedAsUnspecified » DateTime Instant exceeds minimum or maximum instant
[ERROR]   GitHubWriteRetryTest.anOutOfRangeResetHeaderOnAThrottleFallsBackToTheLinearWait:356 » DateTime Instant exceeds minimum or maximum instant

[ERROR] Tests run: 93, Failures: 5, Errors: 3, Skipped: 0

The escape path in full, from the same run — this is the whole of the second finding:

java.time.DateTimeException: Instant exceeds minimum or maximum instant
	at java.base/java.time.Instant.ofEpochSecond(Instant.java:308)
	at …GitHubApiError.lambda$derivedDelay$0(GitHubApiError.java:325)
	at …GitHubApiError.derivedDelay(GitHubApiError.java:325)
	at …GitHubApiError.retryDelay(GitHubApiError.java:312)
	at …GitHubWriteRetry.retryDelay(GitHubWriteRetry.java:254)
	at …GitHubWriteRetry.call(GitHubWriteRetry.java:189)

Two of the six new GitHubApiError tests are labelled controls rather than proof, and both are green before the fix: doesNotWidenWhatReachesTheLog (the log line keeps its own 512-character cap and its ellipsis whatever the classifier may see) and doesNotMakeAPermissionRefusalLookLikeAThrottle (a 4 KB body with none of the wording is still a refusal, so the wider window did not make the classifier credulous). isReadFromABoundedWindowRatherThanFromAnUnboundedBody is half proof and half honest edge: wording at 4 000 characters is now read, wording at 64 000 is still not.

Gates on this tree:

  • ./mvnw -B spotless:apply → clean
  • ./mvnw -B clean compile spotbugs:check spotless:checkBugInstance size is 0, Error size is 0, spotless clean
  • ./mvnw -B clean testTests run: 3320, Failures: 0, Errors: 0, Skipped: 0
  • jacoco ∩ git diff -U0 fc54d93...HEAD over changed main code → 139 changed lines, zero uncovered lines and zero uncovered branches

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

Additional Notes

GitHubWriteRetry's main code is untouched — the guard belongs where the exception is raised, and the retry loop's contract (a WebApplicationException in, the same one back out) is what the new test pins from the outside.

The 8 KB window is a judgement call, not a measurement: it is sixteen times the log cap and several times any body GitHub sends, chosen so the classifier stops depending on where in a body the message sits while the pass over it stays a linear scan.

… log gets

`isThrottled()` and `blocksContentCreation()` matched against the same
string `diagnostics()` prints, so every narrowing the log line asks for
narrowed the retry decision with it. The 512-character cap did it first
(#732): wording past the cap was not seen, and the failure is total rather
than partial — `GitHubWriteRetry.retryDelay` returns empty on
`!isThrottled()`, so the write is rethrown on the first attempt and not
repeated at all, which is the pre-#495 behaviour this area exists to
prevent. #740 then bounded the redaction input to 1024 characters and
closed the one path by which deeper wording still arrived: v0.6.3 redacted
the whole collapsed body first, so a long credential-shaped prefix
compressed to `***` and carried the message forward into the classified
string (#747). Measured threshold: 900 characters of prefix still
classified, 1010 no longer.

Both readings are now taken from one collapse pass and kept apart. The log
line keeps the `bounded → redacted → capped` order and its ellipsis; the
classification reads the collapsed body bounded at 8 KB and nothing else.
It is deliberately the unredacted text, because the mask ran before the
classifier could read it and a mask that swallowed the word `blocked`
turned a content-creation block into a permission refusal. Nothing in that
window is ever logged or returned.

Also guards `Instant.ofEpochSecond`. `Long.parseLong` accepts values it
rejects, and the resulting `DateTimeException` escaped
`GitHubWriteRetry.call` past every `catch (WebApplicationException)` in the
write path — so `GitHubLostWrites` did not record the write as lost either,
and a header from an intermediary took the write and the record of its loss
together. A value that cannot be an instant now means what a non-numeric
header already means here: unspecified, and the linear fallback takes over.
@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

Separates the response body reading into a log-facing line (unchanged: bounded, redacted, capped at 512) and a wider classification window (collapsed, unredacted, bounded at 8 KB) so throttle/content-creation wording can no longer be hidden by the log cap or the redaction-input bound; also treats an x-ratelimit-reset outside Instant's range as unspecified so GitHubWriteRetry keeps its WebApplicationException contract and linear fallback.

Description vs. Implementation

No mismatch found between the PR description and the change.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
    A["GitHubApiError.from(Response)"] --> B["readBody()"]
    B --> C["clean(raw): collapse whitespace once"]
    C --> D["logged = bounded 1024, redacted, capped 512 + ellipsis"]
    C --> E["classified = collapsed cut to 8192, unredacted"]
    D --> F["diagnostics() logs redacted line only"]
    E --> G["isThrottled() / blocksContentCreation() regex match"]
    G --> H{"throttle wording or headers?"}
    H -- "no" --> I["WebApplicationException rethrown; no retry"]
    H -- "yes" --> J["derivedDelay: parse x-ratelimit-reset"]
    J --> K{"reset names a valid Instant?"}
    K -- "yes" --> L["wait until reset (floored at zero)"]
    K -- "no / absent" --> M["linear fallback: 5s per attempt"]
Loading

Changes Overview

  • Files changed: 3
  • Lines added: +300
  • Lines removed: -21

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiError.java Modified Adds Body record separating logged from classified body; classification reads collapsed 8KB unredacted window; guards out-of-range reset instants.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubApiErrorTest.java Modified Adds throttle-wording tests beyond cap/redaction bound, mask-deletion control, bounded-window edge, permission-refusal control, and reset-range tests.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetryTest.java Modified Adds tests that out-of-range reset headers fall back to linear waits and still surface as WebApplicationException.

Risk Assessment

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

Everything's coming up Thrillhouse! 🎉

No issues found in this PR.


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

@thrillhousebot thrillhousebot Bot added bug Something isn't working java Pull requests that update java code labels Aug 16, 2026
@devops-thiago
devops-thiago merged commit 6c91e11 into fix/746-credential-shapes Aug 16, 2026
1 check passed
@devops-thiago
devops-thiago deleted the fix/747-classify-collapsed branch August 16, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working java Pull requests that update java code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant