1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
8import {
9 SourceControlProviderKind as SourceControlProviderKindSchema,
10 type SourceControlProviderKind,
11} from "@t3tools/contracts";
12
13const FALLBACK_COOLDOWN = Duration.seconds(30);
14const MAX_FALLBACK_COOLDOWN = Duration.minutes(15);
15
16export const CredentialScope = Context.Reference<string>(
17 "@t3tools/source-control-core/server/SourceControlRateLimit/CredentialScope",
18 {
19 defaultValue: () => "",
20 },
21);
22
23/**
24 * Set by interactive callers (a user's read or write, not a background sweep). A host may let
25 * requests made under it spend a reserved quota and go through a rate-limit pause: a user acting
26 * on a change request should not be refused because a background read exhausted the quota.
27 */
28export const Interactive = Context.Reference<boolean>(
29 "@t3tools/source-control-core/server/SourceControlRateLimit/Interactive",
30 { defaultValue: () => false },
31);
32
33interface RateLimitKey {
34 readonly provider: SourceControlProviderKind;
35 readonly host: string;
36}
37
38interface RateLimitLease extends RateLimitKey {
39 readonly lease: number;
40}
41
42interface RateLimitEntry {
43 readonly attempt: number;
44 readonly generation: number;
45 readonly retryAt: number;
46}
47
48export class SourceControlRateLimitPausedError extends Schema.TaggedError<SourceControlRateLimitPausedError>()(
49 "SourceControlRateLimitPausedError",
50 {
51 provider: SourceControlProviderKindSchema,
52 host: Schema.String,
53 retryAt: Schema.Number,
54 },
55) {
56 get detail(): string {
57 return `${this.provider} requests to ${this.host} are paused until the rate limit resets.`;
58 }
59
60 override get message(): string {
61 return this.detail;
62 }
63}
64
65export class SourceControlRateLimit extends Context.Service<
66 SourceControlRateLimit,
67 {
68 readonly check: (
69 key: RateLimitKey,
70 options?: { readonly allowPaused: boolean },
71 ) => Effect.Effect<number, SourceControlRateLimitPausedError>;
72 readonly recordRateLimit: (
73 input: RateLimitLease & { readonly retryAt?: number | undefined },
74 ) => Effect.Effect<void>;
75 readonly recordSuccess: (input: RateLimitLease) => Effect.Effect<void>;
76 }
77>()("@t3tools/source-control-core/server/SourceControlRateLimit") {}
78
79function normalizedKey(key: RateLimitKey, scope: string): string {
80 return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`;
81}
82
83function fallbackCooldownMs(attempt: number): number {
84 return Math.min(
85 Duration.toMillis(FALLBACK_COOLDOWN) * 2 ** Math.max(0, attempt - 1),
86 Duration.toMillis(MAX_FALLBACK_COOLDOWN),
87 );
88}
89
90export function retryAtFromHeader(value: string | undefined, now: number): number | undefined {
91 if (value === undefined) return undefined;
92 const normalized = value.trim();
93 if (/^\d+$/u.test(normalized)) {
94 const seconds = Number(normalized);
95 const retryAt = now + seconds * 1_000;
96 return Number.isSafeInteger(retryAt) ? retryAt : undefined;
97 }
98 const retryAt = Date.parse(normalized);
99 return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined;
100}
101
102/** @public Service construction is part of the canonical Effect module API. */
103export const make = Effect.gen(function* () {
104 const entries = yield* Ref.make<ReadonlyMap<string, RateLimitEntry>>(new Map());
105
106 const check: SourceControlRateLimit["Service"]["check"] = Effect.fn(
107 "SourceControlRateLimit.check",
108 )(function* (input, options) {
109 const now = yield* Clock.currentTimeMillis;
110 const key = normalizedKey(input, yield* CredentialScope);
111 const entry = (yield* Ref.get(entries)).get(key);
112 if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) {
113 return yield* new SourceControlRateLimitPausedError({
114 provider: input.provider,
115 host: input.host.trim().toLowerCase(),
116 retryAt: entry.retryAt,
117 });
118 }
119 return entry?.generation ?? 0;
120 });
121
122 const recordRateLimit: SourceControlRateLimit["Service"]["recordRateLimit"] = Effect.fn(
123 "SourceControlRateLimit.recordRateLimit",
124 )(function* (input) {
125 const now = yield* Clock.currentTimeMillis;
126 const key = normalizedKey(input, yield* CredentialScope);
127 yield* Ref.update(entries, (current) => {
128 const previous = current.get(key);
129 if (previous !== undefined && previous.generation > input.lease) {
130 if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) {
131 return current;
132 }
133 const retryAt =
134 input.retryAt !== undefined && input.retryAt > previous.retryAt
135 ? input.retryAt
136 : previous.retryAt;
137 if (retryAt === previous.retryAt) return current;
138 const next = new Map(current);
139 next.set(key, { ...previous, retryAt });
140 return next;
141 }
142
143 const attempt = (previous?.attempt ?? 0) + 1;
144 const proposedRetryAt =
145 input.retryAt !== undefined && input.retryAt > now
146 ? input.retryAt
147 : now + fallbackCooldownMs(attempt);
148 const retryAt =
149 previous !== undefined && previous.retryAt > now
150 ? Math.max(previous.retryAt, proposedRetryAt)
151 : proposedRetryAt;
152 const next = new Map(current);
153 next.set(key, {
154 attempt,
155 generation: Math.max(previous?.generation ?? 0, input.lease) + 1,
156 retryAt,
157 });
158 return next;
159 });
160 });
161
162 const recordSuccess: SourceControlRateLimit["Service"]["recordSuccess"] = Effect.fn(
163 "SourceControlRateLimit.recordSuccess",
164 )(function* (input) {
165 const now = yield* Clock.currentTimeMillis;
166 const key = normalizedKey(input, yield* CredentialScope);
167 yield* Ref.update(entries, (current) => {
168 const previous = current.get(key);
169 if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) {
170 return current;
171 }
172 const next = new Map(current);
173 next.set(key, { attempt: 0, generation: previous.generation, retryAt: 0 });
174 return next;
175 });
176 });
177
178 return SourceControlRateLimit.of({ check, recordRateLimit, recordSuccess });
179});
180
181export const layer = Layer.effect(SourceControlRateLimit, make);