-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Add ETag conditional requests to the REST transport #3026
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
joshfree
wants to merge
6
commits into
github:main
Choose a base branch
from
joshfree:feat/etag-conditional-requests
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+744
−19
Open
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
eb8c3fc
Add ETag conditional requests to the REST transport
joshfree e33d656
test(transport): satisfy bodyclose in etag_test helpers
joshfree 5c8069e
fix(transport): rename local max to avoid shadowing builtin
joshfree ed5fdb8
Scope ETag cache to REST client and bound it by bytes
joshfree 7c2bdbc
Merge branch 'main' into feat/etag-conditional-requests
joshfree 3d894b8
Use strings.SplitSeq for Cache-Control and Vary parsing
joshfree File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| package transport | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "container/list" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "sync" | ||
|
|
||
| "github.com/github/github-mcp-server/pkg/http/headers" | ||
| ) | ||
|
|
||
| // defaultETagCacheSize bounds the number of cached conditional responses held | ||
| // in memory by an ETagTransport. | ||
| const defaultETagCacheSize = 512 | ||
|
|
||
| // rateLimitHeaders are copied from the live 304 response onto a cache-served | ||
| // response so downstream rate-limit accounting observes the current state. | ||
| var rateLimitHeaders = []string{ | ||
| "X-RateLimit-Limit", | ||
| "X-RateLimit-Remaining", | ||
| "X-RateLimit-Used", | ||
| "X-RateLimit-Reset", | ||
| "X-RateLimit-Resource", | ||
| "Retry-After", | ||
| "Date", | ||
| } | ||
|
|
||
| // etagEntry is a cached response body and headers keyed by an ETag. | ||
| type etagEntry struct { | ||
| etag string | ||
| status int | ||
| header http.Header | ||
| body []byte | ||
| } | ||
|
|
||
| // response reconstructs an *http.Response from a cached entry, layering the | ||
| // live 304 response's rate-limit and timing headers on top so the caller sees | ||
| // the current rate-limit state while receiving the cached body. | ||
| func (e etagEntry) response(live *http.Response) *http.Response { | ||
| h := e.header.Clone() | ||
| for _, name := range rateLimitHeaders { | ||
| if values := live.Header.Values(name); len(values) > 0 { | ||
| h.Del(name) | ||
| for _, v := range values { | ||
| h.Add(name, v) | ||
| } | ||
| } | ||
| } | ||
| return &http.Response{ | ||
| Status: fmt.Sprintf("%d %s", e.status, http.StatusText(e.status)), | ||
| StatusCode: e.status, | ||
| Proto: live.Proto, | ||
| ProtoMajor: live.ProtoMajor, | ||
| ProtoMinor: live.ProtoMinor, | ||
| Header: h, | ||
| Body: io.NopCloser(bytes.NewReader(e.body)), | ||
| ContentLength: int64(len(e.body)), | ||
| Request: live.Request, | ||
| } | ||
| } | ||
|
|
||
| type lruItem struct { | ||
| key string | ||
| entry etagEntry | ||
| } | ||
|
|
||
| // ETagTransport is an http.RoundTripper that adds HTTP conditional-request | ||
| // support (ETag / If-None-Match) to GET requests. For each cacheable GET it | ||
| // stores the response ETag and body; on a subsequent identical request it sends | ||
| // If-None-Match and, when the server answers 304 Not Modified, serves the | ||
| // cached body instead of re-downloading it. | ||
| // | ||
| // Every request is still sent to the server, so responses are always | ||
| // revalidated and never served stale. A 304 Not Modified does not count against | ||
| // the GitHub REST API primary rate limit, so revalidated requests conserve | ||
| // rate-limit budget and bandwidth. | ||
| // | ||
| // Cached entries are scoped by the request's Authorization header so responses | ||
| // are never shared across tokens. The cache is bounded (LRU) and safe for | ||
| // concurrent use. | ||
| type ETagTransport struct { | ||
| Transport http.RoundTripper | ||
|
|
||
| // MaxEntries bounds the number of cached responses. When zero, | ||
| // defaultETagCacheSize is used. | ||
| MaxEntries int | ||
|
|
||
| mu sync.Mutex | ||
| ll *list.List | ||
| items map[string]*list.Element | ||
| } | ||
|
|
||
| func (t *ETagTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| rt := t.Transport | ||
| if rt == nil { | ||
| rt = http.DefaultTransport | ||
| } | ||
|
|
||
| // Only cache GET requests, and never override a caller-supplied conditional | ||
| // header. | ||
| if req.Method != http.MethodGet || req.Header.Get(headers.IfNoneMatchHeader) != "" { | ||
| return rt.RoundTrip(req) | ||
| } | ||
|
|
||
| key := cacheKey(req) | ||
| cached, ok := t.get(key) | ||
|
|
||
| req = req.Clone(req.Context()) | ||
| if ok { | ||
| req.Header.Set(headers.IfNoneMatchHeader, cached.etag) | ||
| } | ||
|
|
||
| resp, err := rt.RoundTrip(req) | ||
| if err != nil { | ||
| return resp, err | ||
| } | ||
|
|
||
| if resp.StatusCode == http.StatusNotModified && ok { | ||
| // Discard the empty 304 body and serve the cached response instead. | ||
| if resp.Body != nil { | ||
| _, _ = io.Copy(io.Discard, resp.Body) | ||
| resp.Body.Close() | ||
| } | ||
| return cached.response(resp), nil | ||
| } | ||
|
|
||
| if resp.StatusCode == http.StatusOK { | ||
| if etag := resp.Header.Get(headers.ETagHeader); etag != "" { | ||
| body, readErr := io.ReadAll(resp.Body) | ||
|
joshfree marked this conversation as resolved.
Outdated
|
||
| resp.Body.Close() | ||
| if readErr != nil { | ||
| return nil, readErr | ||
| } | ||
| t.add(key, etagEntry{ | ||
| etag: etag, | ||
| status: resp.StatusCode, | ||
| header: resp.Header.Clone(), | ||
| body: body, | ||
| }) | ||
| resp.Body = io.NopCloser(bytes.NewReader(body)) | ||
| resp.ContentLength = int64(len(body)) | ||
| } | ||
| } | ||
|
|
||
| return resp, nil | ||
| } | ||
|
|
||
| func cacheKey(req *http.Request) string { | ||
| sum := sha256.Sum256([]byte(req.Header.Get(headers.AuthorizationHeader))) | ||
| return req.Method + " " + req.URL.String() + " " + hex.EncodeToString(sum[:8]) | ||
| } | ||
|
|
||
| func (t *ETagTransport) get(key string) (etagEntry, bool) { | ||
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
| if t.items == nil { | ||
| return etagEntry{}, false | ||
| } | ||
| el, ok := t.items[key] | ||
| if !ok { | ||
| return etagEntry{}, false | ||
| } | ||
| t.ll.MoveToFront(el) | ||
| return el.Value.(*lruItem).entry, true | ||
| } | ||
|
|
||
| func (t *ETagTransport) add(key string, entry etagEntry) { | ||
| t.mu.Lock() | ||
| defer t.mu.Unlock() | ||
| if t.items == nil { | ||
| t.items = make(map[string]*list.Element) | ||
| t.ll = list.New() | ||
| } | ||
| if el, ok := t.items[key]; ok { | ||
| el.Value.(*lruItem).entry = entry | ||
| t.ll.MoveToFront(el) | ||
| return | ||
| } | ||
| el := t.ll.PushFront(&lruItem{key: key, entry: entry}) | ||
| t.items[key] = el | ||
|
|
||
| max := t.MaxEntries | ||
| if max <= 0 { | ||
| max = defaultETagCacheSize | ||
| } | ||
| for t.ll.Len() > max { | ||
| oldest := t.ll.Back() | ||
| if oldest == nil { | ||
| break | ||
| } | ||
| t.ll.Remove(oldest) | ||
| delete(t.items, oldest.Value.(*lruItem).key) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.