Skip to content

fix(#6702): use conditional requests for behaviour-suite polling GETs - #6797

Open
waynesun09 wants to merge 1 commit into
mainfrom
fix-6702-conditional-requests
Open

fix(#6702): use conditional requests for behaviour-suite polling GETs#6797
waynesun09 wants to merge 1 commit into
mainfrom
fix-6702-conditional-requests

Conversation

@waynesun09

Copy link
Copy Markdown
Member

What

Follow-up to #6705's instrumentation. That PR measured the harness-wait poll loop draining the shared installation token's primary quota at ~235 req/min, exhausting it ~20 minutes into a suite run (#6702). This is candidate 1 of the two the issue proposed, in order of leverage: conditional requests.

Why this works

GitHub does not count a 304 Not Modified against the primary rate-limit budget. Verified against the live API before writing any code:

$ curl -si -H "If-None-Match: <etag>" .../actions/runs?per_page=5
HTTP/2 304
x-ratelimit-remaining: 4685
$ curl -si -H "If-None-Match: <etag>" .../actions/runs?per_page=5   # again
HTTP/2 304
x-ratelimit-remaining: 4685   # unchanged across repeated 304s

Only the original uncached 200 consumed a unit. The harness-wait poll loop re-requests the same workflow-runs/jobs/artifacts URLs every few seconds, from up to a dozen concurrent scenarios — most of those polls see an unchanged result while a run is still queued or in progress, so this is the highest-leverage fix for exactly the traffic pattern that exhausts the budget.

What changed

  • LiveClient gains a small conditional-GET cache (etagCache, capped at etagCacheLimit) and a getCached(ctx, path) helper: sends If-None-Match when a cached ETag exists, returns the cached body on 304, otherwise decodes and caches the new body + ETag.
  • do() grows a variadic requestHeader option so getConditional can set If-None-Match without touching any of its other 28 call sites.
  • Wired into the five GET endpoints the harness-wait poll loop and its diagnostics actually use: ListWorkflowRuns, ListRecentWorkflowRuns, ListWorkflowRunJobs, ListWorkflowRunArtifacts, ListRepositoryArtifacts. Nothing else opts in — get() (used by writes, one-shot reads) is untouched.

What's deliberately not in this PR

Correctness risk worth naming

If GitHub's ETag on a listing ever failed to change when a run's status changed, the poller would silently see stale data — the exact flake class this fixes, reintroduced worse. Not unit-testable; the behaviour job on this PR is the real check (a stuck poll would time out, and #6698's diagnostics now name errors instead of swallowing them).

Testing

  • go build ./..., go vet ./..., gofmt -l clean
  • go test -race ./internal/forge/... ./pkg/behaviourtest/... — all pass
  • New tests in internal/forge/github/github_test.go: conditional request reuses a 304 and decodes the cached body; a changed ETag forces a real re-fetch (not stale data); a response without an ETag is never cached; etagCache stays bounded across many distinct URLs
  • Behaviour job on this PR is the live verification (12 concurrent scenarios against a pool org, same conditions behaviourtest: pool-org installation token exhausts GitHub API budget ~10 min into the suite #6702 measured)

The harness-wait poll loop (and its diagnostics) re-request the same
workflow-runs/jobs/artifacts URLs every few seconds, from up to a
dozen concurrent scenarios sharing one installation token. #6705's
instrumentation measured that traffic draining the primary quota
~235 req/min, exhausting it ~20 minutes into a suite run.

GitHub does not count a 304 response against the primary rate-limit
budget (verified against the live API: repeated If-None-Match
requests left X-RateLimit-Remaining unchanged, only the initial
uncached GET consumed one unit). This adds a small conditional-GET
cache to LiveClient (etagCache, opt-in per path via getCached) and
wires it into the five GET endpoints the harness-wait poll loop and
its diagnostics use: ListWorkflowRuns, ListRecentWorkflowRuns,
ListWorkflowRunJobs, ListWorkflowRunArtifacts, and
ListRepositoryArtifacts. Unchanged results between polls now cost
nothing; a status change still forces a full re-fetch, since GitHub
issues a new ETag whenever the underlying data changes.

do() grows a variadic requestHeader option so getConditional can set
If-None-Match without touching its other 28 call sites. The cache is
capped (etagCacheLimit) since a long suite run touches many distinct
run/job/artifact URLs.

Deliberately out of scope: a remaining-budget circuit breaker (the
issue's second candidate). If post-merge behaviour runs still show
#6698's 403 diagnostics after this lands, that's the next step, sized
with real data instead of a guessed threshold.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Use conditional requests for GitHub Actions polling

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Cache ETags and response bodies for repeatedly polled GitHub Actions endpoints.
• Reuse cached payloads after 304 responses to preserve shared API quota.
• Cover cache refresh, missing ETags, 304 reuse, and bounded growth.
Diagram

graph TD
  A["Polling Endpoints"] --> B["Cache Lookup"] --> C["GitHub API"] --> D{"Not Modified?"}
  D -- "Yes" --> E["Cached Body"] --> G["Decoded Results"]
  D -- "No" --> F["Fresh Body"] --> G
  F -. "store ETag" .-> B
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Rate-limit circuit breaker
  • ➕ Protects quota even when resources change frequently or omit ETags.
  • ➕ Can adapt polling globally as the remaining budget falls.
  • ➖ Requires a defensible threshold and backoff policy.
  • ➖ Reduces request volume rather than making unchanged polls quota-free.
2. Caching HTTP transport
  • ➕ Could centralize conditional request behavior below individual client methods.
  • ➕ May make future read endpoints opt in through policy rather than bespoke calls.
  • ➖ Risks caching one-shot or mutation-sensitive reads unintentionally.
  • ➖ Adds invalidation and generic response-handling complexity beyond the affected poll loop.

Recommendation: Keep the PR's explicit, endpoint-level conditional caching. It directly matches the unchanged polling workload, limits behavioral impact to the five quota-heavy reads, and preserves the circuit breaker as a data-driven follow-up if conditional requests do not sufficiently reduce exhaustion.

Files changed (2) +234 / -11

Bug fix (1) +114 / -11
github.goAdd bounded conditional-GET caching to polling endpoints +114/-11

Add bounded conditional-GET caching to polling endpoints

• Adds optional request headers plus a mutex-protected, per-path ETag/body cache capped at 256 entries. Five repeatedly polled GitHub Actions listing methods now send If-None-Match, reuse cached bodies on 304, and refresh data when GitHub returns a changed representation.

internal/forge/github/github.go

Tests (1) +120 / -0
github_test.goVerify ETag reuse, refresh, fallback, and cache bounds +120/-0

Verify ETag reuse, refresh, fallback, and cache bounds

• Adds HTTP-server tests confirming weak ETags are echoed verbatim, 304 responses reuse cached payloads, changed ETags refresh status data, missing ETags bypass caching, and the cache remains bounded.

internal/forge/github/github_test.go

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 31, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 1:38 PM UTC · Completed 1:55 PM UTC

Commit: 126b20b · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $5.54

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.01887% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/forge/github/github.go 83.01% 2 Missing and 7 partials ⚠️

📢 Thoughts on this report? Let us know!

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Cache lacks byte bound 🐞 Bug ➹ Performance
Description
getCached reads each successful response into an unbounded byte slice and retains up to 256 such
slices, so etagCacheLimit bounds entry count but not memory consumption. Large GitHub or
intermediary responses can therefore cause substantial transient and long-lived memory growth
compared with the previous streaming decode.
Code

internal/forge/github/github.go[553]

+	data, err := io.ReadAll(resp.Body)
Relevance

●●● Strong

Close precedent accepted bounding io.ReadAll in this exact GitHub client to prevent excessive memory
consumption.

PR-#1612

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
io.ReadAll has no size limit and its full result is stored in each cache entry. The declared cache
limit only constrains the number of map entries, not the size of their bodies; prior accepted review
guidance in this file identified the same unbounded io.ReadAll risk.

internal/forge/github/github.go[553-566]
internal/forge/github/github.go[62-66]
PR-#1612

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Successful response bodies are read without a byte limit and retained in an entry-count-only cache, leaving total cache memory unbounded.

## Issue Context
The five listing endpoints previously decoded directly from response streams; the new implementation retains raw bodies for conditional reuse.

## Fix Focus Areas
- internal/forge/github/github.go[553-566]
- internal/forge/github/github.go[62-66]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Concurrent cache state regresses 🐞 Bug ≡ Correctness
Description
Concurrent getCached calls for the same path can both snapshot an old entry and then write
responses out of order, allowing an older ETag/body to overwrite a newer one or an old 304 snapshot
to be returned after newer data was cached. The shared behaviour-suite client is used by concurrent
polling scenarios, so this can transiently regress observed workflow state and force another
quota-consuming 200 to repair the cache.
Code

internal/forge/github/github.go[565]

+		c.etagCache[path] = etagEntry{etag: newETag, body: data}
Relevance

●● Moderate

Concurrency regression is plausible, but no closely matching accepted or rejected cache-race
precedent was found.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The cache snapshot is read and unlocked before the request, while the response is later assigned
without checking whether another request changed that path. The behaviour suite configures
concurrent scenarios around a shared CI client, and its workflow wait path repeatedly calls the
converted listing method.

internal/forge/github/github.go[535-566]
e2e/behaviour/suite_test.go[126-135]
pkg/behaviourtest/drivers/ci/githubactions/githubactions.go[115-128]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Concurrent same-path requests can overwrite a newer cache entry with an older response because the network request is outside the lock and the final assignment is unconditional.

## Issue Context
The behaviour suite shares one GitHub client across concurrent scenarios, and multiple polling paths can overlap.

## Fix Focus Areas
- internal/forge/github/github.go[535-566]
- e2e/behaviour/suite_test.go[126-135]
- pkg/behaviourtest/drivers/ci/githubactions/githubactions.go[115-128]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Invalid payloads become sticky 🐞 Bug ☼ Reliability
Description
getCached stores every ETagged 200 response before any endpoint validates its JSON, so a corrupted
or malformed representation can be replayed on every subsequent 304 and repeatedly fail decoding.
This turns a one-request payload failure into a persistent polling failure until GitHub changes the
ETag or the entry is evicted.
Code

internal/forge/github/github.go[565]

+		c.etagCache[path] = etagEntry{etag: newETag, body: data}
Relevance

●● Moderate

Validation-after-caching is a credible reliability issue, but history lacks a close precedent for
sticky malformed 304 bodies.

PR-#383

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The helper commits the ETag/body at line 565, while each converted endpoint performs
json.Unmarshal only after the helper returns. A later 304 directly returns those already-cached
bytes without any opportunity to fetch a clean body.

internal/forge/github/github.go[545-568]
internal/forge/github/github.go[3385-3401]
internal/forge/github/github.go[3426-3442]
internal/forge/github/github.go[3461-3474]
internal/forge/github/github.go[3490-3501]
internal/forge/github/github.go[3546-3562]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An ETagged body is committed to the cache before callers validate that it can be decoded, allowing malformed content to remain sticky across 304 responses.

## Issue Context
All five converted list methods unmarshal only after `getCached` has already stored the bytes.

## Fix Focus Areas
- internal/forge/github/github.go[545-568]
- internal/forge/github/github.go[3385-3401]
- internal/forge/github/github.go[3426-3442]
- internal/forge/github/github.go[3461-3474]
- internal/forge/github/github.go[3490-3501]
- internal/forge/github/github.go[3546-3562]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 62 rules
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

if c.etagCache == nil {
c.etagCache = make(map[string]etagEntry)
}
c.etagCache[path] = etagEntry{etag: newETag, body: data}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Concurrent cache state regresses 🐞 Bug ≡ Correctness

Concurrent getCached calls for the same path can both snapshot an old entry and then write
responses out of order, allowing an older ETag/body to overwrite a newer one or an old 304 snapshot
to be returned after newer data was cached. The shared behaviour-suite client is used by concurrent
polling scenarios, so this can transiently regress observed workflow state and force another
quota-consuming 200 to repair the cache.
Agent Prompt
## Issue description
Concurrent same-path requests can overwrite a newer cache entry with an older response because the network request is outside the lock and the final assignment is unconditional.

## Issue Context
The behaviour suite shares one GitHub client across concurrent scenarios, and multiple polling paths can overlap.

## Fix Focus Areas
- internal/forge/github/github.go[535-566]
- e2e/behaviour/suite_test.go[126-135]
- pkg/behaviourtest/drivers/ci/githubactions/githubactions.go[115-128]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

if c.etagCache == nil {
c.etagCache = make(map[string]etagEntry)
}
c.etagCache[path] = etagEntry{etag: newETag, body: data}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Invalid payloads become sticky 🐞 Bug ☼ Reliability

getCached stores every ETagged 200 response before any endpoint validates its JSON, so a corrupted
or malformed representation can be replayed on every subsequent 304 and repeatedly fail decoding.
This turns a one-request payload failure into a persistent polling failure until GitHub changes the
ETag or the entry is evicted.
Agent Prompt
## Issue description
An ETagged body is committed to the cache before callers validate that it can be decoded, allowing malformed content to remain sticky across 304 responses.

## Issue Context
All five converted list methods unmarshal only after `getCached` has already stored the bytes.

## Fix Focus Areas
- internal/forge/github/github.go[545-568]
- internal/forge/github/github.go[3385-3401]
- internal/forge/github/github.go[3426-3442]
- internal/forge/github/github.go[3461-3474]
- internal/forge/github/github.go[3490-3501]
- internal/forge/github/github.go[3546-3562]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return prev.body, nil
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Cache lacks byte bound 🐞 Bug ➹ Performance

getCached reads each successful response into an unbounded byte slice and retains up to 256 such
slices, so etagCacheLimit bounds entry count but not memory consumption. Large GitHub or
intermediary responses can therefore cause substantial transient and long-lived memory growth
compared with the previous streaming decode.
Agent Prompt
## Issue description
Successful response bodies are read without a byte limit and retained in an entry-count-only cache, leaving total cache memory unbounded.

## Issue Context
The five listing endpoints previously decoded directly from response streams; the new implementation retains raw bodies for conditional reuse.

## Fix Focus Areas
- internal/forge/github/github.go[553-566]
- internal/forge/github/github.go[62-66]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@fullsend-ai-review fullsend-ai-review Bot added the risk/moderate PR risk: moderate label Aug 31, 2026
@fullsend-ai-review

Copy link
Copy Markdown

Risk Assessment: moderate (2/5)

Details

Moderate risk: a focused 245-line fix with 50% test coverage targeting a well-scoped issue, but modifying a high-churn core file (github.go) with 8 recent contributors and extensive fix history; the narrow scope and strong test ratio keep overall risk contained.

@fullsend-ai-review

Copy link
Copy Markdown

Review

Findings

Low

  • [shared-mutable-slice] internal/forge/github/github.go:557getCached stores the []byte from io.ReadAll in etagCache and returns the same backing array to the caller. Currently safe because all callers pass the result to json.Unmarshal (which does not mutate its input), but a future caller that mutates the returned slice would silently corrupt the cached copy. Defensively returning slices.Clone(prev.body) on the 304 path would decouple the two.

  • [mutex-idiom-consistency] internal/forge/github/github.go:540getCached uses explicit Lock/Unlock pairs without defer, while the established pattern in this file (RateLimit, observeRateLimit) uses defer c.rateMu.Unlock(). Both critical sections are short (3–5 lines of trivially safe map operations), so the deadlock risk is negligible — noting for consistency only.

  • [error-wrapping-consistency] internal/forge/github/github.go:556 — The io.ReadAll error is wrapped as "read response: %w". Including the path would improve debuggability (e.g., "read cached response for %s: %w").

  • [naming-consistency] internal/forge/github/github.go:235 — The type requestHeader is generic; a name like extraHeader would better signal its narrow purpose, but it is unexported and its doc comment clearly scopes it to do().

@fullsend-ai-review fullsend-ai-review Bot added the ready-for-merge All reviewers approved — ready to merge label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-merge All reviewers approved — ready to merge risk/moderate PR risk: moderate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant