| author | |
| committer | |
| log | d720210996a514368ba99f4860063110033d93fa |
| tree | fc99acf46fd9eb1a53983ca9952d7cbee7a73951 |
| parent | 42d92ec59775c187af69071c81b2b91180810bdd |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 |
| 2 | 2 | // Numbers form a session with shared repository-permission caches. Browser and |
| 3 | 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. | |
| 5 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | 7 | import * as Console from "effect/Console"; |
| ... | ... | @@ -15,7 +15,7 @@ import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequest |
| 15 | 15 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; |
| 16 | 16 | import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; |
| 17 | 17 | import * as ServerSettings from "../src/serverSettings.ts"; |
| 18 | import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; | |
| 18 | import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; | |
| 19 | 19 | import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; |
| 20 | 20 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 21 | 21 | |
| ... | ... | @@ -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. */ | |
| 37 | function 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 | ||
| 36 | 44 | const measuredApi = Layer.effect( |
| 37 | 45 | GitHubApi.GitHubApi, |
| 38 | 46 | Effect.gen(function* () { |
| ... | ... | @@ -40,7 +48,7 @@ const measuredApi = Layer.effect( |
| 40 | 48 | return GitHubApi.GitHubApi.of({ |
| 41 | 49 | ...api, |
| 42 | 50 | graphql: (input) => |
| 43 | api.graphql(input).pipe( | |
| 51 | api.graphql({ ...input, query: withRateLimit(input.query) }).pipe( | |
| 44 | 52 | Effect.tap((body) => |
| 45 | 53 | Effect.sync(() => |
| 46 | 54 | reads.push({ |
| ... | ... | @@ -66,7 +74,7 @@ const measuredApi = Layer.effect( |
| 66 | 74 | }), |
| 67 | 75 | ).pipe( |
| 68 | 76 | Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), |
| 69 | Layer.provide(GitHubGraphQlBudget.layer), | |
| 77 | Layer.provide(GitHubQuota.layer), | |
| 70 | 78 | Layer.provide(SourceControlRateLimit.layer), |
| 71 | 79 | Layer.provide(FetchHttpClient.layer), |
| 72 | 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 | 15 | import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; |
| 16 | 16 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 17 | 17 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; |
| 18 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | |
| 18 | import * as GitHubQuota from "../sourceControl/githubQuota.ts"; | |
| 19 | 19 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; |
| 20 | 20 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 21 | 21 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| ... | ... | @@ -69,26 +69,29 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => |
| 69 | 69 | ); |
| 70 | 70 | |
| 71 | 71 | /** |
| 72 | * A transport that answers from the mocks above but spends the real GraphQL budget, the way | |
| 73 | * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. | |
| 72 | * A transport that answers from the mocks above but spends the real GraphQL quota from each | |
| 73 | * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. | |
| 74 | 74 | */ |
| 75 | 75 | const mockApi = Layer.effect( |
| 76 | 76 | GitHubApi.GitHubApi, |
| 77 | 77 | Effect.gen(function* () { |
| 78 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 78 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 79 | 79 | return GitHubApi.GitHubApi.of({ |
| 80 | 80 | graphql: (input) => |
| 81 | budget | |
| 82 | .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) | |
| 83 | .pipe( | |
| 84 | Effect.flatMap((query) => | |
| 85 | (input.query.includes("query PullRequestStackMemberships") | |
| 86 | ? mockedStackMemberships | |
| 87 | : mockedExecute)({ kind: "graphql", ...input, query }), | |
| 88 | ), | |
| 89 | Effect.map((answer) => answer.body), | |
| 90 | Effect.tap((body) => budget.observe(input.host, body)), | |
| 81 | GitHubApi.AllowGitHubReserve.pipe( | |
| 82 | Effect.flatMap((interactive) => | |
| 83 | quota.admit(input.host, "graphql", { | |
| 84 | allowReserve: input.allowReserve ?? interactive, | |
| 85 | }), | |
| 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 | 95 | rest: (input) => mockedExecute({ kind: "rest", ...input }), |
| 93 | 96 | credential: (host) => mockedCredential(host), |
| 94 | 97 | }); |
| ... | ... | @@ -98,7 +101,7 @@ const mockApi = Layer.effect( |
| 98 | 101 | const layer = it.layer( |
| 99 | 102 | GitHubPullRequestApi.layer.pipe( |
| 100 | 103 | Layer.provideMerge(mockApi), |
| 101 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 104 | Layer.provideMerge(GitHubQuota.layer), | |
| 102 | 105 | Layer.provide(NodeCrypto.layer), |
| 103 | 106 | Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), |
| 104 | 107 | ), |
| ... | ... | @@ -109,8 +112,19 @@ function output( |
| 109 | 112 | body: string, |
| 110 | 113 | truncated = false, |
| 111 | 114 | invalidUtf8 = false, |
| 115 | headers: Readonly<Record<string, string>> = {}, | |
| 112 | 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`. */ | |
| 121 | function 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 | } |
| 115 | 129 | |
| 116 | 130 | /** |
| ... | ... | @@ -493,7 +507,7 @@ it.effect( |
| 493 | 507 | Effect.provide( |
| 494 | 508 | GitHubApi.layer.pipe( |
| 495 | 509 | Layer.provide(Layer.mergeAll(credentials, http)), |
| 496 | Layer.provide(GitHubGraphQlBudget.layer), | |
| 510 | Layer.provide(GitHubQuota.layer), | |
| 497 | 511 | Layer.provide(SourceControlRateLimit.layer), |
| 498 | 512 | Layer.merge(VcsProcess.layer), |
| 499 | 513 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -538,15 +552,13 @@ it.effect( |
| 538 | 552 | ); |
| 539 | 553 | |
| 540 | 554 | layer("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 | 556 | Effect.gen(function* () { |
| 543 | 557 | const now = yield* Clock.currentTimeMillis; |
| 544 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 558 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 545 | 559 | const resetAt = "2099-08-13T14:00:00Z"; |
| 546 | yield* budget.observe( | |
| 547 | "preview-budget.example", | |
| 548 | encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), | |
| 549 | ); | |
| 560 | yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); | |
| 561 | // GitHub's count, not ours: the answer below reports the reserve has been reached. | |
| 550 | 562 | mockedExecute.mockReturnValue( |
| 551 | 563 | Effect.succeed( |
| 552 | 564 | output( |
| ... | ... | @@ -563,9 +575,11 @@ layer("GitHubPullRequestApi.layer", (it) => { |
| 563 | 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 | 590 | host: "preview-budget.example", |
| 577 | 591 | number: 7, |
| 578 | 592 | }; |
| 579 | const results = yield* Effect.all( | |
| 580 | Array.from({ length: 20 }, (_, index) => | |
| 581 | cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), | |
| 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 | } | |
| 593 | yield* cli.getPullRequestPreview(input); | |
| 594 | const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); | |
| 595 | expect(held._tag).toBe("SourceControlRateLimitPausedError"); | |
| 591 | 596 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 592 | 597 | yield* TestClock.setTime(Date.parse(resetAt)); |
| 593 | 598 | yield* cli.getPullRequestPreview(input); |
| ... | ... | @@ -3220,7 +3225,7 @@ layer("GitHubPullRequestApi.layer", (it) => { |
| 3220 | 3225 | }), |
| 3221 | 3226 | ); |
| 3222 | 3227 | |
| 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 | 3229 | Effect.gen(function* () { |
| 3225 | 3230 | mockedExecute.mockReturnValueOnce( |
| 3226 | 3231 | Effect.succeed( |
| ... | ... | @@ -3228,12 +3233,6 @@ layer("GitHubPullRequestApi.layer", (it) => { |
| 3228 | 3233 | encodeJson({ |
| 3229 | 3234 | data: { |
| 3230 | 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 | 3248 | }); |
| 3250 | 3249 | |
| 3251 | 3250 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); |
| 3252 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 3253 | 3251 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); |
| 3254 | 3252 | }), |
| 3255 | 3253 | ); |
| ... | ... | @@ -3852,22 +3850,7 @@ layer("GitHubPullRequestApi.layer", (it) => { |
| 3852 | 3850 | Effect.gen(function* () { |
| 3853 | 3851 | const response = coreResponse(); |
| 3854 | 3852 | mockedExecute.mockReturnValue( |
| 3855 | Effect.succeed( | |
| 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 | ), | |
| 3853 | Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), | |
| 3871 | 3854 | ); |
| 3872 | 3855 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3873 | 3856 | const input = { |
| ... | ... | @@ -4125,14 +4108,11 @@ layer("GitHubPullRequestApi.layer", (it) => { |
| 4125 | 4108 | viewerPermission: "READ", |
| 4126 | 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 | 4125 | } as const; |
| 4146 | 4126 | |
| 4147 | 4127 | yield* cli.getViewerAccess(input); |
| 4148 | expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); | |
| 4149 | 4128 | |
| 4150 | 4129 | const error = yield* Effect.flip(cli.getViewerAccess(input)); |
| 4151 | 4130 | |
| ... | ... | @@ -4173,14 +4152,11 @@ layer("GitHubPullRequestApi.layer", (it) => { |
| 4173 | 4152 | viewerPermission: "READ", |
| 4174 | 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 | 12 | |
| 13 | 13 | import * as GitHubApi from "./GitHubApi.ts"; |
| 14 | 14 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 15 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 15 | import * as GitHubQuota from "./githubQuota.ts"; | |
| 16 | 16 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; |
| 17 | 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 18 | 18 | import * as ServerSettings from "../serverSettings.ts"; |
| ... | ... | @@ -49,7 +49,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp |
| 49 | 49 | ); |
| 50 | 50 | const layer = GitHubApi.layer.pipe( |
| 51 | 51 | Layer.provide(Layer.mergeAll(credentials, http)), |
| 52 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 52 | Layer.provideMerge(GitHubQuota.layer), | |
| 53 | 53 | Layer.provideMerge(SourceControlRateLimit.layer), |
| 54 | 54 | ); |
| 55 | 55 | return { layer, requests, invalidations: () => invalidations }; |
| ... | ... | @@ -97,15 +97,8 @@ describe("environmentToken", () => { |
| 97 | 97 | }); |
| 98 | 98 | |
| 99 | 99 | describe("GitHubApi", () => { |
| 100 | it.effect("sends GraphQL with the token and records the reported budget", () => { | |
| 101 | const { layer, requests } = harness(() => | |
| 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 | ); | |
| 100 | it.effect("sends GraphQL with the token, and the document as written", () => { | |
| 101 | const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); | |
| 109 | 102 | return Effect.gen(function* () { |
| 110 | 103 | yield* TestClock.setTime(NOW); |
| 111 | 104 | const api = yield* GitHubApi.GitHubApi; |
| ... | ... | @@ -117,11 +110,16 @@ describe("GitHubApi", () => { |
| 117 | 110 | expect(body).toContain('"login":"julius"'); |
| 118 | 111 | expect(requests[0]!.url).toBe("https://api.github.com/graphql"); |
| 119 | 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 | 118 | }).pipe(Effect.provide(layer)); |
| 121 | 119 | }); |
| 122 | 120 | |
| 123 | 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 | 124 | const spans: Array<Tracer.NativeSpan> = []; |
| 127 | 125 | const tracer = Tracer.make({ |
| ... | ... | @@ -134,17 +132,7 @@ describe("GitHubApi", () => { |
| 134 | 132 | const { layer } = harness((request) => |
| 135 | 133 | request.url.endsWith("/graphql") |
| 136 | 134 | ? json( |
| 137 | { | |
| 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 | }, | |
| 135 | { data: { viewer: { login: "julius" } } }, | |
| 148 | 136 | { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, |
| 149 | 137 | ) |
| 150 | 138 | : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), |
| ... | ... | @@ -168,8 +156,10 @@ describe("GitHubApi", () => { |
| 168 | 156 | expect(graphql).toMatchObject({ |
| 169 | 157 | "github.operation": "getPullRequestDetail", |
| 170 | 158 | "github.graphql.query": "query($body: String!) { viewer { login } }", |
| 171 | "github.graphql.cost": 3, | |
| 172 | "github.graphql.remaining": 4990, | |
| 159 | }); | |
| 160 | expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ | |
| 161 | "github.ratelimit.remaining": 4990, | |
| 162 | "github.ratelimit.resource": "graphql", | |
| 173 | 163 | }); |
| 174 | 164 | expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); |
| 175 | 165 | const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); |
| ... | ... | @@ -258,6 +248,42 @@ describe("GitHubApi", () => { |
| 258 | 248 | }).pipe(Effect.provide(layer)); |
| 259 | 249 | }); |
| 260 | 250 | |
| 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 | 287 | it.effect("carries GitHub's own reason for a refused REST request", () => { |
| 262 | 288 | const { layer } = harness(() => |
| 263 | 289 | json( |
apps/server/src/sourceControl/GitHubApi.ts+19-31| ... | ... | @@ -12,7 +12,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h |
| 12 | 12 | |
| 13 | 13 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| 14 | 14 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 15 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 15 | import * as GitHubQuota from "./githubQuota.ts"; | |
| 16 | 16 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; |
| 17 | 17 | |
| 18 | 18 | const DEFAULT_TIMEOUT = Duration.seconds(30); |
| ... | ... | @@ -218,16 +218,6 @@ function rateLimitAttributes( |
| 218 | 218 | return attributes; |
| 219 | 219 | } |
| 220 | 220 | |
| 221 | const 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 | 221 | /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ |
| 232 | 222 | function retryAtFrom( |
| 233 | 223 | headers: Readonly<Record<string, string | undefined>>, |
| ... | ... | @@ -354,7 +344,7 @@ function classify(input: { |
| 354 | 344 | export const make = Effect.gen(function* () { |
| 355 | 345 | const httpClient = yield* HttpClient.HttpClient; |
| 356 | 346 | const credentials = yield* GitHubCredentials.GitHubCredentials; |
| 357 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | |
| 347 | const quota = yield* GitHubQuota.GitHubQuota; | |
| 358 | 348 | const limits = yield* SourceControlRateLimit.SourceControlRateLimit; |
| 359 | 349 | const gate = yield* Semaphore.make(CONCURRENCY); |
| 360 | 350 | |
| ... | ... | @@ -403,10 +393,17 @@ export const make = Effect.gen(function* () { |
| 403 | 393 | }); |
| 404 | 394 | const { token, fingerprint } = yield* credential(host); |
| 405 | 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 | 400 | const key = { provider: "github" as const, host }; |
| 407 | 401 | const run = Effect.gen(function* () { |
| 408 | const lease = yield* limits | |
| 409 | .check(key, input.allowReserve ? { allowPaused: true } : undefined) | |
| 402 | const lease = yield* quota | |
| 403 | .admit(host, resource, { allowReserve: input.allowReserve }) | |
| 404 | .pipe( | |
| 405 | Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), | |
| 406 | ) | |
| 410 | 407 | .pipe( |
| 411 | 408 | Effect.tapError((paused) => |
| 412 | 409 | Effect.annotateCurrentSpan({ |
| ... | ... | @@ -452,6 +449,7 @@ export const make = Effect.gen(function* () { |
| 452 | 449 | ); |
| 453 | 450 | const headers = response.headers; |
| 454 | 451 | const status = response.status; |
| 452 | yield* quota.observe(host, headers); | |
| 455 | 453 | yield* Effect.annotateCurrentSpan({ |
| 456 | 454 | "http.response.status_code": status, |
| 457 | 455 | ...rateLimitAttributes(headers), |
| ... | ... | @@ -556,17 +554,15 @@ export const make = Effect.gen(function* () { |
| 556 | 554 | : input.query, |
| 557 | 555 | }); |
| 558 | 556 | return yield* Effect.gen(function* () { |
| 559 | const query = yield* budget.query( | |
| 560 | host, | |
| 561 | input.query, | |
| 562 | allowReserve ? { allowReserve: true } : undefined, | |
| 563 | ); | |
| 564 | 557 | const response = yield* send({ |
| 565 | 558 | host, |
| 566 | 559 | operation: input.operation, |
| 567 | 560 | request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( |
| 568 | 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 | 567 | maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, |
| 572 | 568 | allowReserve, |
| ... | ... | @@ -581,14 +577,6 @@ export const make = Effect.gen(function* () { |
| 581 | 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 | 580 | return response.body; |
| 593 | 581 | }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); |
| 594 | 582 | }, |
| ... | ... | @@ -600,11 +588,11 @@ export const make = Effect.gen(function* () { |
| 600 | 588 | export const layer = Layer.effect(GitHubApi, make); |
| 601 | 589 | |
| 602 | 590 | /** |
| 603 | * The transport with the credential, GraphQL budget 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. | |
| 591 | * The transport with the credential, quota reserve and rate-limit pause it reads through. An | |
| 592 | * application builds this once so every GitHub reader shares one view of each quota per host. | |
| 605 | 593 | */ |
| 606 | 594 | export const layerWithDependencies = layer.pipe( |
| 607 | 595 | Layer.provideMerge(GitHubCredentials.layer), |
| 608 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 596 | Layer.provideMerge(GitHubQuota.layer), | |
| 609 | 597 | Layer.provideMerge(SourceControlRateLimit.layer), |
| 610 | 598 | ); |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+2| ... | ... | @@ -459,6 +459,8 @@ describe("GitHubSourceControlProvider writes", () => { |
| 459 | 459 | }); |
| 460 | 460 | assert.strictEqual(requests[0]!.method, "POST"); |
| 461 | 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 | 464 | assert.deepStrictEqual(requests[0]!.body, { |
| 463 | 465 | base: "main", |
| 464 | 466 | head: "me:feature", |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+9-1| ... | ... | @@ -679,7 +679,12 @@ export const make = Effect.gen(function* () { |
| 679 | 679 | const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( |
| 680 | 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 | 688 | const decoded = decodeViewerLogin(response.body); |
| 684 | 689 | if (Result.isFailure(decoded)) { |
| 685 | 690 | return yield* failure("GitHub request failed.", decoded.failure); |
| ... | ... | @@ -944,6 +949,8 @@ export const make = Effect.gen(function* () { |
| 944 | 949 | host: locator.host, |
| 945 | 950 | operation: "createPullRequest", |
| 946 | 951 | method: "POST", |
| 952 | // A user's own write may spend the reserve the background leaves for it. | |
| 953 | allowReserve: true, | |
| 947 | 954 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, |
| 948 | 955 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. |
| 949 | 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 | 997 | host, |
| 991 | 998 | operation: "createRepository", |
| 992 | 999 | method: "POST", |
| 1000 | allowReserve: true, | |
| 993 | 1001 | path: |
| 994 | 1002 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, |
| 995 | 1003 | body: { name, private: input.visibility === "private" }, |
apps/server/src/sourceControl/githubGraphQlBudget.test.ts deleted-241| ... | ... | @@ -1,241 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | ||
| 5 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 6 | import { CredentialScope } from "./SourceControlRateLimit.ts"; | |
| 7 | ||
| 8 | const RESET_AT = "2026-08-13T14:00:00.000Z"; | |
| 9 | const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z"; | |
| 10 | const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z"); | |
| 11 | const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z"); | |
| 12 | ||
| 13 | function 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 | ||
| 22 | describe("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 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Ref from "effect/Ref"; | |
| 6 | import * as Predicate from "effect/Predicate"; | |
| 7 | ||
| 8 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 9 | ||
| 10 | const GRAPHQL_RESERVE_RATIO = 0.1; | |
| 11 | const RATE_LIMIT_SELECTION = "rateLimit { cost limit remaining resetAt }"; | |
| 12 | ||
| 13 | interface GraphQlBudgetSnapshot { | |
| 14 | readonly cost: number; | |
| 15 | readonly limit: number; | |
| 16 | readonly remaining: number; | |
| 17 | readonly resetAtMs: number; | |
| 18 | } | |
| 19 | ||
| 20 | export 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 | ||
| 32 | function hostKey(host: string): string { | |
| 33 | return host.trim().toLowerCase(); | |
| 34 | } | |
| 35 | ||
| 36 | function 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 | ||
| 68 | function isReadOperation(document: string): boolean { | |
| 69 | const operation = document.trimStart(); | |
| 70 | return operation.startsWith("query") || operation.startsWith("{"); | |
| 71 | } | |
| 72 | ||
| 73 | function 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. */ | |
| 81 | export 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 | ||
| 150 | export const layer = Layer.effect(GitHubGraphQlBudget, make); |
apps/server/src/sourceControl/githubQuota.test.ts created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | ||
| 5 | import * as GitHubQuota from "./githubQuota.ts"; | |
| 6 | import { CredentialScope } from "./SourceControlRateLimit.ts"; | |
| 7 | ||
| 8 | const RESET = Date.parse("2099-08-13T14:00:00Z"); | |
| 9 | const 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 | ||
| 16 | describe("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 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Ref from "effect/Ref"; | |
| 6 | ||
| 7 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 8 | ||
| 9 | /** The share of each quota a background read leaves for a user's next click. */ | |
| 10 | const 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 | */ | |
| 16 | type GitHubQuotaResource = string; | |
| 17 | ||
| 18 | interface 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 | */ | |
| 28 | function 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 | */ | |
| 60 | export 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 | ||
| 75 | const 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 | ||
| 122 | export const layer = Layer.effect(GitHubQuota, make); |