diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index f1fc491b1a2bb655669c40ab8856d057af79f075..e8f0727ac93cf4fb2d2e8ffe26adb39b6e34b770 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -1,7 +1,7 @@ // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 // Numbers form a session with shared repository-permission caches. Browser and // service caches are excluded. Uses real GitHub reads, without a server or database. -// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer. +// Each GraphQL read asks for `rateLimit` here, so its cost is read off its own answer. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; @@ -15,7 +15,7 @@ import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequest import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; import * as ServerSettings from "../src/serverSettings.ts"; -import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; @@ -33,6 +33,14 @@ const decodeCost = Schema.decodeUnknownOption( }), ), ); +/** The query with GitHub's own count of what it cost, for a read rather than a mutation. */ +function withRateLimit(query: string): string { + const end = query.lastIndexOf("}"); + return query.trimStart().startsWith("mutation") || end === -1 + ? query + : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`; +} + const measuredApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { @@ -40,7 +48,7 @@ const measuredApi = Layer.effect( return GitHubApi.GitHubApi.of({ ...api, graphql: (input) => - api.graphql(input).pipe( + api.graphql({ ...input, query: withRateLimit(input.query) }).pipe( Effect.tap((body) => Effect.sync(() => reads.push({ @@ -66,7 +74,7 @@ const measuredApi = Layer.effect( }), ).pipe( Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.provide(FetchHttpClient.layer), Layer.provide(VcsProcess.layer), diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 20fc1dd88526677c4d8b13fdf4eb5927fac625a2..294d053d6f056d88426a7bc1b572af95c0d6f662 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -15,7 +15,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; @@ -69,26 +69,29 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => ); /** - * A transport that answers from the mocks above but spends the real GraphQL budget, the way - * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. + * A transport that answers from the mocks above but spends the real GraphQL quota from each + * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. */ const mockApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; return GitHubApi.GitHubApi.of({ graphql: (input) => - budget - .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) - .pipe( - Effect.flatMap((query) => - (input.query.includes("query PullRequestStackMemberships") - ? mockedStackMemberships - : mockedExecute)({ kind: "graphql", ...input, query }), - ), - Effect.map((answer) => answer.body), - Effect.tap((body) => budget.observe(input.host, body)), + GitHubApi.AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + quota.admit(input.host, "graphql", { + allowReserve: input.allowReserve ?? interactive, + }), ), + Effect.andThen(() => + (input.query.includes("query PullRequestStackMemberships") + ? mockedStackMemberships + : mockedExecute)({ kind: "graphql", ...input }), + ), + Effect.tap((answer) => quota.observe(input.host, answer.headers)), + Effect.map((answer) => answer.body), + ), rest: (input) => mockedExecute({ kind: "rest", ...input }), credential: (host) => mockedCredential(host), }); @@ -98,7 +101,7 @@ const mockApi = Layer.effect( const layer = it.layer( GitHubPullRequestApi.layer.pipe( Layer.provideMerge(mockApi), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provide(NodeCrypto.layer), Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), ), @@ -109,8 +112,19 @@ function output( body: string, truncated = false, invalidUtf8 = false, + headers: Readonly> = {}, ): GitHubApi.GitHubRestResponse { - return { status: 200, headers: {}, body, truncated, invalidUtf8 }; + return { status: 200, headers, body, truncated, invalidUtf8 }; +} + +/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ +function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { + return { + "x-ratelimit-resource": "graphql", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), + }; } /** @@ -493,7 +507,7 @@ it.effect( Effect.provide( GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.merge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), @@ -538,15 +552,13 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { - it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => + it.effect("holds background previews once an answer reports the reserve, until it resets", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const resetAt = "2099-08-13T14:00:00Z"; - yield* budget.observe( - "preview-budget.example", - encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), - ); + yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); + // GitHub's count, not ours: the answer below reports the reserve has been reached. mockedExecute.mockReturnValue( Effect.succeed( output( @@ -563,9 +575,11 @@ layer("GitHubPullRequestApi.layer", (it) => { author: null, }, }, - rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, }, }), + false, + false, + graphqlQuota(499, resetAt), ), ), ); @@ -576,18 +590,9 @@ layer("GitHubPullRequestApi.layer", (it) => { host: "preview-budget.example", number: 7, }; - const results = yield* Effect.all( - Array.from({ length: 20 }, (_, index) => - cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), - ), - { concurrency: "unbounded" }, - ); - expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); - for (const result of results) { - if (result._tag === "Failure") { - expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); - } - } + yield* cli.getPullRequestPreview(input); + const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); + expect(held._tag).toBe("SourceControlRateLimitPausedError"); expect(mockedExecute).toHaveBeenCalledTimes(1); yield* TestClock.setTime(Date.parse(resetAt)); yield* cli.getPullRequestPreview(input); @@ -3220,7 +3225,7 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); - it.effect("accounts for the avatar lookup in the GraphQL budget", () => + it.effect("looks up avatars by node id in one GraphQL read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( Effect.succeed( @@ -3228,12 +3233,6 @@ layer("GitHubPullRequestApi.layer", (it) => { encodeJson({ data: { nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 4_999, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), ), @@ -3249,7 +3248,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); expect(avatars.get("octocat")).toBe("https://avatars/octocat"); }), ); @@ -3852,22 +3850,7 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.gen(function* () { const response = coreResponse(); mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - ...response, - data: { - ...response.data, - rateLimit: { - cost: 1, - limit: 5000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), + Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { @@ -4125,14 +4108,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ); @@ -4145,7 +4125,6 @@ layer("GitHubPullRequestApi.layer", (it) => { } as const; yield* cli.getViewerAccess(input); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); const error = yield* Effect.flip(cli.getViewerAccess(input)); @@ -4173,14 +4152,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index a5c0f82be44311b9cee5d1114c477a66769e0cc8..60e2c6f1b45d5f4d3653d7c4ccce7dbbce462cdd 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -49,7 +49,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp ); const layer = GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); return { layer, requests, invalidations: () => invalidations }; @@ -97,15 +97,8 @@ describe("environmentToken", () => { }); describe("GitHubApi", () => { - it.effect("sends GraphQL with the token and records the reported budget", () => { - const { layer, requests } = harness(() => - json({ - data: { - viewer: { login: "julius" }, - rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" }, - }, - }), - ); + it.effect("sends GraphQL with the token, and the document as written", () => { + const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); return Effect.gen(function* () { yield* TestClock.setTime(NOW); const api = yield* GitHubApi.GitHubApi; @@ -117,11 +110,16 @@ describe("GitHubApi", () => { expect(body).toContain('"login":"julius"'); expect(requests[0]!.url).toBe("https://api.github.com/graphql"); expect(requests[0]!.headers.authorization).toBe("Bearer first"); + // No `rateLimit` selection is added: the quota is read off the response headers. + const sent = requests[0]!.body; + expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( + '"query":"query { viewer { login } }"', + ); }).pipe(Effect.provide(layer)); }); it.effect( - "traces the operation, path, query and cost, never the query string or variables", + "traces the operation, path, query and quota, never the query string or variables", () => { const spans: Array = []; const tracer = Tracer.make({ @@ -134,17 +132,7 @@ describe("GitHubApi", () => { const { layer } = harness((request) => request.url.endsWith("/graphql") ? json( - { - data: { - viewer: { login: "julius" }, - rateLimit: { - cost: 3, - limit: 5000, - remaining: 4990, - resetAt: "2026-10-05T13:00:00Z", - }, - }, - }, + { data: { viewer: { login: "julius" } } }, { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, ) : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), @@ -168,8 +156,10 @@ describe("GitHubApi", () => { expect(graphql).toMatchObject({ "github.operation": "getPullRequestDetail", "github.graphql.query": "query($body: String!) { viewer { login } }", - "github.graphql.cost": 3, - "github.graphql.remaining": 4990, + }); + expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ + "github.ratelimit.remaining": 4990, + "github.ratelimit.resource": "graphql", }); expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); @@ -258,6 +248,42 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { + const reset = Math.floor(NOW / 1000) + 600; + const quota = (remaining: number) => ({ + "x-ratelimit-resource": "core", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset), + }); + let remaining = 499; + const { layer, requests } = harness((request) => + request.url.endsWith("/graphql") + ? json({ data: { viewer: { id: "1" } } }) + : json({ ok: true }, { headers: quota(remaining--) }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. + yield* api.rest(read); + const refused = yield* Effect.flip(api.rest(read)); + expect(refused).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + // A user's own request may spend the reserve, and GraphQL has a quota of its own. + yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); + expect(requests).toHaveLength(3); + // The reset gives the background its quota back. + yield* TestClock.setTime(reset * 1000); + yield* api.rest(read); + expect(requests).toHaveLength(4); + }).pipe(Effect.provide(layer)); + }); + it.effect("carries GitHub's own reason for a refused REST request", () => { const { layer } = harness(() => json( diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 0da53e0230b1709ef363c4518035ab4d3c1f8c4c..5f29e8b4053d186c996f22967196dccb09adf911 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const DEFAULT_TIMEOUT = Duration.seconds(30); @@ -218,16 +218,6 @@ function rateLimitAttributes( return attributes; } -const decodeGraphQlCost = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }), - }), - }), - ), -); - /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ function retryAtFrom( headers: Readonly>, @@ -354,7 +344,7 @@ function classify(input: { export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const credentials = yield* GitHubCredentials.GitHubCredentials; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const limits = yield* SourceControlRateLimit.SourceControlRateLimit; const gate = yield* Semaphore.make(CONCURRENCY); @@ -403,10 +393,17 @@ export const make = Effect.gen(function* () { }); const { token, fingerprint } = yield* credential(host); const scope = yield* SourceControlRateLimit.CredentialScope; + // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path + // this reads today is `core`; a `search/` read would need its own resource here. A refusal + // still pauses the whole host, the key PullRequestService records its own backoff under. + const resource = input.graphql === true ? "graphql" : "core"; const key = { provider: "github" as const, host }; const run = Effect.gen(function* () { - const lease = yield* limits - .check(key, input.allowReserve ? { allowPaused: true } : undefined) + const lease = yield* quota + .admit(host, resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), + ) .pipe( Effect.tapError((paused) => Effect.annotateCurrentSpan({ @@ -452,6 +449,7 @@ export const make = Effect.gen(function* () { ); const headers = response.headers; const status = response.status; + yield* quota.observe(host, headers); yield* Effect.annotateCurrentSpan({ "http.response.status_code": status, ...rateLimitAttributes(headers), @@ -556,17 +554,15 @@ export const make = Effect.gen(function* () { : input.query, }); return yield* Effect.gen(function* () { - const query = yield* budget.query( - host, - input.query, - allowReserve ? { allowReserve: true } : undefined, - ); const response = yield* send({ host, operation: input.operation, request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( HttpClientRequest.acceptJson, - HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), + HttpClientRequest.bodyJsonUnsafe({ + query: input.query, + variables: input.variables ?? {}, + }), ), maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, allowReserve, @@ -581,14 +577,6 @@ export const make = Effect.gen(function* () { status: response.status, }); } - yield* budget.observe(host, response.body); - const cost = Option.getOrUndefined(decodeGraphQlCost(response.body)); - if (cost !== undefined) { - yield* Effect.annotateCurrentSpan({ - "github.graphql.cost": cost.data.rateLimit.cost, - "github.graphql.remaining": cost.data.rateLimit.remaining, - }); - } return response.body; }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); }, @@ -600,11 +588,11 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(GitHubApi, make); /** - * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An - * application builds this once so every GitHub reader shares one budget and one pause per host. + * The transport with the credential, quota reserve and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one view of each quota per host. */ export const layerWithDependencies = layer.pipe( Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index b70bfff78f6b73fc207d3fe6d176f2abd071fb03..caa91a5ac937b9abb2cb8cc7529e6a79ff417d44 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -459,6 +459,8 @@ describe("GitHubSourceControlProvider writes", () => { }); assert.strictEqual(requests[0]!.method, "POST"); assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + // A user's own write, so the background's reserve is not held against it. + assert.strictEqual(requests[0]!.allowReserve, true); assert.deepStrictEqual(requests[0]!.body, { base: "main", head: "me:feature", diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 86edda994ccef6c170e9ec5809482c6111a70fe8..223a2ec58a2647b428dc5884112a4fcff01041be 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -679,7 +679,12 @@ export const make = Effect.gen(function* () { const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( host: string, ) { - const response = yield* rest({ host, operation: "getViewer", path: "user" }); + const response = yield* rest({ + host, + operation: "getViewer", + path: "user", + allowReserve: true, + }); const decoded = decodeViewerLogin(response.body); if (Result.isFailure(decoded)) { return yield* failure("GitHub request failed.", decoded.failure); @@ -944,6 +949,8 @@ export const make = Effect.gen(function* () { host: locator.host, operation: "createPullRequest", method: "POST", + // A user's own write may spend the reserve the background leaves for it. + allowReserve: true, path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. // gh allows maintainer edits unless told otherwise; the API's default is not documented. @@ -990,6 +997,7 @@ export const make = Effect.gen(function* () { host, operation: "createRepository", method: "POST", + allowReserve: true, path: isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, body: { name, private: input.visibility === "private" }, diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts deleted file mode 100644 index 26f9747f32026a32740fe18d5210911f8622b4b2..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; - -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import { CredentialScope } from "./SourceControlRateLimit.ts"; - -const RESET_AT = "2026-08-13T14:00:00.000Z"; -const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z"; -const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z"); -const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z"); - -function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT, cost = 14): string { - return JSON.stringify({ - data: { - viewer: { login: "bilal" }, - rateLimit: { cost, limit, remaining, resetAt }, - }, - }); -} - -describe("GitHub GraphQL budget", () => { - it.effect("adds rate metadata to a read query", () => - Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - - const query = yield* budget.query( - "github.com", - 'query { repository(owner: "acme", name: "web") { name } }', - ); - - expect(query).toContain("rateLimit { cost limit remaining resetAt }"); - expect(query).toContain('repository(owner: "acme", name: "web") { name }'); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("protects the last ten percent with the shared provider cooldown error", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - provider: "github", - host: "github.com", - retryAt: Date.parse(RESET_AT), - }); - expect(error.message).toBe( - "github requests to github.com are paused until the rate limit resets.", - ); - - yield* TestClock.setTime(AFTER_RESET); - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps hosts isolated", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(0)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error._tag).toBe("SourceControlRateLimitPausedError"); - expect(yield* budget.query("github.example.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("isolates query reservations and observations by credential", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = budget.query("github.com", "query { viewer { login } }"); - yield* budget - .observe("github.com", rateLimit(0)) - .pipe(Effect.provideService(CredentialScope, "first")); - yield* budget - .observe("github.com", rateLimit(5000)) - .pipe(Effect.provideService(CredentialScope, "second")); - yield* query.pipe(Effect.provideService(CredentialScope, "second")); - const error = yield* query.pipe(Effect.provideService(CredentialScope, "first"), Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps the lower remaining value from out-of-order responses", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400)); - yield* budget.observe("github.com", rateLimit(600)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("learns a cheaper observed cost without restoring reserved quota", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = "query { viewer { login } }"; - yield* budget.observe("github.com", rateLimit(512, 5_000, RESET_AT, 8)); - yield* budget.query("github.com", query); - // The admission reserved eight points, but the completed read only cost one. - yield* budget.observe("github.com", rateLimit(511, 5_000, RESET_AT, 1)); - - for (let count = 0; count < 4; count += 1) { - expect(yield* budget.query("github.com", query)).toContain("rateLimit"); - } - const error = yield* Effect.flip(budget.query("github.com", query)); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores a response from an older reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - yield* budget.observe("github.com", rateLimit(1_000, 5_000, RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("accepts a response from a later reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1_000)); - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows reads above the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(515)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows an interactive read to use the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - expect( - yield* budget.query("github.com", "query { viewer { login } }", { - allowReserve: true, - }), - ).toContain("rateLimit"); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("reserves an admitted query cost before its response", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(514)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("stops interactive reads when the reserve is exhausted", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1, 5000, RESET_AT, 1)); - yield* budget.query("github.com", "query { viewer { login } }", { allowReserve: true }); - const error = yield* budget - .query("github.com", "query { viewer { login } }", { allowReserve: true }) - .pipe(Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores malformed or partial rate metadata", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", "{"); - yield* budget.observe( - "github.com", - '{"data":{"rateLimit":{"limit":0,"remaining":-1,"resetAt":"never"}}}', - ); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("does not add a read field to a mutation", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const mutation = "mutation { addComment(input: {}) { clientMutationId } }"; - yield* budget.observe("github.com", rateLimit(0)); - - expect(yield* budget.query("github.com", mutation)).toBe(mutation); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); -}); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts deleted file mode 100644 index 1021f6cde9fa8ba54ac91fd67948187f3081a2d1..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ /dev/null @@ -1,150 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Predicate from "effect/Predicate"; - -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; - -const GRAPHQL_RESERVE_RATIO = 0.1; -const RATE_LIMIT_SELECTION = "rateLimit { cost limit remaining resetAt }"; - -interface GraphQlBudgetSnapshot { - readonly cost: number; - readonly limit: number; - readonly remaining: number; - readonly resetAtMs: number; -} - -export class GitHubGraphQlBudget extends Context.Service< - GitHubGraphQlBudget, - { - readonly query: ( - host: string, - document: string, - options?: { readonly allowReserve: boolean }, - ) => Effect.Effect; - readonly observe: (host: string, raw: string) => Effect.Effect; - } ->()("t3/sourceControl/githubGraphQlBudget") {} - -function hostKey(host: string): string { - return host.trim().toLowerCase(); -} - -function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { - try { - const parsed: unknown = JSON.parse(raw); - if ( - !Predicate.isObject(parsed) || - !Predicate.isObject(parsed.data) || - !Predicate.isObject(parsed.data.rateLimit) - ) { - return null; - } - const { cost, limit, remaining, resetAt } = parsed.data.rateLimit; - if ( - typeof cost !== "number" || - !Number.isFinite(cost) || - cost < 0 || - typeof limit !== "number" || - !Number.isFinite(limit) || - limit <= 0 || - typeof remaining !== "number" || - !Number.isFinite(remaining) || - remaining < 0 || - typeof resetAt !== "string" - ) { - return null; - } - const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; - } catch { - return null; - } -} - -function isReadOperation(document: string): boolean { - const operation = document.trimStart(); - return operation.startsWith("query") || operation.startsWith("{"); -} - -function withRateLimit(document: string): string { - if (!isReadOperation(document)) return document; - const end = document.lastIndexOf("}"); - if (end === -1 || document.includes(RATE_LIMIT_SELECTION)) return document; - return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const snapshots = yield* Ref.make>(new Map()); - - const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( - function* (host, document, options) { - if (!isReadOperation(document)) return document; - const now = yield* Clock.currentTimeMillis; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - const retryAt = yield* Ref.modify(snapshots, (current) => { - const snapshot = current.get(key); - if (snapshot === undefined) return [null, current] as const; - if (snapshot.resetAtMs <= now) { - const next = new Map(current); - next.delete(key); - return [null, next] as const; - } - const remaining = snapshot.remaining - Math.max(1, snapshot.cost); - if ( - remaining < 0 || - (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) - ) { - return [snapshot.resetAtMs, current] as const; - } - const next = new Map(current); - next.set(key, { ...snapshot, remaining }); - return [null, next] as const; - }); - if (retryAt !== null) { - return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", - host: hostKey(host), - retryAt, - }); - } - return withRateLimit(document); - }, - ); - - const observe: GitHubGraphQlBudget["Service"]["observe"] = Effect.fn( - "GitHubGraphQlBudget.observe", - )(function* (host, raw) { - const snapshot = snapshotFrom(raw); - if (snapshot === null) return; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - yield* Ref.update(snapshots, (current) => { - const previous = current.get(key); - // Concurrent reads can finish out of order. Quota only falls within one reset window, and - // an answer from an older window must not replace the current one. - if (previous !== undefined && snapshot.resetAtMs < previous.resetAtMs) { - return current; - } - const next = new Map(current); - // Keep the conservative balance, but learn the observed cost even when our reservation - // was larger. Otherwise one expensive read makes every later cheap read spend its cost. - next.set( - key, - previous !== undefined && - snapshot.resetAtMs === previous.resetAtMs && - snapshot.remaining >= previous.remaining - ? { ...previous, cost: snapshot.cost } - : snapshot, - ); - return next; - }); - }); - - return GitHubGraphQlBudget.of({ query, observe }); -}); - -export const layer = Layer.effect(GitHubGraphQlBudget, make); diff --git a/apps/server/src/sourceControl/githubQuota.test.ts b/apps/server/src/sourceControl/githubQuota.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e21970cecca72cc87f84cbff28d6eb8d1b216b7f --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import * as GitHubQuota from "./githubQuota.ts"; +import { CredentialScope } from "./SourceControlRateLimit.ts"; + +const RESET = Date.parse("2099-08-13T14:00:00Z"); +const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ + "x-ratelimit-resource": resource, + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset / 1000), +}); + +describe("GitHubQuota", () => { + it.effect("refuses a background request below the reserve, per quota and account", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(499)); + const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); + assert.strictEqual(refused.retryAt, RESET); + // Another quota, another host and another account each have their own balance. + yield* quota.admit("github.com", "core"); + yield* quota.admit("ghe.example", "graphql"); + yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("takes the latest balance in a window, and ignores an older window", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // GitHub can report more left later in the same window, from another region. + yield* quota.observe("github.com", headers(900)); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // An answer from an older window says nothing about this one. + yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // A later window replaces it. + yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); + yield* quota.admit("github.com", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("lets interactive requests spend the reserve but not an empty quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(1)); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + yield* quota.observe("github.com", headers(0)); + const refused = yield* Effect.flip( + quota.admit("github.com", "graphql", { allowReserve: true }), + ); + assert.strictEqual(refused.retryAt, RESET); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(501, "core")); + // Conditional reads answer 304 with the balance unchanged, however many are sent. + for (let index = 0; index < 5; index++) { + yield* quota.admit("github.com", "core"); + yield* quota.observe("github.com", headers(501, "core")); + } + yield* quota.admit("github.com", "core"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(0)); + yield* TestClock.setTime(RESET); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); + yield* quota.admit("other.example", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); +}); diff --git a/apps/server/src/sourceControl/githubQuota.ts b/apps/server/src/sourceControl/githubQuota.ts new file mode 100644 index 0000000000000000000000000000000000000000..c553535019f452209af1f17c2ccbd28e7c1b8a7b --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.ts @@ -0,0 +1,122 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +/** The share of each quota a background read leaves for a user's next click. */ +const RESERVE_RATIO = 0.1; + +/** + * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). + * Each is spent and reset on its own, so each is tracked on its own. + */ +type GitHubQuotaResource = string; + +interface QuotaSnapshot { + readonly limit: number; + readonly remaining: number; + readonly resetAtMs: number; +} + +/** + * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer + * carries. Null when the answer named no quota, which an Enterprise host with limits off does. + */ +function quotaFromHeaders( + headers: Readonly>, +): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { + const resource = headers["x-ratelimit-resource"]?.trim(); + const limit = Number(headers["x-ratelimit-limit"]); + const remaining = Number(headers["x-ratelimit-remaining"]); + const reset = Number(headers["x-ratelimit-reset"]); + if ( + !resource || + !Number.isFinite(limit) || + limit <= 0 || + !Number.isFinite(remaining) || + remaining < 0 || + !Number.isFinite(reset) + ) { + return null; + } + return { resource, limit, remaining, resetAtMs: reset * 1_000 }; +} + +/** + * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that + * would dip below it is refused until the quota resets, so a user's next click still has budget. + * + * The balance is GitHub's own count from the latest answer's headers, so it already includes + * what anything else spent with the same token, such as an agent's `gh` commands. Requests in + * flight together all see that one balance; the reserve absorbs what they spend before their + * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub + * spends nothing, and a local count would drift below GitHub's for the rest of the window. + * + * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. + */ +export class GitHubQuota extends Context.Service< + GitHubQuota, + { + readonly admit: ( + host: string, + resource: GitHubQuotaResource, + options?: { readonly allowReserve: boolean }, + ) => Effect.Effect; + readonly observe: ( + host: string, + headers: Readonly>, + ) => Effect.Effect; + } +>()("t3/sourceControl/githubQuota") {} + +const make = Effect.gen(function* () { + const snapshots = yield* Ref.make>(new Map()); + const keyOf = (host: string, resource: string, scope: string) => + `${host.trim().toLowerCase()}\0${resource}\0${scope}`; + + const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( + function* (host, resource, options) { + const now = yield* Clock.currentTimeMillis; + const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); + const snapshot = (yield* Ref.get(snapshots)).get(key); + if (snapshot === undefined || snapshot.resetAtMs <= now) return; + const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; + if (snapshot.remaining >= floor) return; + return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: "github", + host: host.trim().toLowerCase(), + retryAt: snapshot.resetAtMs, + }); + }, + ); + + const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( + function* (host, headers) { + const quota = quotaFromHeaders(headers); + if (quota === null) return; + const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); + yield* Ref.update(snapshots, (current) => { + const previous = current.get(key); + // The latest answer wins. GitHub serves requests from several regions, so a later answer + // can report more left in the same window, and keeping the lower one would hold + // background work at the reserve until the reset. An answer from an older window says + // nothing of this one. + if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; + const next = new Map(current); + next.set(key, { + limit: quota.limit, + remaining: quota.remaining, + resetAtMs: quota.resetAtMs, + }); + return next; + }); + }, + ); + + return GitHubQuota.of({ admit, observe }); +}); + +export const layer = Layer.effect(GitHubQuota, make);