diff --git a/src/github/app.ts b/src/github/app.ts index e5a91bed6..f4c5584f0 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -41,6 +41,14 @@ export { rateLimitRetryMs, setGitHubResponseCache, } from "./client"; +export { + fetchCachedGitHubGraphQl, + githubGraphQlCacheTtlSeconds, + graphqlCacheClassForQuery, + graphqlOperationName, + isCacheableGraphQlQuery, + isCacheableGraphQlResponseBody, +} from "./graphql-cache"; type CheckRunResponse = { id: number; diff --git a/src/github/backfill.ts b/src/github/backfill.ts index e6246645d..a1eb94766 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -75,7 +75,7 @@ import { timeoutFetch, type GitHubRateLimitAdmissionKey, } from "./client"; - +import { fetchCachedGitHubGraphQl } from "./graphql-cache"; type GitHubLabelPayload = { name: string; color?: string; @@ -3234,18 +3234,10 @@ async function githubGraphQl( token: string, admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { - const response = await timeoutFetch("https://api.github.com/graphql", { - method: "POST", - headers: { - accept: "application/vnd.github+json", - "content-type": "application/json", - "user-agent": "gittensory/0.1", - authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ query }), - ...(admissionKey ? { githubRateLimitAdmission: true, githubRateLimitAdmissionKey: admissionKey } : {}), - }); - await recordGitHubResponse(env, null, "/graphql", response, "graphql", admissionKey); + const response = await fetchCachedGitHubGraphQl(query, token, admissionKey); + if (!isGitHubResponseCacheReplay(response)) { + await recordGitHubResponse(env, null, "/graphql", response, "graphql", admissionKey); + } if (!response.ok) { const body = await response.text(); throw new GitHubApiError( diff --git a/src/github/client.ts b/src/github/client.ts index 7b278d4b5..06ef5116f 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -49,6 +49,10 @@ export function setGitHubResponseCache(cache: GitHubResponseCache | null): void responseCache = cache; } +export function getGitHubResponseCache(): GitHubResponseCache | null { + return responseCache; +} + export type GitHubCacheClass = "branch_protection" | "metadata" | "commit"; type EnvLookup = Record; export type GitHubTimeoutFetchInit = RequestInit & { diff --git a/src/github/graphql-cache.ts b/src/github/graphql-cache.ts new file mode 100644 index 000000000..49967d12c --- /dev/null +++ b/src/github/graphql-cache.ts @@ -0,0 +1,182 @@ +import { + GITHUB_RESPONSE_CACHE_REPLAY_HEADER, + getGitHubResponseCache, + timeoutFetch, + type CachedGitHubResponse, + type GitHubRateLimitAdmissionKey, + type GitHubTimeoutFetchInit, +} from "./client"; +import { incr } from "../selfhost/metrics"; + +const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; +const GITHUB_GRAPHQL_CACHE_METRIC = "gittensory_github_graphql_cache_total"; +const DEFAULT_GRAPHQL_TTL_SECONDS = 10 * 60; + +export type GitHubGraphQlCacheClass = "repo_totals" | "contributor_activity"; + +/** Only cache explicitly stable GraphQL operations used by backfill sweeps. PR/issue/review/thread/detail + * reads are mutable gate inputs and must always reflect current GitHub state. Exported for tests. */ +export function graphqlOperationName(query: string): string | null { + const match = /^\s*query\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(query); + return match?.[1] ?? null; +} + +export function graphqlCacheClassForQuery(query: string): GitHubGraphQlCacheClass | null { + const operation = graphqlOperationName(query); + if (operation === "GittensoryRepoTotals") return "repo_totals"; + if (operation === "GittensoryContributorActivity") return "contributor_activity"; + return null; +} + +export function isCacheableGraphQlQuery(query: string): boolean { + return graphqlCacheClassForQuery(query) !== null; +} + +/** GitHub GraphQL returns HTTP 200 for many failure modes; only cache bodies without a non-empty `errors` array. */ +export function isCacheableGraphQlResponseBody(body: string): boolean { + try { + const payload = JSON.parse(body) as { errors?: unknown }; + return !Array.isArray(payload.errors) || payload.errors.length === 0; + } catch { + return false; + } +} + +function positiveEnvSeconds(env: Record, name: string, fallback: number): number { + const raw = env[name]; + if (raw === undefined || raw.trim() === "") return fallback; + const value = Number(raw); + if (!Number.isFinite(value)) return fallback; + const seconds = Math.floor(value); + return seconds >= 1 ? seconds : fallback; +} + +export function githubGraphQlCacheTtlSeconds(cls: GitHubGraphQlCacheClass, env: Record = process.env): number { + return positiveEnvSeconds(env, "GITHUB_GRAPHQL_CACHE_TTL_SECONDS", DEFAULT_GRAPHQL_TTL_SECONDS); +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function graphqlCacheKey(query: string, token: string): Promise { + const authHash = await sha256Hex(`Bearer ${token}`); + const queryHash = (await sha256Hex(query)).slice(0, 16); + return `gql:v1:${authHash}:${queryHash}`; +} + +function graphqlSingleFlightKey(cacheKey: string, admissionKey?: GitHubRateLimitAdmissionKey): string { + return `${cacheKey}:${admissionKey ?? ""}`; +} + +function recordGraphQlCacheMetric(result: "hit" | "miss" | "set" | "coalesced" | "bypassed" | "error", cls: string): void { + incr(GITHUB_GRAPHQL_CACHE_METRIC, { result, class: cls }); +} + +function responseFromCached(hit: CachedGitHubResponse, replayKind: "hit" | "coalesced"): Response { + const headers = new Headers({ "content-type": hit.contentType, [GITHUB_RESPONSE_CACHE_REPLAY_HEADER]: replayKind }); + return new Response(hit.body, { status: hit.status, headers }); +} + +function graphQlFetchInit(query: string, token: string, admissionKey?: GitHubRateLimitAdmissionKey): GitHubTimeoutFetchInit { + return { + method: "POST", + headers: { + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "gittensory/0.1", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query }), + ...(admissionKey ? { githubRateLimitAdmission: true, githubRateLimitAdmissionKey: admissionKey } : {}), + }; +} + +async function fetchGraphQlWithRetry( + query: string, + token: string, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + return timeoutFetch(GITHUB_GRAPHQL_URL, graphQlFetchInit(query, token, admissionKey)); +} + +async function fetchAndMaybeCacheGraphQl( + query: string, + token: string, + cacheKey: string, + cls: GitHubGraphQlCacheClass, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise<{ response: Response; cached: CachedGitHubResponse | null }> { + const response = await fetchGraphQlWithRetry(query, token, admissionKey); + if (response.status !== 200) return { response, cached: null }; + try { + const body = await response.clone().text(); + if (!isCacheableGraphQlResponseBody(body)) return { response, cached: null }; + const cached = { + status: 200, + body, + contentType: response.headers.get("content-type") ?? "application/json", + }; + await getGitHubResponseCache()!.set(cacheKey, cached, githubGraphQlCacheTtlSeconds(cls)); + recordGraphQlCacheMetric("set", cls); + return { response, cached }; + } catch { + recordGraphQlCacheMetric("error", cls); + return { response, cached: null }; + } +} + +const inFlightGraphQlPosts = new Map>(); + +/** Auth-aware shared cache for allowlisted stable GitHub GraphQL POST reads. */ +export async function fetchCachedGitHubGraphQl( + query: string, + token: string, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + const cache = getGitHubResponseCache(); + const cls = graphqlCacheClassForQuery(query); + const useCache = cache !== null && cls !== null; + if (!useCache) { + recordGraphQlCacheMetric("bypassed", cls ?? "sensitive"); + return fetchGraphQlWithRetry(query, token, admissionKey); + } + + const cacheKey = await graphqlCacheKey(query, token); + let hit: CachedGitHubResponse | null = null; + try { + hit = await cache.get(cacheKey); + } catch { + recordGraphQlCacheMetric("error", cls); + } + if (hit?.status === 200 && isCacheableGraphQlResponseBody(hit.body)) { + recordGraphQlCacheMetric("hit", cls); + return responseFromCached(hit, "hit"); + } + recordGraphQlCacheMetric("miss", cls); + + const singleFlightKey = graphqlSingleFlightKey(cacheKey, admissionKey); + const existing = inFlightGraphQlPosts.get(singleFlightKey); + if (existing) { + recordGraphQlCacheMetric("coalesced", cls); + const replay = await existing; + if (replay) return responseFromCached(replay, "coalesced"); + } + + const request = fetchAndMaybeCacheGraphQl(query, token, cacheKey, cls, admissionKey).then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }), + ); + const shared = request.then((settled) => (settled.ok ? settled.result.cached : null)); + const sharedWithCleanup = shared.finally(() => inFlightGraphQlPosts.delete(singleFlightKey)); + inFlightGraphQlPosts.set(singleFlightKey, sharedWithCleanup); + const result = await request; + if (!result.ok) throw result.error; + return result.result.response; +} + +/** Test-only: reset shared GraphQL cache single-flight state between tests. */ +export function clearGitHubGraphQlCacheForTest(): void { + inFlightGraphQlPosts.clear(); +} diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 6b479ce22..eef6377ee 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -3484,6 +3484,37 @@ describe("GitHub backfill", () => { ); }); + it("uses the shared GraphQL cache for allowlisted totals reads without double-counting rate-limit observations", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-25T00:05:00.000Z")); + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + const store = new Map(); + setGitHubResponseCache({ + get: async (key) => store.get(key) ?? null, + set: async (key, value) => void store.set(key, value), + }); + let graphQlFetches = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + graphQlFetches += 1; + return githubTotalsResponse({ openIssues: 0, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + } + if (url.includes("/labels?")) return Response.json([]); + return Response.json([]); + }); + + await persistTotalsSnapshot(env, { fetchedAt: "2026-05-24T23:40:00.000Z", labelsTotal: 0 }); + await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "light", force: true }); + await env.DB.prepare(`update repo_github_totals_snapshots set fetched_at = '2026-05-24T23:40:00.000Z' where repo_full_name = 'JSONbored/gittensory'`).run(); + await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "light", force: true }); + + expect(graphQlFetches).toBe(1); + expect([...store.keys()].some((key) => key.startsWith("gql:v1:"))).toBe(true); + expect((await listLatestGitHubRateLimitObservations(env)).filter((observation) => observation.resource === "graphql")).toHaveLength(1); + }); + it("records label rate limits, in-loop page caps, and expired rate observations", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); await seedRegisteredRepo(env); diff --git a/test/unit/github-graphql-cache.test.ts b/test/unit/github-graphql-cache.test.ts new file mode 100644 index 000000000..ab26e66d8 --- /dev/null +++ b/test/unit/github-graphql-cache.test.ts @@ -0,0 +1,448 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clearGitHubGraphQlCacheForTest, + fetchCachedGitHubGraphQl, + githubGraphQlCacheTtlSeconds, + graphqlCacheClassForQuery, + graphqlOperationName, + isCacheableGraphQlQuery, + isCacheableGraphQlResponseBody, +} from "../../src/github/graphql-cache"; +import { + clearGitHubResponseCacheForTest, + GITHUB_RESPONSE_CACHE_REPLAY_HEADER, + githubRateLimitAdmissionKeyForInstallation, + isGitHubResponseCacheReplay, + latestGitHubRestRateLimitObservation, + setGitHubResponseCache, + type CachedGitHubResponse, +} from "../../src/github/client"; +import { listLatestGitHubRateLimitObservations, recordGitHubRateLimitObservation } from "../../src/db/repositories"; +import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; +import { createTestEnv } from "../helpers/d1"; + +const TOTALS_QUERY = `query GittensoryRepoTotals { + rateLimit { remaining resetAt } + repository(owner: "o", name: "r") { + issues(states: OPEN) { totalCount } + openPullRequests: pullRequests(states: OPEN) { totalCount } + mergedPullRequests: pullRequests(states: MERGED) { totalCount } + closedPullRequests: pullRequests(states: CLOSED) { totalCount } + labels { totalCount } + } +}`; + +const MUTABLE_QUERY = `query GittensoryPullRequestDetails { + repository(owner: "o", name: "r") { + pullRequest(number: 1) { title } + } +}`; + +const ANONYMOUS_QUERY = `query { + rateLimit { remaining } +}`; + +function installMemoryResponseCache(): Map { + const store = new Map(); + setGitHubResponseCache({ + get: async (key) => store.get(key) ?? null, + set: async (key, value, ttlSeconds) => void store.set(key, { ...value, ...(ttlSeconds ? {} : {}) }), + }); + return store; +} + +afterEach(() => { + clearGitHubResponseCacheForTest(); + clearGitHubGraphQlCacheForTest(); + resetMetrics(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("graphql cache allowlist", () => { + it("recognizes stable operations and rejects mutable or anonymous queries", () => { + expect(graphqlOperationName(TOTALS_QUERY)).toBe("GittensoryRepoTotals"); + expect(graphqlCacheClassForQuery(TOTALS_QUERY)).toBe("repo_totals"); + expect(isCacheableGraphQlQuery(TOTALS_QUERY)).toBe(true); + + expect(graphqlOperationName(`query GittensoryContributorActivity { rateLimit { remaining } }`)).toBe("GittensoryContributorActivity"); + expect(graphqlCacheClassForQuery(`query GittensoryContributorActivity { x: rateLimit { remaining } }`)).toBe("contributor_activity"); + expect(githubGraphQlCacheTtlSeconds("contributor_activity")).toBe(600); + vi.stubEnv("GITHUB_GRAPHQL_CACHE_TTL_SECONDS", " "); + expect(githubGraphQlCacheTtlSeconds("repo_totals")).toBe(600); + + expect(isCacheableGraphQlQuery(MUTABLE_QUERY)).toBe(false); + expect(isCacheableGraphQlQuery(ANONYMOUS_QUERY)).toBe(false); + expect(graphqlOperationName(" mutation X { }")).toBeNull(); + }); + + it("rejects GraphQL error envelopes and malformed bodies for caching", () => { + expect(isCacheableGraphQlResponseBody(JSON.stringify({ data: { repository: null } }))).toBe(true); + expect(isCacheableGraphQlResponseBody(JSON.stringify({ errors: [{ message: "rate limit" }] }))).toBe(false); + expect(isCacheableGraphQlResponseBody(JSON.stringify({ errors: [] }))).toBe(true); + expect(isCacheableGraphQlResponseBody("not-json")).toBe(false); + }); + + it("honors GITHUB_GRAPHQL_CACHE_TTL_SECONDS with a positive fallback", () => { + vi.stubEnv("GITHUB_GRAPHQL_CACHE_TTL_SECONDS", "120"); + expect(githubGraphQlCacheTtlSeconds("repo_totals")).toBe(120); + vi.stubEnv("GITHUB_GRAPHQL_CACHE_TTL_SECONDS", "0"); + expect(githubGraphQlCacheTtlSeconds("repo_totals")).toBe(600); + vi.stubEnv("GITHUB_GRAPHQL_CACHE_TTL_SECONDS", "not-a-number"); + expect(githubGraphQlCacheTtlSeconds("repo_totals")).toBe(600); + }); +}); + +describe("fetchCachedGitHubGraphQl", () => { + it("passes admission keys through on live GraphQL fetches", async () => { + clearGitHubResponseCacheForTest(); + const admissionKey = githubRateLimitAdmissionKeyForInstallation(123); + vi.stubGlobal("fetch", async () => + Response.json( + { data: { repository: { pullRequest: { title: "live" } } } }, + { headers: { "x-ratelimit-remaining": "4200", "x-ratelimit-reset": "1782802800" } }, + ), + ); + + await fetchCachedGitHubGraphQl(MUTABLE_QUERY, "token-a", admissionKey); + + expect(latestGitHubRestRateLimitObservation(admissionKey)).toMatchObject({ remaining: 4200 }); + }); + + it("passes admission keys through on cacheable GraphQL cold misses", async () => { + installMemoryResponseCache(); + const admissionKey = githubRateLimitAdmissionKeyForInstallation(456); + vi.stubGlobal("fetch", async () => + Response.json( + { data: { repository: { issues: { totalCount: 1 } } } }, + { headers: { "x-ratelimit-remaining": "4100", "x-ratelimit-reset": "1782802800" } }, + ), + ); + + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a", admissionKey); + + expect(latestGitHubRestRateLimitObservation(admissionKey)).toMatchObject({ remaining: 4100 }); + }); + + it("serves a cache hit on the second identical totals query and skips duplicate network calls", async () => { + const store = installMemoryResponseCache(); + let fetches = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + fetches += 1; + expect(String(input)).toBe("https://api.github.com/graphql"); + return Response.json( + { + data: { + rateLimit: { remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, + repository: { + issues: { totalCount: 1 }, + openPullRequests: { totalCount: 2 }, + mergedPullRequests: { totalCount: 3 }, + closedPullRequests: { totalCount: 4 }, + labels: { totalCount: 5 }, + }, + }, + }, + { headers: { "x-ratelimit-remaining": "4999", "x-ratelimit-limit": "5000" } }, + ); + }); + + const first = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + const second = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + + expect(first.headers.get(GITHUB_RESPONSE_CACHE_REPLAY_HEADER)).toBeNull(); + expect(second.headers.get(GITHUB_RESPONSE_CACHE_REPLAY_HEADER)).toBe("hit"); + expect(fetches).toBe(1); + expect(store.size).toBe(1); + expect([...store.keys()].some((key) => key.startsWith("gql:v1:"))).toBe(true); + const cacheKey = [...store.keys()][0]!; + const authHash = cacheKey.split(":")[2]; + expect(authHash).toMatch(/^[0-9a-f]{64}$/); + expect([...store.keys()].some((key) => key.includes("token-a"))).toBe(false); + }); + + it("isolates cache entries by auth token", async () => { + installMemoryResponseCache(); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ data: { repository: { issues: { totalCount: fetches } } } }); + }); + + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-b"); + + expect(fetches).toBe(2); + }); + + it("single-flights concurrent cold misses for the same query", async () => { + installMemoryResponseCache(); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + return Response.json({ data: { repository: { issues: { totalCount: 1 } } } }); + }); + + const [a, b] = await Promise.all([ + fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"), + fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"), + ]); + + expect(fetches).toBe(1); + expect(a.headers.get(GITHUB_RESPONSE_CACHE_REPLAY_HEADER)).toBeNull(); + expect(b.headers.get(GITHUB_RESPONSE_CACHE_REPLAY_HEADER)).toBe("coalesced"); + }); + + it("does not coalesce concurrent cold misses when admission keys differ", async () => { + installMemoryResponseCache(); + const keyA = githubRateLimitAdmissionKeyForInstallation(111); + const keyB = githubRateLimitAdmissionKeyForInstallation(222); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + return Response.json( + { data: { repository: { issues: { totalCount: 1 } } } }, + { headers: { "x-ratelimit-remaining": String(5000 - fetches), "x-ratelimit-reset": "1782802800" } }, + ); + }); + + const [a, b] = await Promise.all([ + fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a", keyA), + fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a", keyB), + ]); + + expect(fetches).toBe(2); + expect(a.headers.get(GITHUB_RESPONSE_CACHE_REPLAY_HEADER)).toBeNull(); + expect(b.headers.get(GITHUB_RESPONSE_CACHE_REPLAY_HEADER)).toBeNull(); + expect(latestGitHubRestRateLimitObservation(keyA)).not.toBeNull(); + expect(latestGitHubRestRateLimitObservation(keyB)).not.toBeNull(); + }); + + it("bypasses cache for mutable PR detail queries", async () => { + installMemoryResponseCache(); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ data: { repository: { pullRequest: { title: "x" } } } }); + }); + + await fetchCachedGitHubGraphQl(MUTABLE_QUERY, "token-a"); + await fetchCachedGitHubGraphQl(MUTABLE_QUERY, "token-a"); + + expect(fetches).toBe(2); + expect(await renderMetrics()).toContain('gittensory_github_graphql_cache_total{class="sensitive",result="bypassed"}'); + }); + + it("does not cache non-200 GraphQL responses", async () => { + const store = installMemoryResponseCache(); + vi.stubGlobal("fetch", async () => new Response("error", { status: 500 })); + const response = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + expect(response.status).toBe(500); + expect(store.size).toBe(0); + }); + + it("does not cache HTTP 200 GraphQL responses that carry an errors envelope", async () => { + const store = installMemoryResponseCache(); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ errors: [{ message: "Something went wrong" }] }); + }); + + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + + expect(fetches).toBe(2); + expect(store.size).toBe(0); + expect(await renderMetrics()).not.toContain('gittensory_github_graphql_cache_total{class="repo_totals",result="set"}'); + }); + + it("treats cached GraphQL error envelopes as a miss on replay", async () => { + setGitHubResponseCache({ + get: async () => ({ + status: 200, + body: JSON.stringify({ errors: [{ message: "stale cached failure" }] }), + contentType: "application/json", + }), + set: async () => undefined, + }); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ data: { repository: { issues: { totalCount: 3 } } } }); + }); + + const response = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + expect(await response.json()).toMatchObject({ data: { repository: { issues: { totalCount: 3 } } } }); + expect(fetches).toBe(1); + }); + + it("fail-opens on cache write errors after a successful upstream fetch", async () => { + setGitHubResponseCache({ + get: async () => null, + set: async () => { + throw new Error("redis write failed"); + }, + }); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ data: { repository: { issues: { totalCount: 1 } } } }); + }); + + const response = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + expect(response.ok).toBe(true); + expect(fetches).toBe(1); + expect(await renderMetrics()).toContain('gittensory_github_graphql_cache_total{class="repo_totals",result="error"}'); + }); + + it("fail-opens on cache read errors and still fetches upstream", async () => { + setGitHubResponseCache({ + get: async () => { + throw new Error("redis down"); + }, + set: async () => undefined, + }); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ data: { repository: { issues: { totalCount: 1 } } } }); + }); + + const response = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + expect(response.ok).toBe(true); + expect(fetches).toBe(1); + expect(await renderMetrics()).toContain('gittensory_github_graphql_cache_total{class="repo_totals",result="error"}'); + }); + + it("treats malformed cached payloads as a miss", async () => { + setGitHubResponseCache({ + get: async () => ({ status: 500, body: "nope", contentType: "application/json" }), + set: async () => undefined, + }); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ data: { repository: { issues: { totalCount: 2 } } } }); + }); + + const response = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + expect(await response.json()).toMatchObject({ data: { repository: { issues: { totalCount: 2 } } } }); + expect(fetches).toBe(1); + }); + + it("retries transient rate limits before caching a successful totals response", async () => { + installMemoryResponseCache(); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + if (fetches === 1) { + return new Response("secondary rate limit", { status: 403, headers: { "retry-after": "0" } }); + } + return Response.json({ data: { repository: { issues: { totalCount: 1 } } } }); + }); + + const response = await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + expect(response.ok).toBe(true); + expect(fetches).toBe(2); + }); + + it("falls through when a coalesced in-flight fetch fails to populate the cache", async () => { + let setCalls = 0; + setGitHubResponseCache({ + get: async () => null, + set: async () => { + setCalls += 1; + throw new Error("cache write failed"); + }, + }); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + await new Promise((resolve) => setTimeout(resolve, 15)); + return Response.json({ data: { repository: { issues: { totalCount: 1 } } } }); + }); + + const [first, second] = await Promise.all([ + fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"), + fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"), + ]); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + expect(fetches).toBe(2); + expect(setCalls).toBeGreaterThanOrEqual(2); + expect(await renderMetrics()).toContain('gittensory_github_graphql_cache_total{class="repo_totals",result="coalesced"}'); + }); + + it("surfaces upstream fetch failures from the cache path", async () => { + installMemoryResponseCache(); + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + await expect(fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a")).rejects.toThrow("network down"); + }); + + it("defaults missing content-type when caching a GraphQL response", async () => { + const store = installMemoryResponseCache(); + vi.stubGlobal("fetch", async () => { + const response = Response.json({ data: { repository: { issues: { totalCount: 1 } } } }); + response.headers.delete("content-type"); + return response; + }); + + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + + expect([...store.values()][0]).toMatchObject({ contentType: "application/json" }); + }); + + it("bypasses caching when the shared response cache is disabled", async () => { + clearGitHubResponseCacheForTest(); + let fetches = 0; + vi.stubGlobal("fetch", async () => { + fetches += 1; + return Response.json({ data: { repository: { issues: { totalCount: 1 } } } }); + }); + + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a"); + + expect(fetches).toBe(2); + expect(await renderMetrics()).toContain('gittensory_github_graphql_cache_total{class="repo_totals",result="bypassed"}'); + }); +}); + +describe("githubGraphQl rate-limit observation boundary", () => { + it("records rate-limit observations only for live GraphQL fetches, not cache replays", async () => { + const env = createTestEnv(); + installMemoryResponseCache(); + vi.stubGlobal("fetch", async () => + Response.json( + { data: { repository: { issues: { totalCount: 1 } } } }, + { headers: { "x-ratelimit-remaining": "4999", "x-ratelimit-limit": "5000", "x-ratelimit-reset": "1782802800" } }, + ), + ); + + const record = async (response: Response) => { + if (isGitHubResponseCacheReplay(response)) return; + await recordGitHubRateLimitObservation(env, { + repoFullName: null, + resource: "graphql", + path: "/graphql", + statusCode: response.status, + limitValue: Number(response.headers.get("x-ratelimit-limit")), + remaining: Number(response.headers.get("x-ratelimit-remaining")), + resetAt: new Date(Number(response.headers.get("x-ratelimit-reset")) * 1000).toISOString(), + }); + }; + + await record(await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a")); + await record(await fetchCachedGitHubGraphQl(TOTALS_QUERY, "token-a")); + + const observations = await listLatestGitHubRateLimitObservations(env); + expect(observations).toHaveLength(1); + expect(observations[0]).toMatchObject({ resource: "graphql", path: "/graphql", remaining: 4999 }); + }); +});