diff --git a/coderd/externalauth/gitprovider/conditional.go b/coderd/externalauth/gitprovider/conditional.go new file mode 100644 index 00000000000..e3f7f24ba02 --- /dev/null +++ b/coderd/externalauth/gitprovider/conditional.go @@ -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]) +} diff --git a/coderd/externalauth/gitprovider/github.go b/coderd/externalauth/gitprovider/github.go index 0204bb2bb50..dc8b22142af 100644 --- a/coderd/externalauth/gitprovider/github.go +++ b/coderd/externalauth/gitprovider/github.go @@ -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 diff --git a/coderd/externalauth/gitprovider/github_test.go b/coderd/externalauth/gitprovider/github_test.go index f3ddc572b2f..b2483c53884 100644 --- a/coderd/externalauth/gitprovider/github_test.go +++ b/coderd/externalauth/gitprovider/github_test.go @@ -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") + }) +}