Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions coderd/externalauth/gitprovider/conditional.go
Original file line number Diff line number Diff line change
@@ -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])
}
39 changes: 38 additions & 1 deletion coderd/externalauth/gitprovider/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]+)(?:[/?#].*)?$`,
),
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions coderd/externalauth/gitprovider/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
Loading