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:
@@ -0,0 +1,127 @@
|
||||
package gitprovider
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Defaults for the conditional-request response cache. These bound
|
||||
// the memory used by cached GitHub responses while still covering a
|
||||
// realistic working set of actively-polled pull requests.
|
||||
const (
|
||||
// defaultResponseCacheEntries is the maximum number of cached
|
||||
// responses retained. Once exceeded, the least-recently-used
|
||||
// entry is evicted.
|
||||
defaultResponseCacheEntries = 2048
|
||||
|
||||
// maxCachedBodyBytes is the largest response body that will be
|
||||
// cached. Larger bodies are still returned to the caller but are
|
||||
// not stored, so a single oversized response cannot blow up the
|
||||
// cache's memory footprint.
|
||||
maxCachedBodyBytes = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
// cachedResponse holds the data needed to satisfy a future
|
||||
// conditional request: the validator (ETag) to echo back via
|
||||
// If-None-Match and the body to reuse on a 304 Not Modified.
|
||||
type cachedResponse struct {
|
||||
key string
|
||||
etag string
|
||||
body []byte
|
||||
}
|
||||
|
||||
// responseCache is a small, concurrency-safe LRU cache of GitHub
|
||||
// responses keyed by request URL and auth scope. It enables
|
||||
// conditional requests (ETag / If-None-Match): when GitHub replies
|
||||
// 304 Not Modified the cached body is reused, avoiding a full
|
||||
// re-download. Conditional requests that return 304 also do not
|
||||
// count against the primary REST rate limit, which matters for the
|
||||
// diff-status worker that polls open PRs on a short interval.
|
||||
type responseCache struct {
|
||||
mu sync.Mutex
|
||||
maxSize int
|
||||
ll *list.List
|
||||
entries map[string]*list.Element
|
||||
}
|
||||
|
||||
// newResponseCache constructs an empty cache retaining at most
|
||||
// maxSize entries. A non-positive maxSize falls back to the default.
|
||||
func newResponseCache(maxSize int) *responseCache {
|
||||
if maxSize <= 0 {
|
||||
maxSize = defaultResponseCacheEntries
|
||||
}
|
||||
return &responseCache{
|
||||
maxSize: maxSize,
|
||||
ll: list.New(),
|
||||
entries: make(map[string]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
// load returns the cached ETag and body for key, if present, and
|
||||
// marks the entry as most-recently-used.
|
||||
func (c *responseCache) load(key string) (etag string, body []byte, ok bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
elem, found := c.entries[key]
|
||||
if !found {
|
||||
return "", nil, false
|
||||
}
|
||||
c.ll.MoveToFront(elem)
|
||||
cr := elem.Value.(*cachedResponse)
|
||||
return cr.etag, cr.body, true
|
||||
}
|
||||
|
||||
// store records the ETag and body for key, evicting the
|
||||
// least-recently-used entry when the cache is full. Empty ETags and
|
||||
// bodies larger than maxCachedBodyBytes are not stored.
|
||||
func (c *responseCache) store(key, etag string, body []byte) {
|
||||
if etag == "" || len(body) > maxCachedBodyBytes {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if elem, found := c.entries[key]; found {
|
||||
c.ll.MoveToFront(elem)
|
||||
cr := elem.Value.(*cachedResponse)
|
||||
cr.etag = etag
|
||||
cr.body = body
|
||||
return
|
||||
}
|
||||
|
||||
// Copy the body so we never retain a slice that the caller may
|
||||
// later reuse or mutate.
|
||||
stored := make([]byte, len(body))
|
||||
copy(stored, body)
|
||||
|
||||
elem := c.ll.PushFront(&cachedResponse{key: key, etag: etag, body: stored})
|
||||
c.entries[key] = elem
|
||||
|
||||
if c.ll.Len() > c.maxSize {
|
||||
c.evictOldest()
|
||||
}
|
||||
}
|
||||
|
||||
// evictOldest removes the least-recently-used entry. The caller must
|
||||
// hold c.mu.
|
||||
func (c *responseCache) evictOldest() {
|
||||
elem := c.ll.Back()
|
||||
if elem == nil {
|
||||
return
|
||||
}
|
||||
c.ll.Remove(elem)
|
||||
delete(c.entries, elem.Value.(*cachedResponse).key)
|
||||
}
|
||||
|
||||
// responseCacheKey derives a cache key that isolates responses by
|
||||
// request URL and auth scope. The token is hashed rather than stored
|
||||
// so that a cached entry for one user's token is never served to
|
||||
// another, without keeping raw credentials in memory.
|
||||
func responseCacheKey(requestURL, token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return requestURL + "\x00" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -973,3 +973,92 @@ func TestEscapePathPreserveSlashes(t *testing.T) {
|
||||
got := gp.BuildBranchURL("owner", "repo", "feat/my thing")
|
||||
assert.Equal(t, "https://github.com/owner/repo/tree/feat/my%20thing", got)
|
||||
}
|
||||
|
||||
func TestConditionalRequestReuse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NotModifiedReusesCachedBody", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const etag = `"abc123etag"`
|
||||
var srvURL string
|
||||
var requests int
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
// After the first response, the provider must revalidate
|
||||
// with the ETag we handed out.
|
||||
if inm := r.Header.Get("If-None-Match"); inm != "" {
|
||||
assert.Equal(t, etag, inm)
|
||||
w.Header().Set("ETag", etag)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
htmlURL := fmt.Sprintf("https://%s/owner/repo/pull/42",
|
||||
strings.TrimPrefix(strings.TrimPrefix(srvURL, "http://"), "https://"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("ETag", etag)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`[{"html_url":%q,"number":42}]`, htmlURL)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
srvURL = srv.URL
|
||||
|
||||
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gp)
|
||||
|
||||
branch := gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"}
|
||||
|
||||
// Cold fetch: 200 + full body, populates the cache.
|
||||
first, err := gp.ResolveBranchPullRequest(context.Background(), "test-token", branch)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
assert.Equal(t, 42, first.Number)
|
||||
|
||||
// Warm fetch: server returns 304, provider reuses the cached
|
||||
// body and yields the same result.
|
||||
second, err := gp.ResolveBranchPullRequest(context.Background(), "test-token", branch)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, second)
|
||||
assert.Equal(t, 42, second.Number)
|
||||
assert.Equal(t, first.Owner, second.Owner)
|
||||
assert.Equal(t, first.Repo, second.Repo)
|
||||
|
||||
assert.Equal(t, 2, requests, "expected exactly two upstream requests")
|
||||
})
|
||||
|
||||
t.Run("DifferentTokenDoesNotShareCache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var srvURL string
|
||||
var conditionalRequests int
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-None-Match") != "" {
|
||||
conditionalRequests++
|
||||
}
|
||||
htmlURL := fmt.Sprintf("https://%s/owner/repo/pull/7",
|
||||
strings.TrimPrefix(strings.TrimPrefix(srvURL, "http://"), "https://"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("ETag", `"tok-etag"`)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`[{"html_url":%q,"number":7}]`, htmlURL)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
srvURL = srv.URL
|
||||
|
||||
gp, err := gitprovider.New("github", srv.URL+"/api/v3", srv.Client())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gp)
|
||||
|
||||
branch := gitprovider.BranchRef{Owner: "owner", Repo: "repo", Branch: "feat"}
|
||||
|
||||
_, err = gp.ResolveBranchPullRequest(context.Background(), "token-a", branch)
|
||||
require.NoError(t, err)
|
||||
// A different token must not reuse token-a's cached ETag.
|
||||
_, err = gp.ResolveBranchPullRequest(context.Background(), "token-b", branch)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0, conditionalRequests,
|
||||
"a different token must not send If-None-Match from another token's cache")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user