Skip to content

Add ETag conditional requests to the REST transport - #3026

Open
joshfree wants to merge 7 commits into
github:mainfrom
joshfree:feat/etag-conditional-requests
Open

Add ETag conditional requests to the REST transport#3026
joshfree wants to merge 7 commits into
github:mainfrom
joshfree:feat/etag-conditional-requests

Conversation

@joshfree

@joshfree joshfree commented Aug 5, 2026

Copy link
Copy Markdown

Summary

Adds HTTP conditional-request (ETag / If-None-Match) support to the REST transport so unchanged resources are revalidated with a 304 Not Modified instead of being re-downloaded in full.

Why

Every REST request is currently issued unconditionally: the ETag returned by the GitHub API is never stored or replayed, so repeated tool calls that read the same resource (for example pull request reads, file and commit listings, and reviews) download the full response each time.

Fixes #3025

What changed

  • Added ETagTransport, a bounded (LRU), concurrency-safe http.RoundTripper that caches the ETag and body of cacheable GET responses and sends If-None-Match on the next identical request; a 304 Not Modified is served from the cached body.
  • Wired ETagTransport into createGitHubClients() below the user-agent and auth layers, so cached entries are scoped by the request's Authorization header and never shared across tokens.
  • Rate-limit headers are surfaced from the live 304 response so downstream rate-limit accounting stays correct.
  • Added ETag / If-None-Match header constants.

Every request is still sent to the server, so responses are always revalidated and never served stale. Per the GitHub REST API docs, a 304 Not Modified does not count against the token's primary rate limit, so repeated reads conserve rate-limit budget and bandwidth while returning identical data.

MCP impact

  • No tool or API changes

Security / limits

  • Auth / permissions considered — cache keys include a hash of the Authorization header, so cached bodies are never served across different tokens.
  • Data exposure, filtering, or token/size limits considered — the cache is bounded (default 512 entries, LRU eviction) and in-memory only.

Tool renaming

  • I am not renaming tools as part of this PR

Lint & tests

  • Tested locally with go test ./pkg/http/transport/... (new etag_test.go covers the 304-serves-cached-body flow, per-token scoping, rate-limit header pass-through, GET-only caching, and caller-supplied conditional headers). go build ./... and go vet ./... pass.

Docs

  • Not needed

Every REST request was issued unconditionally: the ETag returned by the
GitHub API was never stored or replayed, so repeated tool calls that read
the same resource (for example pull request reads, file and commit
listings, and reviews) re-downloaded the full response each time.

This adds an ETagTransport round tripper that caches the ETag and body of
cacheable GET responses and sends If-None-Match on the next identical
request. When the API answers 304 Not Modified, the cached body is served
instead of re-downloading it. The transport is inserted below the
user-agent and auth layers in createGitHubClients(), so cached entries are
scoped by the request's Authorization header and never shared across
tokens. The cache is bounded (LRU) and safe for concurrent use.

Every request is still sent to the server, so responses are always
revalidated and never served stale. Per the GitHub REST API docs, a 304
Not Modified response does not count against the token's primary rate
limit, so repeated reads conserve rate-limit budget and bandwidth while
returning identical data. Rate-limit headers are surfaced from the live
304 response so downstream rate-limit accounting stays correct.

Closes github#3025

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
@joshfree
joshfree requested a review from a team as a code owner August 5, 2026 23:40
Copilot AI balanced review requested due to automatic review settings August 5, 2026 23:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds ETag-based conditional revalidation to GitHub REST requests to reduce bandwidth and rate-limit usage.

Changes:

  • Adds a concurrency-safe LRU ETag transport.
  • Integrates it into REST client construction.
  • Adds header constants and transport tests.
Show a summary per file
File Description
pkg/http/transport/etag.go Implements ETag caching and revalidation.
pkg/http/transport/etag_test.go Tests conditional-request behavior.
pkg/http/headers/headers.go Adds ETag header constants.
internal/ghmcp/server.go Enables the transport for REST clients.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread pkg/http/transport/etag.go Outdated
Comment thread pkg/http/transport/etag.go Outdated
The do/helper closures returned *http.Response, which the bodyclose
linter flags at each call site even though the body is closed inside
the closure. Return only the asserted values (status code, body, and
headers) so no response escapes the helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
@SamMorrowDrums

Copy link
Copy Markdown
Collaborator

Thanks for this, the aims make sense, however most of our usage is through the hosted remote server, which is horizontally scaled and so would need to think about this because it's pretty expensive to add a distributed store there, and additional Redis usage, extra network request per request that would normally be false adding latency, in-memory store would likely be much less useful and we also handle hundreds of RPS so really would need to think about this in that context.

joshfree and others added 2 commits August 5, 2026 17:56
revive's redefines-builtin-id flags the local variable named max in
the LRU eviction loop, which shadows the Go 1.21 builtin. Rename it to
limit; no behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
Restrict the conditional-request cache to the long-lived local (stdio)
REST client and give the raw-content client a separate transport without
it, so large file bodies are streamed rather than buffered into memory.

Bound the cache by a per-entry and total-byte budget in addition to the
entry count, and never retain responses marked non-storable by HTTP
cache directives (request or response Cache-Control: no-store, or
Vary: *).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
@joshfree

joshfree commented Aug 7, 2026

Copy link
Copy Markdown
Author

That concern is now addressed by scoping the cache to the local install (ed5fdb83).

The conditional-request cache is wired only into the long-lived stdio server (internal/ghmcp/createGitHubClients). The hosted remote server builds a fresh REST client per request via RequestDeps.GetClient (pkg/github/dependencies.go), which does not use this transport — so nothing changes for the horizontally-scaled deployment: no distributed/Redis store, no extra network hop, and no added latency at hundreds of RPS.

For the local server the in-memory cache is bounded by entry count and by per-entry (1 MiB) and total (32 MiB) byte budgets with LRU eviction, and the raw-content client uses a separate transport without the cache so large file reads are streamed rather than retained. The two inline nits (Cache-Control: no-store / Vary: *, and the byte budget) are handled in the same commit.

Happy to keep this strictly local-only, or to expose it behind an explicit opt-in flag if that is preferable.

joshfree and others added 2 commits August 7, 2026 10:03
golangci-lint's modernize analyzer flags ranging over strings.Split;
strings.SplitSeq avoids allocating the intermediate slice.

Refs github#3025

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
@joshfree

Copy link
Copy Markdown
Author

Gentle nudge on this PR. CI is fully green (13/13 checks) and the branch is current. It adds ETag conditional-request support to reduce redundant API calls (ref #3025), with tests and updated tool snapshots/docs per CONTRIBUTING. There is one approving review, but it does not satisfy the required CODEOWNER review, so it is ready for a maintainer whenever there is bandwidth. Any feedback will be addressed promptly.

Comment thread pkg/http/transport/etag.go
Avoid fully buffering responses that exceed the ETag cache entry budget when Content-Length is unknown. Preserve the consumed prefix with the remaining response stream so callers still receive the complete body.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: c4eabbcd-abb9-4c5d-a1f5-c98618623c5b

@IrynaKulakova IrynaKulakova left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. My earlier concern about the unbounded io.ReadAll is fully addressed in 05fa854 — the read is now capped with io.LimitReader, oversized bodies are handed back as the buffered prefix plus the remaining stream, and TestETagTransport_StreamsOversizedUnknownLengthBody guards the unknown-length case that the original ContentLength check missed. Verified locally: go test -race ./pkg/http/transport/... passes and CI is green.

The design is sound overall — always revalidate, never serve stale, per-token cache scoping, LRU with both entry and byte budgets, and the raw-content client kept off the cache so large file bodies stream.

One non-blocking follow-up (happy for it to land separately): the cache key doesn't include Accept, even though the API returns Vary: Accept and a distinct ETag per representation. I confirmed this against api.github.com — the same PR URL yields different ETags for application/vnd.github+json and application/vnd.github.diff. In this repo that's exercised by pullrequests.go (JSON read vs RawOptions{Type: Diff} on the same URL) and by search with text-match. No wrong body is served today, since the differing ETags simply produce a 200, but the two representations continually evict each other so those URLs get no cache hits while still paying the buffering cost. It's also latent on GHES or proxies that compute a representation-independent ETag. Adding Accept to cacheKey would close it.

Two smaller nits, also non-blocking: cacheKey truncates the Authorization hash to 8 bytes even though it's a tenant-isolation boundary (the full sum costs nothing), and the error path returns resp, err where the RoundTripper contract asks for a nil response alongside a non-nil error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PR tools re-fetch data without conditional requests (ETag / If-None-Match)

5 participants