| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import { |
| 9 | SourceControlProviderKind as SourceControlProviderKindSchema, |
| 10 | type SourceControlProviderKind, |
| 11 | } from "@t3tools/contracts"; |
| 12 | |
| 13 | const FALLBACK_COOLDOWN = Duration.seconds(30); |
| 14 | const MAX_FALLBACK_COOLDOWN = Duration.minutes(15); |
| 15 | |
| 16 | export 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 | */ |
| 28 | export const Interactive = Context.Reference<boolean>( |
| 29 | "@t3tools/source-control-core/server/SourceControlRateLimit/Interactive", |
| 30 | { defaultValue: () => false }, |
| 31 | ); |
| 32 | |
| 33 | interface RateLimitKey { |
| 34 | readonly provider: SourceControlProviderKind; |
| 35 | readonly host: string; |
| 36 | } |
| 37 | |
| 38 | interface RateLimitLease extends RateLimitKey { |
| 39 | readonly lease: number; |
| 40 | } |
| 41 | |
| 42 | interface RateLimitEntry { |
| 43 | readonly attempt: number; |
| 44 | readonly generation: number; |
| 45 | readonly retryAt: number; |
| 46 | } |
| 47 | |
| 48 | export 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 | |
| 65 | export 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 | |
| 79 | function normalizedKey(key: RateLimitKey, scope: string): string { |
| 80 | return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`; |
| 81 | } |
| 82 | |
| 83 | function 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 | |
| 90 | export 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. */ |
| 103 | export 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 | |
| 181 | export const layer = Layer.effect(SourceControlRateLimit, make); |