authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 17:26:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 17:26:26-07:00
logd720210996a514368ba99f4860063110033d93fa
treefc99acf46fd9eb1a53983ca9952d7cbee7a73951
parent42d92ec59775c187af69071c81b2b91180810bdd
signature Signed by PGP key B5690EEEBB952194

refactor(server): GitHub rate limits read the response headers (#16986)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

10 files changed, 350 insertions(+), 525 deletions(-)

apps/server/scripts/measure-pr-preview.ts+12-4
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 4561// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456
2// Numbers form a session with shared repository-permission caches. Browser and2// Numbers form a session with shared repository-permission caches. Browser and
3// service caches are excluded. Uses real GitHub reads, without a server or database.3// service caches are excluded. Uses real GitHub reads, without a server or database.
4// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer.4// Each GraphQL read asks for `rateLimit` here, so its cost is read off its own answer.
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Console from "effect/Console";7import * as Console from "effect/Console";
...@@ -15,7 +15,7 @@ import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequest...@@ -15,7 +15,7 @@ import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequest
15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";
17import * as ServerSettings from "../src/serverSettings.ts";17import * as ServerSettings from "../src/serverSettings.ts";
18import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts";18import * as GitHubQuota from "../src/sourceControl/githubQuota.ts";
19import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts";19import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts";
20import * as VcsProcess from "../src/vcs/VcsProcess.ts";20import * as VcsProcess from "../src/vcs/VcsProcess.ts";
2121
...@@ -33,6 +33,14 @@ const decodeCost = Schema.decodeUnknownOption(...@@ -33,6 +33,14 @@ const decodeCost = Schema.decodeUnknownOption(
33 }),33 }),
34 ),34 ),
35);35);
36/** The query with GitHub's own count of what it cost, for a read rather than a mutation. */
37function withRateLimit(query: string): string {
38 const end = query.lastIndexOf("}");
39 return query.trimStart().startsWith("mutation") || end === -1
40 ? query
41 : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`;
42}
43
36const measuredApi = Layer.effect(44const measuredApi = Layer.effect(
37 GitHubApi.GitHubApi,45 GitHubApi.GitHubApi,
38 Effect.gen(function* () {46 Effect.gen(function* () {
...@@ -40,7 +48,7 @@ const measuredApi = Layer.effect(...@@ -40,7 +48,7 @@ const measuredApi = Layer.effect(
40 return GitHubApi.GitHubApi.of({48 return GitHubApi.GitHubApi.of({
41 ...api,49 ...api,
42 graphql: (input) =>50 graphql: (input) =>
43 api.graphql(input).pipe(51 api.graphql({ ...input, query: withRateLimit(input.query) }).pipe(
44 Effect.tap((body) =>52 Effect.tap((body) =>
45 Effect.sync(() =>53 Effect.sync(() =>
46 reads.push({54 reads.push({
...@@ -66,7 +74,7 @@ const measuredApi = Layer.effect(...@@ -66,7 +74,7 @@ const measuredApi = Layer.effect(
66 }),74 }),
67).pipe(75).pipe(
68 Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))),76 Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))),
69 Layer.provide(GitHubGraphQlBudget.layer),77 Layer.provide(GitHubQuota.layer),
70 Layer.provide(SourceControlRateLimit.layer),78 Layer.provide(SourceControlRateLimit.layer),
71 Layer.provide(FetchHttpClient.layer),79 Layer.provide(FetchHttpClient.layer),
72 Layer.provide(VcsProcess.layer),80 Layer.provide(VcsProcess.layer),
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts+49-73
...@@ -15,7 +15,7 @@ import { HttpClient, HttpClientResponse } from "effect/http";...@@ -15,7 +15,7 @@ import { HttpClient, HttpClientResponse } from "effect/http";
15import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";15import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
16import * as GitHubApi from "../sourceControl/GitHubApi.ts";16import * as GitHubApi from "../sourceControl/GitHubApi.ts";
17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
18import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";18import * as GitHubQuota from "../sourceControl/githubQuota.ts";
19import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";19import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
20import * as VcsProcess from "../vcs/VcsProcess.ts";20import * as VcsProcess from "../vcs/VcsProcess.ts";
21import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";21import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
...@@ -69,26 +69,29 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() =>...@@ -69,26 +69,29 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() =>
69);69);
7070
71/**71/**
72 * A transport that answers from the mocks above but spends the real GraphQL budget, the way72 * A transport that answers from the mocks above but spends the real GraphQL quota from each
73 * GitHubApi does, so the reserve and pause behaviour is still the module's to prove.73 * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove.
74 */74 */
75const mockApi = Layer.effect(75const mockApi = Layer.effect(
76 GitHubApi.GitHubApi,76 GitHubApi.GitHubApi,
77 Effect.gen(function* () {77 Effect.gen(function* () {
78 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;78 const quota = yield* GitHubQuota.GitHubQuota;
79 return GitHubApi.GitHubApi.of({79 return GitHubApi.GitHubApi.of({
80 graphql: (input) =>80 graphql: (input) =>
81 budget81 GitHubApi.AllowGitHubReserve.pipe(
82 .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined)82 Effect.flatMap((interactive) =>
83 .pipe(83 quota.admit(input.host, "graphql", {
84 Effect.flatMap((query) =>84 allowReserve: input.allowReserve ?? interactive,
85 (input.query.includes("query PullRequestStackMemberships")85 }),
86 ? mockedStackMemberships
87 : mockedExecute)({ kind: "graphql", ...input, query }),
88 ),
89 Effect.map((answer) => answer.body),
90 Effect.tap((body) => budget.observe(input.host, body)),
91 ),86 ),
87 Effect.andThen(() =>
88 (input.query.includes("query PullRequestStackMemberships")
89 ? mockedStackMemberships
90 : mockedExecute)({ kind: "graphql", ...input }),
91 ),
92 Effect.tap((answer) => quota.observe(input.host, answer.headers)),
93 Effect.map((answer) => answer.body),
94 ),
92 rest: (input) => mockedExecute({ kind: "rest", ...input }),95 rest: (input) => mockedExecute({ kind: "rest", ...input }),
93 credential: (host) => mockedCredential(host),96 credential: (host) => mockedCredential(host),
94 });97 });
...@@ -98,7 +101,7 @@ const mockApi = Layer.effect(...@@ -98,7 +101,7 @@ const mockApi = Layer.effect(
98const layer = it.layer(101const layer = it.layer(
99 GitHubPullRequestApi.layer.pipe(102 GitHubPullRequestApi.layer.pipe(
100 Layer.provideMerge(mockApi),103 Layer.provideMerge(mockApi),
101 Layer.provideMerge(GitHubGraphQlBudget.layer),104 Layer.provideMerge(GitHubQuota.layer),
102 Layer.provide(NodeCrypto.layer),105 Layer.provide(NodeCrypto.layer),
103 Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))),106 Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))),
104 ),107 ),
...@@ -109,8 +112,19 @@ function output(...@@ -109,8 +112,19 @@ function output(
109 body: string,112 body: string,
110 truncated = false,113 truncated = false,
111 invalidUtf8 = false,114 invalidUtf8 = false,
115 headers: Readonly<Record<string, string>> = {},
112): GitHubApi.GitHubRestResponse {116): GitHubApi.GitHubRestResponse {
113 return { status: 200, headers: {}, body, truncated, invalidUtf8 };117 return { status: 200, headers, body, truncated, invalidUtf8 };
118}
119
120/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */
121function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") {
122 return {
123 "x-ratelimit-resource": "graphql",
124 "x-ratelimit-limit": "5000",
125 "x-ratelimit-remaining": String(remaining),
126 "x-ratelimit-reset": String(Date.parse(resetAt) / 1000),
127 };
114}128}
115129
116/**130/**
...@@ -493,7 +507,7 @@ it.effect(...@@ -493,7 +507,7 @@ it.effect(
493 Effect.provide(507 Effect.provide(
494 GitHubApi.layer.pipe(508 GitHubApi.layer.pipe(
495 Layer.provide(Layer.mergeAll(credentials, http)),509 Layer.provide(Layer.mergeAll(credentials, http)),
496 Layer.provide(GitHubGraphQlBudget.layer),510 Layer.provide(GitHubQuota.layer),
497 Layer.provide(SourceControlRateLimit.layer),511 Layer.provide(SourceControlRateLimit.layer),
498 Layer.merge(VcsProcess.layer),512 Layer.merge(VcsProcess.layer),
499 Layer.provideMerge(NodeServices.layer),513 Layer.provideMerge(NodeServices.layer),
...@@ -538,15 +552,13 @@ it.effect(...@@ -538,15 +552,13 @@ it.effect(
538);552);
539553
540layer("GitHubPullRequestApi.layer", (it) => {554layer("GitHubPullRequestApi.layer", (it) => {
541 it.effect("admits only one concurrent preview above the reserve and resumes after reset", () =>555 it.effect("holds background previews once an answer reports the reserve, until it resets", () =>
542 Effect.gen(function* () {556 Effect.gen(function* () {
543 const now = yield* Clock.currentTimeMillis;557 const now = yield* Clock.currentTimeMillis;
544 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;558 const quota = yield* GitHubQuota.GitHubQuota;
545 const resetAt = "2099-08-13T14:00:00Z";559 const resetAt = "2099-08-13T14:00:00Z";
546 yield* budget.observe(560 yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt));
547 "preview-budget.example",561 // GitHub's count, not ours: the answer below reports the reserve has been reached.
548 encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }),
549 );
550 mockedExecute.mockReturnValue(562 mockedExecute.mockReturnValue(
551 Effect.succeed(563 Effect.succeed(
552 output(564 output(
...@@ -563,9 +575,11 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -563,9 +575,11 @@ layer("GitHubPullRequestApi.layer", (it) => {
563 author: null,575 author: null,
564 },576 },
565 },577 },
566 rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt },
567 },578 },
568 }),579 }),
580 false,
581 false,
582 graphqlQuota(499, resetAt),
569 ),583 ),
570 ),584 ),
571 );585 );
...@@ -576,18 +590,9 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -576,18 +590,9 @@ layer("GitHubPullRequestApi.layer", (it) => {
576 host: "preview-budget.example",590 host: "preview-budget.example",
577 number: 7,591 number: 7,
578 };592 };
579 const results = yield* Effect.all(593 yield* cli.getPullRequestPreview(input);
580 Array.from({ length: 20 }, (_, index) =>594 const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 }));
581 cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result),595 expect(held._tag).toBe("SourceControlRateLimitPausedError");
582 ),
583 { concurrency: "unbounded" },
584 );
585 expect(results.filter((result) => result._tag === "Success")).toHaveLength(1);
586 for (const result of results) {
587 if (result._tag === "Failure") {
588 expect(result.failure._tag).toBe("SourceControlRateLimitPausedError");
589 }
590 }
591 expect(mockedExecute).toHaveBeenCalledTimes(1);596 expect(mockedExecute).toHaveBeenCalledTimes(1);
592 yield* TestClock.setTime(Date.parse(resetAt));597 yield* TestClock.setTime(Date.parse(resetAt));
593 yield* cli.getPullRequestPreview(input);598 yield* cli.getPullRequestPreview(input);
...@@ -3220,7 +3225,7 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -3220,7 +3225,7 @@ layer("GitHubPullRequestApi.layer", (it) => {
3220 }),3225 }),
3221 );3226 );
32223227
3223 it.effect("accounts for the avatar lookup in the GraphQL budget", () =>3228 it.effect("looks up avatars by node id in one GraphQL read", () =>
3224 Effect.gen(function* () {3229 Effect.gen(function* () {
3225 mockedExecute.mockReturnValueOnce(3230 mockedExecute.mockReturnValueOnce(
3226 Effect.succeed(3231 Effect.succeed(
...@@ -3228,12 +3233,6 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -3228,12 +3233,6 @@ layer("GitHubPullRequestApi.layer", (it) => {
3228 encodeJson({3233 encodeJson({
3229 data: {3234 data: {
3230 nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }],3235 nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }],
3231 rateLimit: {
3232 cost: 1,
3233 limit: 5_000,
3234 remaining: 4_999,
3235 resetAt: "2099-08-13T14:00:00Z",
3236 },
3237 },3236 },
3238 }),3237 }),
3239 ),3238 ),
...@@ -3249,7 +3248,6 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -3249,7 +3248,6 @@ layer("GitHubPullRequestApi.layer", (it) => {
3249 });3248 });
32503249
3251 expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] });3250 expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] });
3252 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");
3253 expect(avatars.get("octocat")).toBe("https://avatars/octocat");3251 expect(avatars.get("octocat")).toBe("https://avatars/octocat");
3254 }),3252 }),
3255 );3253 );
...@@ -3852,22 +3850,7 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -3852,22 +3850,7 @@ layer("GitHubPullRequestApi.layer", (it) => {
3852 Effect.gen(function* () {3850 Effect.gen(function* () {
3853 const response = coreResponse();3851 const response = coreResponse();
3854 mockedExecute.mockReturnValue(3852 mockedExecute.mockReturnValue(
3855 Effect.succeed(3853 Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))),
3856 output(
3857 encodeJson({
3858 ...response,
3859 data: {
3860 ...response.data,
3861 rateLimit: {
3862 cost: 1,
3863 limit: 5000,
3864 remaining: 500,
3865 resetAt: "2099-08-13T14:00:00Z",
3866 },
3867 },
3868 }),
3869 ),
3870 ),
3871 );3854 );
3872 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;3855 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3873 const input = {3856 const input = {
...@@ -4125,14 +4108,11 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -4125,14 +4108,11 @@ layer("GitHubPullRequestApi.layer", (it) => {
4125 viewerPermission: "READ",4108 viewerPermission: "READ",
4126 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },4109 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4127 },4110 },
4128 rateLimit: {
4129 cost: 1,
4130 limit: 5_000,
4131 remaining: 500,
4132 resetAt: "2099-08-13T14:00:00Z",
4133 },
4134 },4111 },
4135 }),4112 }),
4113 false,
4114 false,
4115 graphqlQuota(499),
4136 ),4116 ),
4137 ),4117 ),
4138 );4118 );
...@@ -4145,7 +4125,6 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -4145,7 +4125,6 @@ layer("GitHubPullRequestApi.layer", (it) => {
4145 } as const;4125 } as const;
41464126
4147 yield* cli.getViewerAccess(input);4127 yield* cli.getViewerAccess(input);
4148 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");
41494128
4150 const error = yield* Effect.flip(cli.getViewerAccess(input));4129 const error = yield* Effect.flip(cli.getViewerAccess(input));
41514130
...@@ -4173,14 +4152,11 @@ layer("GitHubPullRequestApi.layer", (it) => {...@@ -4173,14 +4152,11 @@ layer("GitHubPullRequestApi.layer", (it) => {
4173 viewerPermission: "READ",4152 viewerPermission: "READ",
4174 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },4153 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4175 },4154 },
4176 rateLimit: {
4177 cost: 1,
4178 limit: 5_000,
4179 remaining: 500,
4180 resetAt: "2099-08-13T14:00:00Z",
4181 },
4182 },4155 },
4183 }),4156 }),
4157 false,
4158 false,
4159 graphqlQuota(499),
4184 ),4160 ),
4185 ),4161 ),
4186 )4162 )
apps/server/src/sourceControl/GitHubApi.test.ts+51-25
...@@ -12,7 +12,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h...@@ -12,7 +12,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h
1212
13import * as GitHubApi from "./GitHubApi.ts";13import * as GitHubApi from "./GitHubApi.ts";
14import * as GitHubCredentials from "./GitHubCredentials.ts";14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";15import * as GitHubQuota from "./githubQuota.ts";
16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
17import * as VcsProcess from "../vcs/VcsProcess.ts";17import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as ServerSettings from "../serverSettings.ts";18import * as ServerSettings from "../serverSettings.ts";
...@@ -49,7 +49,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp...@@ -49,7 +49,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp
49 );49 );
50 const layer = GitHubApi.layer.pipe(50 const layer = GitHubApi.layer.pipe(
51 Layer.provide(Layer.mergeAll(credentials, http)),51 Layer.provide(Layer.mergeAll(credentials, http)),
52 Layer.provideMerge(GitHubGraphQlBudget.layer),52 Layer.provideMerge(GitHubQuota.layer),
53 Layer.provideMerge(SourceControlRateLimit.layer),53 Layer.provideMerge(SourceControlRateLimit.layer),
54 );54 );
55 return { layer, requests, invalidations: () => invalidations };55 return { layer, requests, invalidations: () => invalidations };
...@@ -97,15 +97,8 @@ describe("environmentToken", () => {...@@ -97,15 +97,8 @@ describe("environmentToken", () => {
97});97});
9898
99describe("GitHubApi", () => {99describe("GitHubApi", () => {
100 it.effect("sends GraphQL with the token and records the reported budget", () => {100 it.effect("sends GraphQL with the token, and the document as written", () => {
101 const { layer, requests } = harness(() =>101 const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } }));
102 json({
103 data: {
104 viewer: { login: "julius" },
105 rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" },
106 },
107 }),
108 );
109 return Effect.gen(function* () {102 return Effect.gen(function* () {
110 yield* TestClock.setTime(NOW);103 yield* TestClock.setTime(NOW);
111 const api = yield* GitHubApi.GitHubApi;104 const api = yield* GitHubApi.GitHubApi;
...@@ -117,11 +110,16 @@ describe("GitHubApi", () => {...@@ -117,11 +110,16 @@ describe("GitHubApi", () => {
117 expect(body).toContain('"login":"julius"');110 expect(body).toContain('"login":"julius"');
118 expect(requests[0]!.url).toBe("https://api.github.com/graphql");111 expect(requests[0]!.url).toBe("https://api.github.com/graphql");
119 expect(requests[0]!.headers.authorization).toBe("Bearer first");112 expect(requests[0]!.headers.authorization).toBe("Bearer first");
113 // No `rateLimit` selection is added: the quota is read off the response headers.
114 const sent = requests[0]!.body;
115 expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain(
116 '"query":"query { viewer { login } }"',
117 );
120 }).pipe(Effect.provide(layer));118 }).pipe(Effect.provide(layer));
121 });119 });
122120
123 it.effect(121 it.effect(
124 "traces the operation, path, query and cost, never the query string or variables",122 "traces the operation, path, query and quota, never the query string or variables",
125 () => {123 () => {
126 const spans: Array<Tracer.NativeSpan> = [];124 const spans: Array<Tracer.NativeSpan> = [];
127 const tracer = Tracer.make({125 const tracer = Tracer.make({
...@@ -134,17 +132,7 @@ describe("GitHubApi", () => {...@@ -134,17 +132,7 @@ describe("GitHubApi", () => {
134 const { layer } = harness((request) =>132 const { layer } = harness((request) =>
135 request.url.endsWith("/graphql")133 request.url.endsWith("/graphql")
136 ? json(134 ? json(
137 {135 { data: { viewer: { login: "julius" } } },
138 data: {
139 viewer: { login: "julius" },
140 rateLimit: {
141 cost: 3,
142 limit: 5000,
143 remaining: 4990,
144 resetAt: "2026-10-05T13:00:00Z",
145 },
146 },
147 },
148 { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } },136 { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } },
149 )137 )
150 : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }),138 : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }),
...@@ -168,8 +156,10 @@ describe("GitHubApi", () => {...@@ -168,8 +156,10 @@ describe("GitHubApi", () => {
168 expect(graphql).toMatchObject({156 expect(graphql).toMatchObject({
169 "github.operation": "getPullRequestDetail",157 "github.operation": "getPullRequestDetail",
170 "github.graphql.query": "query($body: String!) { viewer { login } }",158 "github.graphql.query": "query($body: String!) { viewer { login } }",
171 "github.graphql.cost": 3,159 });
172 "github.graphql.remaining": 4990,160 expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({
161 "github.ratelimit.remaining": 4990,
162 "github.ratelimit.resource": "graphql",
173 });163 });
174 expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/);164 expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/);
175 const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes);165 const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes);
...@@ -258,6 +248,42 @@ describe("GitHubApi", () => {...@@ -258,6 +248,42 @@ describe("GitHubApi", () => {
258 }).pipe(Effect.provide(layer));248 }).pipe(Effect.provide(layer));
259 });249 });
260250
251 it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => {
252 const reset = Math.floor(NOW / 1000) + 600;
253 const quota = (remaining: number) => ({
254 "x-ratelimit-resource": "core",
255 "x-ratelimit-limit": "5000",
256 "x-ratelimit-remaining": String(remaining),
257 "x-ratelimit-reset": String(reset),
258 });
259 let remaining = 499;
260 const { layer, requests } = harness((request) =>
261 request.url.endsWith("/graphql")
262 ? json({ data: { viewer: { id: "1" } } })
263 : json({ ok: true }, { headers: quota(remaining--) }),
264 );
265 return Effect.gen(function* () {
266 yield* TestClock.setTime(NOW);
267 const api = yield* GitHubApi.GitHubApi;
268 const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" };
269 // The first answer leaves 499 of 5000, under the last tenth; the next background read waits.
270 yield* api.rest(read);
271 const refused = yield* Effect.flip(api.rest(read));
272 expect(refused).toMatchObject({
273 _tag: "SourceControlRateLimitPausedError",
274 retryAt: reset * 1000,
275 });
276 // A user's own request may spend the reserve, and GraphQL has a quota of its own.
277 yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true));
278 yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" });
279 expect(requests).toHaveLength(3);
280 // The reset gives the background its quota back.
281 yield* TestClock.setTime(reset * 1000);
282 yield* api.rest(read);
283 expect(requests).toHaveLength(4);
284 }).pipe(Effect.provide(layer));
285 });
286
261 it.effect("carries GitHub's own reason for a refused REST request", () => {287 it.effect("carries GitHub's own reason for a refused REST request", () => {
262 const { layer } = harness(() =>288 const { layer } = harness(() =>
263 json(289 json(
apps/server/src/sourceControl/GitHubApi.ts+19-31
...@@ -12,7 +12,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h...@@ -12,7 +12,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h
1212
13import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";13import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
14import * as GitHubCredentials from "./GitHubCredentials.ts";14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";15import * as GitHubQuota from "./githubQuota.ts";
16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
1717
18const DEFAULT_TIMEOUT = Duration.seconds(30);18const DEFAULT_TIMEOUT = Duration.seconds(30);
...@@ -218,16 +218,6 @@ function rateLimitAttributes(...@@ -218,16 +218,6 @@ function rateLimitAttributes(
218 return attributes;218 return attributes;
219}219}
220220
221const decodeGraphQlCost = Schema.decodeUnknownOption(
222 Schema.fromJsonString(
223 Schema.Struct({
224 data: Schema.Struct({
225 rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }),
226 }),
227 }),
228 ),
229);
230
231/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */221/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */
232function retryAtFrom(222function retryAtFrom(
233 headers: Readonly<Record<string, string | undefined>>,223 headers: Readonly<Record<string, string | undefined>>,
...@@ -354,7 +344,7 @@ function classify(input: {...@@ -354,7 +344,7 @@ function classify(input: {
354export const make = Effect.gen(function* () {344export const make = Effect.gen(function* () {
355 const httpClient = yield* HttpClient.HttpClient;345 const httpClient = yield* HttpClient.HttpClient;
356 const credentials = yield* GitHubCredentials.GitHubCredentials;346 const credentials = yield* GitHubCredentials.GitHubCredentials;
357 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;347 const quota = yield* GitHubQuota.GitHubQuota;
358 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;348 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
359 const gate = yield* Semaphore.make(CONCURRENCY);349 const gate = yield* Semaphore.make(CONCURRENCY);
360350
...@@ -403,10 +393,17 @@ export const make = Effect.gen(function* () {...@@ -403,10 +393,17 @@ export const make = Effect.gen(function* () {
403 });393 });
404 const { token, fingerprint } = yield* credential(host);394 const { token, fingerprint } = yield* credential(host);
405 const scope = yield* SourceControlRateLimit.CredentialScope;395 const scope = yield* SourceControlRateLimit.CredentialScope;
396 // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path
397 // this reads today is `core`; a `search/` read would need its own resource here. A refusal
398 // still pauses the whole host, the key PullRequestService records its own backoff under.
399 const resource = input.graphql === true ? "graphql" : "core";
406 const key = { provider: "github" as const, host };400 const key = { provider: "github" as const, host };
407 const run = Effect.gen(function* () {401 const run = Effect.gen(function* () {
408 const lease = yield* limits402 const lease = yield* quota
409 .check(key, input.allowReserve ? { allowPaused: true } : undefined)403 .admit(host, resource, { allowReserve: input.allowReserve })
404 .pipe(
405 Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)),
406 )
410 .pipe(407 .pipe(
411 Effect.tapError((paused) =>408 Effect.tapError((paused) =>
412 Effect.annotateCurrentSpan({409 Effect.annotateCurrentSpan({
...@@ -452,6 +449,7 @@ export const make = Effect.gen(function* () {...@@ -452,6 +449,7 @@ export const make = Effect.gen(function* () {
452 );449 );
453 const headers = response.headers;450 const headers = response.headers;
454 const status = response.status;451 const status = response.status;
452 yield* quota.observe(host, headers);
455 yield* Effect.annotateCurrentSpan({453 yield* Effect.annotateCurrentSpan({
456 "http.response.status_code": status,454 "http.response.status_code": status,
457 ...rateLimitAttributes(headers),455 ...rateLimitAttributes(headers),
...@@ -556,17 +554,15 @@ export const make = Effect.gen(function* () {...@@ -556,17 +554,15 @@ export const make = Effect.gen(function* () {
556 : input.query,554 : input.query,
557 });555 });
558 return yield* Effect.gen(function* () {556 return yield* Effect.gen(function* () {
559 const query = yield* budget.query(
560 host,
561 input.query,
562 allowReserve ? { allowReserve: true } : undefined,
563 );
564 const response = yield* send({557 const response = yield* send({
565 host,558 host,
566 operation: input.operation,559 operation: input.operation,
567 request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe(560 request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe(
568 HttpClientRequest.acceptJson,561 HttpClientRequest.acceptJson,
569 HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }),562 HttpClientRequest.bodyJsonUnsafe({
563 query: input.query,
564 variables: input.variables ?? {},
565 }),
570 ),566 ),
571 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,567 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
572 allowReserve,568 allowReserve,
...@@ -581,14 +577,6 @@ export const make = Effect.gen(function* () {...@@ -581,14 +577,6 @@ export const make = Effect.gen(function* () {
581 status: response.status,577 status: response.status,
582 });578 });
583 }579 }
584 yield* budget.observe(host, response.body);
585 const cost = Option.getOrUndefined(decodeGraphQlCost(response.body));
586 if (cost !== undefined) {
587 yield* Effect.annotateCurrentSpan({
588 "github.graphql.cost": cost.data.rateLimit.cost,
589 "github.graphql.remaining": cost.data.rateLimit.remaining,
590 });
591 }
592 return response.body;580 return response.body;
593 }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope));581 }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope));
594 },582 },
...@@ -600,11 +588,11 @@ export const make = Effect.gen(function* () {...@@ -600,11 +588,11 @@ export const make = Effect.gen(function* () {
600export const layer = Layer.effect(GitHubApi, make);588export const layer = Layer.effect(GitHubApi, make);
601589
602/**590/**
603 * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An591 * The transport with the credential, quota reserve and rate-limit pause it reads through. An
604 * application builds this once so every GitHub reader shares one budget and one pause per host.592 * application builds this once so every GitHub reader shares one view of each quota per host.
605 */593 */
606export const layerWithDependencies = layer.pipe(594export const layerWithDependencies = layer.pipe(
607 Layer.provideMerge(GitHubCredentials.layer),595 Layer.provideMerge(GitHubCredentials.layer),
608 Layer.provideMerge(GitHubGraphQlBudget.layer),596 Layer.provideMerge(GitHubQuota.layer),
609 Layer.provideMerge(SourceControlRateLimit.layer),597 Layer.provideMerge(SourceControlRateLimit.layer),
610);598);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+2
...@@ -459,6 +459,8 @@ describe("GitHubSourceControlProvider writes", () => {...@@ -459,6 +459,8 @@ describe("GitHubSourceControlProvider writes", () => {
459 });459 });
460 assert.strictEqual(requests[0]!.method, "POST");460 assert.strictEqual(requests[0]!.method, "POST");
461 assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls");461 assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls");
462 // A user's own write, so the background's reserve is not held against it.
463 assert.strictEqual(requests[0]!.allowReserve, true);
462 assert.deepStrictEqual(requests[0]!.body, {464 assert.deepStrictEqual(requests[0]!.body, {
463 base: "main",465 base: "main",
464 head: "me:feature",466 head: "me:feature",
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+9-1
...@@ -679,7 +679,12 @@ export const make = Effect.gen(function* () {...@@ -679,7 +679,12 @@ export const make = Effect.gen(function* () {
679 const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* (679 const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* (
680 host: string,680 host: string,
681 ) {681 ) {
682 const response = yield* rest({ host, operation: "getViewer", path: "user" });682 const response = yield* rest({
683 host,
684 operation: "getViewer",
685 path: "user",
686 allowReserve: true,
687 });
683 const decoded = decodeViewerLogin(response.body);688 const decoded = decodeViewerLogin(response.body);
684 if (Result.isFailure(decoded)) {689 if (Result.isFailure(decoded)) {
685 return yield* failure("GitHub request failed.", decoded.failure);690 return yield* failure("GitHub request failed.", decoded.failure);
...@@ -944,6 +949,8 @@ export const make = Effect.gen(function* () {...@@ -944,6 +949,8 @@ export const make = Effect.gen(function* () {
944 host: locator.host,949 host: locator.host,
945 operation: "createPullRequest",950 operation: "createPullRequest",
946 method: "POST",951 method: "POST",
952 // A user's own write may spend the reserve the background leaves for it.
953 allowReserve: true,
947 path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`,954 path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`,
948 // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`.955 // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`.
949 // gh allows maintainer edits unless told otherwise; the API's default is not documented.956 // gh allows maintainer edits unless told otherwise; the API's default is not documented.
...@@ -990,6 +997,7 @@ export const make = Effect.gen(function* () {...@@ -990,6 +997,7 @@ export const make = Effect.gen(function* () {
990 host,997 host,
991 operation: "createRepository",998 operation: "createRepository",
992 method: "POST",999 method: "POST",
1000 allowReserve: true,
993 path:1001 path:
994 isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`,1002 isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`,
995 body: { name, private: input.visibility === "private" },1003 body: { name, private: input.visibility === "private" },
apps/server/src/sourceControl/githubGraphQlBudget.test.ts deleted-241
...@@ -1,241 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
6import { CredentialScope } from "./SourceControlRateLimit.ts";
7
8const RESET_AT = "2026-08-13T14:00:00.000Z";
9const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z";
10const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z");
11const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z");
12
13function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT, cost = 14): string {
14 return JSON.stringify({
15 data: {
16 viewer: { login: "bilal" },
17 rateLimit: { cost, limit, remaining, resetAt },
18 },
19 });
20}
21
22describe("GitHub GraphQL budget", () => {
23 it.effect("adds rate metadata to a read query", () =>
24 Effect.gen(function* () {
25 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
26
27 const query = yield* budget.query(
28 "github.com",
29 'query { repository(owner: "acme", name: "web") { name } }',
30 );
31
32 expect(query).toContain("rateLimit { cost limit remaining resetAt }");
33 expect(query).toContain('repository(owner: "acme", name: "web") { name }');
34 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
35 );
36
37 it.effect("protects the last ten percent with the shared provider cooldown error", () =>
38 Effect.gen(function* () {
39 yield* TestClock.setTime(BEFORE_RESET);
40 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
41 yield* budget.observe("github.com", rateLimit(500));
42
43 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
44 expect(error).toMatchObject({
45 _tag: "SourceControlRateLimitPausedError",
46 provider: "github",
47 host: "github.com",
48 retryAt: Date.parse(RESET_AT),
49 });
50 expect(error.message).toBe(
51 "github requests to github.com are paused until the rate limit resets.",
52 );
53
54 yield* TestClock.setTime(AFTER_RESET);
55 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
56 "rateLimit",
57 );
58 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
59 );
60
61 it.effect("keeps hosts isolated", () =>
62 Effect.gen(function* () {
63 yield* TestClock.setTime(BEFORE_RESET);
64 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
65 yield* budget.observe("github.com", rateLimit(0));
66
67 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
68 expect(error._tag).toBe("SourceControlRateLimitPausedError");
69 expect(yield* budget.query("github.example.com", "query { viewer { login } }")).toContain(
70 "rateLimit",
71 );
72 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
73 );
74
75 it.effect("isolates query reservations and observations by credential", () =>
76 Effect.gen(function* () {
77 yield* TestClock.setTime(BEFORE_RESET);
78 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
79 const query = budget.query("github.com", "query { viewer { login } }");
80 yield* budget
81 .observe("github.com", rateLimit(0))
82 .pipe(Effect.provideService(CredentialScope, "first"));
83 yield* budget
84 .observe("github.com", rateLimit(5000))
85 .pipe(Effect.provideService(CredentialScope, "second"));
86 yield* query.pipe(Effect.provideService(CredentialScope, "second"));
87 const error = yield* query.pipe(Effect.provideService(CredentialScope, "first"), Effect.flip);
88 expect(error.retryAt).toBe(Date.parse(RESET_AT));
89 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
90 );
91
92 it.effect("keeps the lower remaining value from out-of-order responses", () =>
93 Effect.gen(function* () {
94 yield* TestClock.setTime(BEFORE_RESET);
95 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
96 yield* budget.observe("github.com", rateLimit(400));
97 yield* budget.observe("github.com", rateLimit(600));
98
99 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
100 expect(error).toMatchObject({
101 _tag: "SourceControlRateLimitPausedError",
102 retryAt: Date.parse(RESET_AT),
103 });
104 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
105 );
106
107 it.effect("learns a cheaper observed cost without restoring reserved quota", () =>
108 Effect.gen(function* () {
109 yield* TestClock.setTime(BEFORE_RESET);
110 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
111 const query = "query { viewer { login } }";
112 yield* budget.observe("github.com", rateLimit(512, 5_000, RESET_AT, 8));
113 yield* budget.query("github.com", query);
114 // The admission reserved eight points, but the completed read only cost one.
115 yield* budget.observe("github.com", rateLimit(511, 5_000, RESET_AT, 1));
116
117 for (let count = 0; count < 4; count += 1) {
118 expect(yield* budget.query("github.com", query)).toContain("rateLimit");
119 }
120 const error = yield* Effect.flip(budget.query("github.com", query));
121 expect(error).toMatchObject({
122 _tag: "SourceControlRateLimitPausedError",
123 retryAt: Date.parse(RESET_AT),
124 });
125 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
126 );
127
128 it.effect("ignores a response from an older reset window", () =>
129 Effect.gen(function* () {
130 yield* TestClock.setTime(BEFORE_RESET);
131 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
132 yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT));
133 yield* budget.observe("github.com", rateLimit(1_000, 5_000, RESET_AT));
134
135 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
136 expect(error).toMatchObject({
137 _tag: "SourceControlRateLimitPausedError",
138 retryAt: Date.parse(NEXT_RESET_AT),
139 });
140 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
141 );
142
143 it.effect("accepts a response from a later reset window", () =>
144 Effect.gen(function* () {
145 yield* TestClock.setTime(BEFORE_RESET);
146 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
147 yield* budget.observe("github.com", rateLimit(1_000));
148 yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT));
149
150 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
151 expect(error).toMatchObject({
152 _tag: "SourceControlRateLimitPausedError",
153 retryAt: Date.parse(NEXT_RESET_AT),
154 });
155 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
156 );
157
158 it.effect("allows reads above the reserve", () =>
159 Effect.gen(function* () {
160 yield* TestClock.setTime(BEFORE_RESET);
161 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
162 yield* budget.observe("github.com", rateLimit(515));
163
164 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
165 "rateLimit",
166 );
167 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
168 );
169
170 it.effect("allows an interactive read to use the reserve", () =>
171 Effect.gen(function* () {
172 yield* TestClock.setTime(BEFORE_RESET);
173 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
174 yield* budget.observe("github.com", rateLimit(500));
175
176 expect(
177 yield* budget.query("github.com", "query { viewer { login } }", {
178 allowReserve: true,
179 }),
180 ).toContain("rateLimit");
181 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
182 );
183
184 it.effect("reserves an admitted query cost before its response", () =>
185 Effect.gen(function* () {
186 yield* TestClock.setTime(BEFORE_RESET);
187 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
188 yield* budget.observe("github.com", rateLimit(514));
189
190 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
191 "rateLimit",
192 );
193 const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }"));
194
195 expect(error).toMatchObject({
196 _tag: "SourceControlRateLimitPausedError",
197 retryAt: Date.parse(RESET_AT),
198 });
199 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
200 );
201
202 it.effect("stops interactive reads when the reserve is exhausted", () =>
203 Effect.gen(function* () {
204 yield* TestClock.setTime(BEFORE_RESET);
205 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
206 yield* budget.observe("github.com", rateLimit(1, 5000, RESET_AT, 1));
207 yield* budget.query("github.com", "query { viewer { login } }", { allowReserve: true });
208 const error = yield* budget
209 .query("github.com", "query { viewer { login } }", { allowReserve: true })
210 .pipe(Effect.flip);
211 expect(error.retryAt).toBe(Date.parse(RESET_AT));
212 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
213 );
214
215 it.effect("ignores malformed or partial rate metadata", () =>
216 Effect.gen(function* () {
217 yield* TestClock.setTime(BEFORE_RESET);
218 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
219 yield* budget.observe("github.com", "{");
220 yield* budget.observe(
221 "github.com",
222 '{"data":{"rateLimit":{"limit":0,"remaining":-1,"resetAt":"never"}}}',
223 );
224
225 expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain(
226 "rateLimit",
227 );
228 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
229 );
230
231 it.effect("does not add a read field to a mutation", () =>
232 Effect.gen(function* () {
233 yield* TestClock.setTime(BEFORE_RESET);
234 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
235 const mutation = "mutation { addComment(input: {}) { clientMutationId } }";
236 yield* budget.observe("github.com", rateLimit(0));
237
238 expect(yield* budget.query("github.com", mutation)).toBe(mutation);
239 }).pipe(Effect.provide(GitHubGraphQlBudget.layer)),
240 );
241});
apps/server/src/sourceControl/githubGraphQlBudget.ts deleted-150
...@@ -1,150 +0,0 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6import * as Predicate from "effect/Predicate";
7
8import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
9
10const GRAPHQL_RESERVE_RATIO = 0.1;
11const RATE_LIMIT_SELECTION = "rateLimit { cost limit remaining resetAt }";
12
13interface GraphQlBudgetSnapshot {
14 readonly cost: number;
15 readonly limit: number;
16 readonly remaining: number;
17 readonly resetAtMs: number;
18}
19
20export class GitHubGraphQlBudget extends Context.Service<
21 GitHubGraphQlBudget,
22 {
23 readonly query: (
24 host: string,
25 document: string,
26 options?: { readonly allowReserve: boolean },
27 ) => Effect.Effect<string, SourceControlRateLimit.SourceControlRateLimitPausedError>;
28 readonly observe: (host: string, raw: string) => Effect.Effect<void>;
29 }
30>()("t3/sourceControl/githubGraphQlBudget") {}
31
32function hostKey(host: string): string {
33 return host.trim().toLowerCase();
34}
35
36function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null {
37 try {
38 const parsed: unknown = JSON.parse(raw);
39 if (
40 !Predicate.isObject(parsed) ||
41 !Predicate.isObject(parsed.data) ||
42 !Predicate.isObject(parsed.data.rateLimit)
43 ) {
44 return null;
45 }
46 const { cost, limit, remaining, resetAt } = parsed.data.rateLimit;
47 if (
48 typeof cost !== "number" ||
49 !Number.isFinite(cost) ||
50 cost < 0 ||
51 typeof limit !== "number" ||
52 !Number.isFinite(limit) ||
53 limit <= 0 ||
54 typeof remaining !== "number" ||
55 !Number.isFinite(remaining) ||
56 remaining < 0 ||
57 typeof resetAt !== "string"
58 ) {
59 return null;
60 }
61 const resetAtMs = Date.parse(resetAt);
62 return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null;
63 } catch {
64 return null;
65 }
66}
67
68function isReadOperation(document: string): boolean {
69 const operation = document.trimStart();
70 return operation.startsWith("query") || operation.startsWith("{");
71}
72
73function withRateLimit(document: string): string {
74 if (!isReadOperation(document)) return document;
75 const end = document.lastIndexOf("}");
76 if (end === -1 || document.includes(RATE_LIMIT_SELECTION)) return document;
77 return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`;
78}
79
80/** @public Service construction is part of the canonical Effect module API. */
81export const make = Effect.gen(function* () {
82 const snapshots = yield* Ref.make<ReadonlyMap<string, GraphQlBudgetSnapshot>>(new Map());
83
84 const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")(
85 function* (host, document, options) {
86 if (!isReadOperation(document)) return document;
87 const now = yield* Clock.currentTimeMillis;
88 const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`;
89 const retryAt = yield* Ref.modify(snapshots, (current) => {
90 const snapshot = current.get(key);
91 if (snapshot === undefined) return [null, current] as const;
92 if (snapshot.resetAtMs <= now) {
93 const next = new Map(current);
94 next.delete(key);
95 return [null, next] as const;
96 }
97 const remaining = snapshot.remaining - Math.max(1, snapshot.cost);
98 if (
99 remaining < 0 ||
100 (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO)
101 ) {
102 return [snapshot.resetAtMs, current] as const;
103 }
104 const next = new Map(current);
105 next.set(key, { ...snapshot, remaining });
106 return [null, next] as const;
107 });
108 if (retryAt !== null) {
109 return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({
110 provider: "github",
111 host: hostKey(host),
112 retryAt,
113 });
114 }
115 return withRateLimit(document);
116 },
117 );
118
119 const observe: GitHubGraphQlBudget["Service"]["observe"] = Effect.fn(
120 "GitHubGraphQlBudget.observe",
121 )(function* (host, raw) {
122 const snapshot = snapshotFrom(raw);
123 if (snapshot === null) return;
124 const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`;
125 yield* Ref.update(snapshots, (current) => {
126 const previous = current.get(key);
127 // Concurrent reads can finish out of order. Quota only falls within one reset window, and
128 // an answer from an older window must not replace the current one.
129 if (previous !== undefined && snapshot.resetAtMs < previous.resetAtMs) {
130 return current;
131 }
132 const next = new Map(current);
133 // Keep the conservative balance, but learn the observed cost even when our reservation
134 // was larger. Otherwise one expensive read makes every later cheap read spend its cost.
135 next.set(
136 key,
137 previous !== undefined &&
138 snapshot.resetAtMs === previous.resetAtMs &&
139 snapshot.remaining >= previous.remaining
140 ? { ...previous, cost: snapshot.cost }
141 : snapshot,
142 );
143 return next;
144 });
145 });
146
147 return GitHubGraphQlBudget.of({ query, observe });
148});
149
150export const layer = Layer.effect(GitHubGraphQlBudget, make);
apps/server/src/sourceControl/githubQuota.test.ts created+86
...@@ -0,0 +1,86 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as GitHubQuota from "./githubQuota.ts";
6import { CredentialScope } from "./SourceControlRateLimit.ts";
7
8const RESET = Date.parse("2099-08-13T14:00:00Z");
9const headers = (remaining: number, resource = "graphql", reset = RESET) => ({
10 "x-ratelimit-resource": resource,
11 "x-ratelimit-limit": "5000",
12 "x-ratelimit-remaining": String(remaining),
13 "x-ratelimit-reset": String(reset / 1000),
14});
15
16describe("GitHubQuota", () => {
17 it.effect("refuses a background request below the reserve, per quota and account", () =>
18 Effect.gen(function* () {
19 const quota = yield* GitHubQuota.GitHubQuota;
20 yield* quota.observe("github.com", headers(499));
21 const refused = yield* Effect.flip(quota.admit("github.com", "graphql"));
22 assert.strictEqual(refused.retryAt, RESET);
23 // Another quota, another host and another account each have their own balance.
24 yield* quota.admit("github.com", "core");
25 yield* quota.admit("ghe.example", "graphql");
26 yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b"));
27 yield* quota.admit("github.com", "graphql", { allowReserve: true });
28 }).pipe(Effect.provide(GitHubQuota.layer)),
29 );
30
31 it.effect("takes the latest balance in a window, and ignores an older window", () =>
32 Effect.gen(function* () {
33 const quota = yield* GitHubQuota.GitHubQuota;
34 yield* quota.observe("github.com", headers(400));
35 yield* Effect.flip(quota.admit("github.com", "graphql"));
36 // GitHub can report more left later in the same window, from another region.
37 yield* quota.observe("github.com", headers(900));
38 yield* quota.admit("github.com", "graphql");
39 yield* quota.observe("github.com", headers(400));
40 yield* Effect.flip(quota.admit("github.com", "graphql"));
41 // An answer from an older window says nothing about this one.
42 yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000));
43 yield* Effect.flip(quota.admit("github.com", "graphql"));
44 // A later window replaces it.
45 yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000));
46 yield* quota.admit("github.com", "graphql");
47 }).pipe(Effect.provide(GitHubQuota.layer)),
48 );
49
50 it.effect("lets interactive requests spend the reserve but not an empty quota", () =>
51 Effect.gen(function* () {
52 const quota = yield* GitHubQuota.GitHubQuota;
53 yield* quota.observe("github.com", headers(1));
54 yield* quota.admit("github.com", "graphql", { allowReserve: true });
55 yield* quota.observe("github.com", headers(0));
56 const refused = yield* Effect.flip(
57 quota.admit("github.com", "graphql", { allowReserve: true }),
58 );
59 assert.strictEqual(refused.retryAt, RESET);
60 }).pipe(Effect.provide(GitHubQuota.layer)),
61 );
62
63 it.effect("spends nothing locally, so a free 304 cannot run the balance down", () =>
64 Effect.gen(function* () {
65 const quota = yield* GitHubQuota.GitHubQuota;
66 yield* quota.observe("github.com", headers(501, "core"));
67 // Conditional reads answer 304 with the balance unchanged, however many are sent.
68 for (let index = 0; index < 5; index++) {
69 yield* quota.admit("github.com", "core");
70 yield* quota.observe("github.com", headers(501, "core"));
71 }
72 yield* quota.admit("github.com", "core");
73 }).pipe(Effect.provide(GitHubQuota.layer)),
74 );
75
76 it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () =>
77 Effect.gen(function* () {
78 const quota = yield* GitHubQuota.GitHubQuota;
79 yield* quota.observe("github.com", headers(0));
80 yield* TestClock.setTime(RESET);
81 yield* quota.admit("github.com", "graphql");
82 yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" });
83 yield* quota.admit("other.example", "graphql");
84 }).pipe(Effect.provide(GitHubQuota.layer)),
85 );
86});
apps/server/src/sourceControl/githubQuota.ts created+122
...@@ -0,0 +1,122 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
8
9/** The share of each quota a background read leaves for a user's next click. */
10const RESERVE_RATIO = 0.1;
11
12/**
13 * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …).
14 * Each is spent and reset on its own, so each is tracked on its own.
15 */
16type GitHubQuotaResource = string;
17
18interface QuotaSnapshot {
19 readonly limit: number;
20 readonly remaining: number;
21 readonly resetAtMs: number;
22}
23
24/**
25 * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer
26 * carries. Null when the answer named no quota, which an Enterprise host with limits off does.
27 */
28function quotaFromHeaders(
29 headers: Readonly<Record<string, string | undefined>>,
30): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null {
31 const resource = headers["x-ratelimit-resource"]?.trim();
32 const limit = Number(headers["x-ratelimit-limit"]);
33 const remaining = Number(headers["x-ratelimit-remaining"]);
34 const reset = Number(headers["x-ratelimit-reset"]);
35 if (
36 !resource ||
37 !Number.isFinite(limit) ||
38 limit <= 0 ||
39 !Number.isFinite(remaining) ||
40 remaining < 0 ||
41 !Number.isFinite(reset)
42 ) {
43 return null;
44 }
45 return { resource, limit, remaining, resetAtMs: reset * 1_000 };
46}
47
48/**
49 * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that
50 * would dip below it is refused until the quota resets, so a user's next click still has budget.
51 *
52 * The balance is GitHub's own count from the latest answer's headers, so it already includes
53 * what anything else spent with the same token, such as an agent's `gh` commands. Requests in
54 * flight together all see that one balance; the reserve absorbs what they spend before their
55 * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub
56 * spends nothing, and a local count would drift below GitHub's for the rest of the window.
57 *
58 * Interactive requests may spend the reserve, but not a quota GitHub already reported empty.
59 */
60export class GitHubQuota extends Context.Service<
61 GitHubQuota,
62 {
63 readonly admit: (
64 host: string,
65 resource: GitHubQuotaResource,
66 options?: { readonly allowReserve: boolean },
67 ) => Effect.Effect<void, SourceControlRateLimit.SourceControlRateLimitPausedError>;
68 readonly observe: (
69 host: string,
70 headers: Readonly<Record<string, string | undefined>>,
71 ) => Effect.Effect<void>;
72 }
73>()("t3/sourceControl/githubQuota") {}
74
75const make = Effect.gen(function* () {
76 const snapshots = yield* Ref.make<ReadonlyMap<string, QuotaSnapshot>>(new Map());
77 const keyOf = (host: string, resource: string, scope: string) =>
78 `${host.trim().toLowerCase()}\0${resource}\0${scope}`;
79
80 const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")(
81 function* (host, resource, options) {
82 const now = yield* Clock.currentTimeMillis;
83 const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope);
84 const snapshot = (yield* Ref.get(snapshots)).get(key);
85 if (snapshot === undefined || snapshot.resetAtMs <= now) return;
86 const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO;
87 if (snapshot.remaining >= floor) return;
88 return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({
89 provider: "github",
90 host: host.trim().toLowerCase(),
91 retryAt: snapshot.resetAtMs,
92 });
93 },
94 );
95
96 const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")(
97 function* (host, headers) {
98 const quota = quotaFromHeaders(headers);
99 if (quota === null) return;
100 const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope);
101 yield* Ref.update(snapshots, (current) => {
102 const previous = current.get(key);
103 // The latest answer wins. GitHub serves requests from several regions, so a later answer
104 // can report more left in the same window, and keeping the lower one would hold
105 // background work at the reserve until the reset. An answer from an older window says
106 // nothing of this one.
107 if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current;
108 const next = new Map(current);
109 next.set(key, {
110 limit: quota.limit,
111 remaining: quota.remaining,
112 resetAtMs: quota.resetAtMs,
113 });
114 return next;
115 });
116 },
117 );
118
119 return GitHubQuota.of({ admit, observe });
120});
121
122export const layer = Layer.effect(GitHubQuota, make);