mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user