Files
coder/coderd/externalauth/gitprovider/conditional.go
T
Josh FreeandCopilot 2acfe7e829 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
2026-07-30 17:36:39 +01:00

128 lines
3.7 KiB
Go

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])
}