feat(github): reuse ETags across syncs via conditional requests (#356)

* feat(github): reuse ETags across syncs via conditional requests

The mirror re-lists every repository's pull requests on each scheduled
sync with `state: "all"`, and the Octokit client was created with the
throttling plugin but no conditional-request/ETag layer. Every sync
therefore re-downloaded unchanged data as full 200s and spent full
rate-limit budget.

Add an ETag cache wired into `createGitHubClient`: for each GET it
replays the previously stored `If-None-Match`, so GitHub returns
`304 Not Modified` (which does not count against the token's primary
rate limit) when nothing changed, and the cached body is reused. The
store is process-lifetime and scoped per user so ETags survive the
per-sync client re-creation without leaking data across tokens. Non-GET
requests are untouched.

Refs #355

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

* test(github): cover conditional-request ETag cache

Add unit tests driving a real Octokit instance with a stubbed fetch:
the second GET replays `If-None-Match`, a `304` is transparently served
from cache as a 200 with the same body, responses without an ETag are
re-fetched, non-GET requests are never made conditional, and cache
entries are isolated by scope.

Refs #355

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

* fix: key conditional-request cache by expanded URL

The request hook built the cache key from requestOptions.url, which is
still the route template (/repos/{owner}/{repo}/pulls) at hook time.
Every repo therefore shared one entry per user + endpoint, so with more
than one repo per user the stored ETag never matched and the 304 path
stopped firing. Expand the route via octokit.request.endpoint.parse
before building the key, and add a two-repo regression test that fails
on the shared-key behavior.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
This commit is contained in:
Josh Free
2026-08-05 18:06:31 -07:00
committed by GitHub
parent 428c97441c
commit 2b455354f9
3 changed files with 322 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
import { describe, expect, test } from "bun:test";
import { Octokit } from "@octokit/rest";
import {
applyConditionalRequests,
conditionalRequestCacheKey,
InMemoryConditionalRequestStore,
} from "@/lib/github-conditional-requests";
function jsonResponse(
body: unknown,
init: { status: number; etag?: string; link?: string },
): Response {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (init.etag) headers.etag = init.etag;
if (init.link) headers.link = init.link;
return new Response(init.status === 304 ? null : JSON.stringify(body), {
status: init.status,
headers,
});
}
function clientWithFetch(fetch: (url: string, init: any) => Promise<Response>) {
return new Octokit({
auth: "test-token",
request: { fetch: fetch as unknown as typeof globalThis.fetch },
});
}
describe("applyConditionalRequests", () => {
test("replays If-None-Match and serves the cached body on 304", async () => {
const etag = 'W/"abc123"';
const pulls = [{ number: 1, title: "one" }];
let calls = 0;
let secondRequestIfNoneMatch: string | undefined;
const octokit = clientWithFetch(async (_url, init) => {
calls += 1;
if (calls === 1) {
return jsonResponse(pulls, { status: 200, etag });
}
secondRequestIfNoneMatch = init?.headers?.["if-none-match"];
return jsonResponse(null, { status: 304, etag });
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
const first = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
state: "all",
});
const second = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
state: "all",
});
expect(calls).toBe(2);
expect(secondRequestIfNoneMatch).toBe(etag);
// A 304 is transparently presented as a 200 carrying the cached body.
expect(second.status).toBe(200);
expect(second.data).toEqual(first.data);
expect(second.data).toEqual(pulls);
});
test("does not attach conditional headers to non-GET requests", async () => {
let sawIfNoneMatch = false;
const octokit = clientWithFetch(async (_url, init) => {
if (init?.headers?.["if-none-match"]) sawIfNoneMatch = true;
return jsonResponse({ ok: true }, { status: 201 });
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
await octokit.request("POST /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
title: "x",
head: "a",
base: "b",
});
expect(sawIfNoneMatch).toBe(false);
});
test("makes a full request again when the response carries no ETag", async () => {
let calls = 0;
let secondRequestIfNoneMatch: string | undefined;
const octokit = clientWithFetch(async (_url, init) => {
calls += 1;
if (calls >= 2) secondRequestIfNoneMatch = init?.headers?.["if-none-match"];
return jsonResponse([{ number: 1 }], { status: 200 }); // no etag
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
});
const second = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
});
expect(calls).toBe(2);
expect(secondRequestIfNoneMatch).toBeUndefined();
expect(second.status).toBe(200);
});
test("keys cache entries per expanded URL so repos do not collide", async () => {
// Each resource gets its own ETag; the stub returns 304 only when the
// presented If-None-Match matches the ETag issued for that exact URL.
const etagByUrl = new Map<string, string>();
let notModifiedCount = 0;
const octokit = clientWithFetch(async (url, init) => {
const ifNoneMatch = init?.headers?.["if-none-match"] as
| string
| undefined;
let etag = etagByUrl.get(url);
if (!etag) {
etag = `W/"etag-${etagByUrl.size + 1}"`;
etagByUrl.set(url, etag);
}
if (ifNoneMatch && ifNoneMatch === etag) {
notModifiedCount += 1;
return jsonResponse(null, { status: 304, etag });
}
return jsonResponse([{ url }], { status: 200, etag });
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
const get = (owner: string, repo: string) =>
octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner,
repo,
state: "all",
});
const first = await get("alpha", "one");
await get("beta", "two");
const third = await get("alpha", "one");
// The repeated first request must revalidate against its OWN ETag and come
// back as a 304 cache hit, not be clobbered by the second repo's entry.
expect(notModifiedCount).toBe(1);
expect(third.status).toBe(200);
expect(third.data).toEqual(first.data);
});
test("isolates cached entries by scope", () => {
const store = new InMemoryConditionalRequestStore();
store.set(conditionalRequestCacheKey("user-1", "GET", "/x"), {
etag: "a",
data: 1,
status: 200,
});
expect(
store.get(conditionalRequestCacheKey("user-2", "GET", "/x")),
).toBeUndefined();
expect(
store.get(conditionalRequestCacheKey("user-1", "GET", "/x"))?.etag,
).toBe("a");
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { Octokit } from "@octokit/rest";
/**
* A single cached GitHub response, retained so that a later conditional request
* which comes back `304 Not Modified` can be served from memory instead of
* downloading the full body again. Only the fields the mirror actually consumes
* are stored.
*/
export interface CachedResponse {
etag: string;
data: unknown;
status: number;
/** Preserved so `octokit.paginate` can keep following pages on a cache hit. */
link?: string;
}
export interface ConditionalRequestStore {
get(key: string): CachedResponse | undefined;
set(key: string, value: CachedResponse): void;
}
/**
* Process-lifetime, bounded in-memory store. Gitea Mirror runs as a long-lived
* server whose scheduler re-syncs on an interval, so keeping ETags in memory is
* enough to turn each repeated poll into a cheap `304`. The bound keeps memory
* predictable; evicting an entry only costs one full response on the next sync.
*/
export class InMemoryConditionalRequestStore implements ConditionalRequestStore {
private readonly entries = new Map<string, CachedResponse>();
constructor(private readonly maxEntries = 5000) {}
get(key: string): CachedResponse | undefined {
return this.entries.get(key);
}
set(key: string, value: CachedResponse): void {
// Delete-then-set so Map iteration order approximates LRU for eviction.
this.entries.delete(key);
this.entries.set(key, value);
if (this.entries.size > this.maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest !== undefined) this.entries.delete(oldest);
}
}
}
/**
* Shared across every client so ETags survive the per-sync re-creation of the
* Octokit instance (a fresh client is built on each scheduled sync).
*/
export const defaultConditionalRequestStore =
new InMemoryConditionalRequestStore();
export function conditionalRequestCacheKey(
scope: string,
method: string,
url: string,
): string {
return `${scope} ${method.toUpperCase()} ${url}`;
}
/**
* Adds ETag-based conditional requests to an Octokit instance. For every GET it
* replays the previously stored `If-None-Match`; GitHub then answers `304 Not
* Modified` — which does not count against the token's primary rate limit —
* whenever nothing changed, and the cached body is returned in place of a full
* re-download. Non-GET requests are passed through untouched.
*
* The scope isolates cache entries per token/user so one account never reads
* another's cached data.
*/
export function applyConditionalRequests(
octokit: Octokit,
options: { store?: ConditionalRequestStore; scope?: string } = {},
): void {
// Some tests stub Octokit without the hook system; skip wiring in that case.
if (typeof (octokit as any)?.hook?.wrap !== "function") return;
const store = options.store ?? defaultConditionalRequestStore;
const scope = options.scope ?? "default";
octokit.hook.wrap("request", async (request: any, requestOptions: any): Promise<any> => {
const method = String(requestOptions.method ?? "GET").toUpperCase();
if (method !== "GET") {
return request(requestOptions);
}
// Build the key from the expanded absolute URL, not the route template.
// Inside the hook `requestOptions.url` is still `/repos/{owner}/{repo}/...`,
// so keying on it would collapse every repo into a single entry per user +
// endpoint and stop the 304 path from firing once more than one repo syncs.
// `octokit.request.endpoint.parse` expands the route (owner/repo + query);
// the chained `request` argument has no `.endpoint`, so it must come from
// `octokit.request`.
const parseEndpoint = (octokit as any)?.request?.endpoint?.parse;
const expandedUrl =
typeof parseEndpoint === "function"
? parseEndpoint(requestOptions).url
: requestOptions.url;
const key = conditionalRequestCacheKey(scope, method, expandedUrl);
const cached = store.get(key);
if (cached?.etag) {
requestOptions.headers = {
...requestOptions.headers,
"if-none-match": cached.etag,
};
}
try {
const response = await request(requestOptions);
const etag = response?.headers?.etag;
if (etag && response.status >= 200 && response.status < 300) {
store.set(key, {
etag,
data: response.data,
status: response.status,
link: response.headers?.link,
});
}
return response;
} catch (error: any) {
// GitHub answered "not modified": reuse the stored body, presented as a
// 200 so callers (including octokit.paginate) are unaffected.
if (error?.status === 304 && cached) {
return {
status: 200,
url: expandedUrl,
headers: {
...(error.response?.headers ?? {}),
etag: cached.etag,
...(cached.link ? { link: cached.link } : {}),
},
data: cached.data,
} as any;
}
throw error;
}
});
}
+5
View File
@@ -3,6 +3,7 @@ import type { GitRepo, RepoStatus } from "@/types/Repository";
import { Octokit } from "@octokit/rest";
import { throttling } from "@octokit/plugin-throttling";
import type { Config } from "@/types/config";
import { applyConditionalRequests } from "@/lib/github-conditional-requests";
// Conditionally import rate limit manager (not available in test environment)
let RateLimitManager: any = null;
let publishEvent: any = null;
@@ -202,6 +203,10 @@ export function createGitHubClient(
});
}
// Reuse ETags across syncs so unchanged lists (e.g. pull requests) come back
// as rate-limit-free 304s instead of full downloads on every scheduled poll.
applyConditionalRequests(octokit, { scope: userId ?? username });
return octokit;
}