1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7
8import * 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. */
11const 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 */
17type GitHubQuotaResource = string;
18
19interface 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 */
29function 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 */
61export 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
76const 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
123export const layer = Layer.effect(GitHubQuota, make);