feat(coderd/externalauth/gitprovider): use conditional requests for GitHub JSON reads (#27628)

Fixes #27627.

## What

The chat diff-status gitsync worker polls open pull requests on a fixed
10s interval and re-downloads the full JSON body every tick, even when
nothing changed, because the GitHub client never sends `If-None-Match` /
ETag.

This adds a small, concurrency-safe, bounded in-memory ETag+body cache
(`coderd/externalauth/gitprovider/conditional.go`) and wires it into
`githubProvider.decodeJSON`. When an ETag is cached for a request, we
send `If-None-Match`; on `304 Not Modified` we decode the cached body;
on `200` we cache `{etag, body}` when an ETag is present and the body is
under a size cap.

## Why

`304` responses do not count against GitHub's primary rate limit, but
full `200`s do. Today every unchanged poll burns quota that the same
token also needs for interactive Git and API operations, so busy
instances can hit rate-limit errors and stalls elsewhere. Unchanged PRs
now revalidate for free with no behavior change; only genuine changes
transfer a body.

## Details

- Cache key = request URL + a hash of the token, so one token's response
is never served under another; raw tokens are not retained.
- Bounded by entry count (LRU eviction, default 2048) and per-body size
(1 MiB) to cap memory.
- Scope limited to the JSON reads through `decodeJSON`; the raw-diff
path (`fetchDiff`, up to `MaxDiffSize`) is intentionally left out to
avoid caching large bodies.

## Tests

`TestConditionalRequestReuse` in `github_test.go` covers:
- `NotModifiedReusesCachedBody` — a warm poll sends `If-None-Match` with
the prior ETag and reuses the cached body on `304`, yielding the same
result with exactly two upstream requests.
- `DifferentTokenDoesNotShareCache` — a different token never sends
another token's cached ETag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
This commit is contained in:
Josh Free
2026-07-30 17:36:39 +01:00
committed by GitHub
co-authored by Copilot
parent 6df24634fd
commit 2acfe7e829
3 changed files with 254 additions and 1 deletions
+38 -1
View File
@@ -26,6 +26,11 @@ type githubProvider struct {
httpClient *http.Client
clock quartz.Clock
// cache stores ETags and response bodies so JSON reads can be
// issued as conditional requests, letting unchanged pull
// requests return 304 Not Modified instead of a full body.
cache *responseCache
// Compiled per-instance to support GitHub Enterprise hosts.
pullRequestPathPattern *regexp.Regexp
repositoryHTTPSPattern *regexp.Regexp
@@ -57,6 +62,7 @@ func newGitHub(apiBaseURL string, httpClient *http.Client, clock quartz.Clock) *
webBaseURL: webBaseURL,
httpClient: httpClient,
clock: clock,
cache: newResponseCache(defaultResponseCacheEntries),
pullRequestPathPattern: regexp.MustCompile(
`^https://` + escapedHost + `/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/pull/([0-9]+)(?:[/?#].*)?$`,
),
@@ -401,12 +407,33 @@ func (g *githubProvider) decodeJSON(
req.Header.Set("Authorization", "Bearer "+token)
}
// Issue a conditional request when we have a cached ETag for this
// URL + auth scope. GitHub replies 304 Not Modified when nothing
// changed, which is cheaper than a full body and does not count
// against the primary REST rate limit.
cacheKey := responseCacheKey(requestURL, token)
var cachedBody []byte
if g.cache != nil {
if etag, body, ok := g.cache.load(cacheKey); ok {
req.Header.Set("If-None-Match", etag)
cachedBody = body
}
}
resp, err := g.httpClient.Do(req)
if err != nil {
return xerrors.Errorf("execute github request: %w", err)
}
defer resp.Body.Close()
// Nothing changed since the cached response: reuse the stored body.
if resp.StatusCode == http.StatusNotModified && cachedBody != nil {
if err := json.Unmarshal(cachedBody, dest); err != nil {
return xerrors.Errorf("decode cached github response: %w", err)
}
return nil
}
if resp.StatusCode != http.StatusOK {
if rlErr := checkRateLimitError(resp, g.clock, "X-Ratelimit-Reset"); rlErr != nil {
return rlErr
@@ -425,7 +452,17 @@ func (g *githubProvider) decodeJSON(
)
}
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
body, err := io.ReadAll(resp.Body)
if err != nil {
return xerrors.Errorf("read github response: %w", err)
}
// Cache the validator so the next poll can be made conditional.
if g.cache != nil {
g.cache.store(cacheKey, resp.Header.Get("ETag"), body)
}
if err := json.Unmarshal(body, dest); err != nil {
return xerrors.Errorf("decode github response: %w", err)
}
return nil