| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Clock from "effect/Clock"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Ref from "effect/Ref"; |
| 7 | |
| 8 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 9 | |
| 10 | /** The share of each quota a background read leaves for a user's next click. */ |
| 11 | const RESERVE_RATIO = 0.1; |
| 12 | |
| 13 | /** |
| 14 | * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). |
| 15 | * Each is spent and reset on its own, so each is tracked on its own. |
| 16 | */ |
| 17 | type GitHubQuotaResource = string; |
| 18 | |
| 19 | interface QuotaSnapshot { |
| 20 | readonly limit: number; |
| 21 | readonly remaining: number; |
| 22 | readonly resetAtMs: number; |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer |
| 27 | * carries. Null when the answer named no quota, which an Enterprise host with limits off does. |
| 28 | */ |
| 29 | function quotaFromHeaders( |
| 30 | headers: Readonly<Record<string, string | undefined>>, |
| 31 | ): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { |
| 32 | const resource = headers["x-ratelimit-resource"]?.trim(); |
| 33 | const limit = Number(headers["x-ratelimit-limit"]); |
| 34 | const remaining = Number(headers["x-ratelimit-remaining"]); |
| 35 | const reset = Number(headers["x-ratelimit-reset"]); |
| 36 | if ( |
| 37 | !resource || |
| 38 | !Number.isFinite(limit) || |
| 39 | limit <= 0 || |
| 40 | !Number.isFinite(remaining) || |
| 41 | remaining < 0 || |
| 42 | !Number.isFinite(reset) |
| 43 | ) { |
| 44 | return null; |
| 45 | } |
| 46 | return { resource, limit, remaining, resetAtMs: reset * 1_000 }; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that |
| 51 | * would dip below it is refused until the quota resets, so a user's next click still has budget. |
| 52 | * |
| 53 | * The balance is GitHub's own count from the latest answer's headers, so it already includes |
| 54 | * what anything else spent with the same token, such as an agent's `gh` commands. Requests in |
| 55 | * flight together all see that one balance; the reserve absorbs what they spend before their |
| 56 | * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub |
| 57 | * spends nothing, and a local count would drift below GitHub's for the rest of the window. |
| 58 | * |
| 59 | * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. |
| 60 | */ |
| 61 | export class GitHubQuota extends Context.Service< |
| 62 | GitHubQuota, |
| 63 | { |
| 64 | readonly admit: ( |
| 65 | host: string, |
| 66 | resource: GitHubQuotaResource, |
| 67 | options?: { readonly allowReserve: boolean }, |
| 68 | ) => Effect.Effect<void, SourceControlRateLimit.SourceControlRateLimitPausedError>; |
| 69 | readonly observe: ( |
| 70 | host: string, |
| 71 | headers: Readonly<Record<string, string | undefined>>, |
| 72 | ) => Effect.Effect<void>; |
| 73 | } |
| 74 | >()("@t3tools/source-control-github/server/GitHubQuota") {} |
| 75 | |
| 76 | const make = Effect.gen(function* () { |
| 77 | const snapshots = yield* Ref.make<ReadonlyMap<string, QuotaSnapshot>>(new Map()); |
| 78 | const keyOf = (host: string, resource: string, scope: string) => |
| 79 | `${host.trim().toLowerCase()}\0${resource}\0${scope}`; |
| 80 | |
| 81 | const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( |
| 82 | function* (host, resource, options) { |
| 83 | const now = yield* Clock.currentTimeMillis; |
| 84 | const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); |
| 85 | const snapshot = (yield* Ref.get(snapshots)).get(key); |
| 86 | if (snapshot === undefined || snapshot.resetAtMs <= now) return; |
| 87 | const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; |
| 88 | if (snapshot.remaining >= floor) return; |
| 89 | return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ |
| 90 | provider: SourceControlProviderKind.make("github"), |
| 91 | host: host.trim().toLowerCase(), |
| 92 | retryAt: snapshot.resetAtMs, |
| 93 | }); |
| 94 | }, |
| 95 | ); |
| 96 | |
| 97 | const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( |
| 98 | function* (host, headers) { |
| 99 | const quota = quotaFromHeaders(headers); |
| 100 | if (quota === null) return; |
| 101 | const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); |
| 102 | yield* Ref.update(snapshots, (current) => { |
| 103 | const previous = current.get(key); |
| 104 | // The latest answer wins. GitHub serves requests from several regions, so a later answer |
| 105 | // can report more left in the same window, and keeping the lower one would hold |
| 106 | // background work at the reserve until the reset. An answer from an older window says |
| 107 | // nothing of this one. |
| 108 | if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; |
| 109 | const next = new Map(current); |
| 110 | next.set(key, { |
| 111 | limit: quota.limit, |
| 112 | remaining: quota.remaining, |
| 113 | resetAtMs: quota.resetAtMs, |
| 114 | }); |
| 115 | return next; |
| 116 | }); |
| 117 | }, |
| 118 | ); |
| 119 | |
| 120 | return GitHubQuota.of({ admit, observe }); |
| 121 | }); |
| 122 | |
| 123 | export const layer = Layer.effect(GitHubQuota, make); |