effect 4.0.0-beta.23 → 4.0.0-beta.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,11 @@
3
3
  */
4
4
  import type { NonEmptyReadonlyArray } from "../../Array.ts"
5
5
  import * as Cause from "../../Cause.ts"
6
+ import { Clock } from "../../Clock.ts"
7
+ import * as Duration from "../../Duration.ts"
6
8
  import * as Effect from "../../Effect.ts"
7
9
  import * as Exit from "../../Exit.ts"
8
- import type { Fiber } from "../../Fiber.ts"
10
+ import * as Fiber from "../../Fiber.ts"
9
11
  import { constFalse, constTrue, dual, flow, identity } from "../../Function.ts"
10
12
  import * as Inspectable from "../../Inspectable.ts"
11
13
  import * as Layer from "../../Layer.ts"
@@ -19,6 +21,7 @@ import * as ServiceMap from "../../ServiceMap.ts"
19
21
  import * as Stream from "../../Stream.ts"
20
22
  import * as Tracer from "../../Tracer.ts"
21
23
  import type { EqualsWith, ExcludeTag, ExtractTag, NoExcessProperties, NoInfer, Tags } from "../../Types.ts"
24
+ import type * as RateLimiter from "../persistence/RateLimiter.ts"
22
25
  import * as Cookies from "./Cookies.ts"
23
26
  import * as Headers from "./Headers.ts"
24
27
  import * as Error from "./HttpClientError.ts"
@@ -630,7 +633,7 @@ export const make = (
630
633
  request: HttpClientRequest.HttpClientRequest,
631
634
  url: URL,
632
635
  signal: AbortSignal,
633
- fiber: Fiber<HttpClientResponse.HttpClientResponse, Error.HttpClientError>
636
+ fiber: Fiber.Fiber<HttpClientResponse.HttpClientResponse, Error.HttpClientError>
634
637
  ) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error.HttpClientError>
635
638
  ): HttpClient =>
636
639
  makeWith((effect) =>
@@ -1072,6 +1075,286 @@ export const retryTransient: {
1072
1075
  }
1073
1076
  )
1074
1077
 
1078
+ /**
1079
+ * @since 4.0.0
1080
+ * @category rate limiting
1081
+ */
1082
+ export declare namespace WithRateLimiter {
1083
+ /**
1084
+ * @since 4.0.0
1085
+ * @category rate limiting
1086
+ */
1087
+ export interface Options {
1088
+ /**
1089
+ * The `RateLimiter` service to use for rate limiting.
1090
+ */
1091
+ readonly limiter: RateLimiter.RateLimiter
1092
+ /**
1093
+ * The initial rate limit window duration.
1094
+ */
1095
+ readonly window: Duration.Input
1096
+ /**
1097
+ * The initial maximum number of allowed requests in the window.
1098
+ */
1099
+ readonly limit: number
1100
+ /**
1101
+ * The key to identify the rate limit. Requests with the same key will share
1102
+ * the same rate limit. This can be used to implement per-user or
1103
+ * per-endpoint rate limits.
1104
+ */
1105
+ readonly key: string | ((request: HttpClientRequest.HttpClientRequest) => string)
1106
+ /**
1107
+ * Defaults to `"fixed-window"`.
1108
+ */
1109
+ readonly algorithm?: "fixed-window" | "token-bucket" | undefined
1110
+ /**
1111
+ * Defaults to `1`.
1112
+ */
1113
+ readonly tokens?: number | ((request: HttpClientRequest.HttpClientRequest) => number) | undefined
1114
+ /**
1115
+ * Disable automatic limits updates from response headers.
1116
+ */
1117
+ readonly disableResponseInspection?: boolean | undefined
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * Applies request rate limiting using the `RateLimiter` service.
1123
+ *
1124
+ * It can update limits by inspecting common rate limit response headers and
1125
+ * automatically retries HTTP `429` responses (or `HttpClientError` values
1126
+ * wrapping a `429` response) by forcing the retry back through the limiter.
1127
+ *
1128
+ * @since 4.0.0
1129
+ * @category rate limiting
1130
+ */
1131
+ export const withRateLimiter: {
1132
+ /**
1133
+ * Applies request rate limiting using the `RateLimiter` service.
1134
+ *
1135
+ * It can update limits by inspecting common rate limit response headers and
1136
+ * automatically retries HTTP `429` responses (or `HttpClientError` values
1137
+ * wrapping a `429` response) by forcing the retry back through the limiter.
1138
+ *
1139
+ * @since 4.0.0
1140
+ * @category rate limiting
1141
+ */
1142
+ (options: WithRateLimiter.Options): <E, R>(
1143
+ self: HttpClient.With<E, R>
1144
+ ) => HttpClient.With<E | RateLimiter.RateLimiterError, R>
1145
+ /**
1146
+ * Applies request rate limiting using the `RateLimiter` service.
1147
+ *
1148
+ * It can update limits by inspecting common rate limit response headers and
1149
+ * automatically retries HTTP `429` responses (or `HttpClientError` values
1150
+ * wrapping a `429` response) by forcing the retry back through the limiter.
1151
+ *
1152
+ * @since 4.0.0
1153
+ * @category rate limiting
1154
+ */
1155
+ <E, R>(self: HttpClient.With<E, R>, options: WithRateLimiter.Options): HttpClient.With<E | RateLimiter.RateLimiterError, R>
1156
+ } = dual(2, <E, R>(
1157
+ self: HttpClient.With<E, R>,
1158
+ options: WithRateLimiter.Options
1159
+ ): HttpClient.With<E | RateLimiter.RateLimiterError, R> => {
1160
+ const initialState: RateLimiterState = {
1161
+ limit: options.limit,
1162
+ window: Duration.max(Duration.fromInputUnsafe(options.window), Duration.millis(1))
1163
+ }
1164
+ const states = new Map<string, RateLimiterState>()
1165
+
1166
+ const keyOption = options.key
1167
+ const resolveKey: (request: HttpClientRequest.HttpClientRequest) => string = typeof keyOption === "function"
1168
+ ? keyOption
1169
+ : () => keyOption
1170
+ const tokensOption = options.tokens
1171
+ const resolveTokens: (request: HttpClientRequest.HttpClientRequest) => number | undefined =
1172
+ typeof tokensOption === "function" ? tokensOption : () => tokensOption
1173
+
1174
+ const getState = (key: string): RateLimiterState => {
1175
+ const current = states.get(key)
1176
+ if (current !== undefined) {
1177
+ return current
1178
+ }
1179
+ states.set(key, initialState)
1180
+ return initialState
1181
+ }
1182
+
1183
+ const onResponse = options.disableResponseInspection
1184
+ ? undefined
1185
+ : (clock: Clock, key: string, headers: Headers.Headers, tokens: number | undefined) => {
1186
+ const current = getState(key)
1187
+ const next = parseRateLimiterState(current, clock, headers, tokens)
1188
+ if (next.limit !== current.limit || !Duration.equals(next.window, current.window)) {
1189
+ states.set(key, next)
1190
+ }
1191
+ }
1192
+
1193
+ return transform(self, function loop(effect, request): Effect.Effect<
1194
+ HttpClientResponse.HttpClientResponse,
1195
+ E | RateLimiter.RateLimiterError,
1196
+ R
1197
+ > {
1198
+ const fiber = Fiber.getCurrent()!
1199
+ const clock = fiber.getRef(Clock)
1200
+ const key = resolveKey(request)
1201
+ const tokens = resolveTokens(request)
1202
+ const current = getState(key)
1203
+ return Effect.flatMap(
1204
+ options.limiter.consume({
1205
+ algorithm: options.algorithm,
1206
+ onExceeded: "delay",
1207
+ key,
1208
+ limit: current.limit,
1209
+ window: current.window,
1210
+ tokens
1211
+ }),
1212
+ ({ delay }) => {
1213
+ const run = Effect.matchEffect(effect, {
1214
+ onSuccess(response) {
1215
+ onResponse?.(clock, key, response.headers, tokens)
1216
+ return response.status === 429 ? loop(effect, request) : Effect.succeed(response)
1217
+ },
1218
+ onFailure(error) {
1219
+ if (isTooManyRequestsHttpClientError(error)) {
1220
+ onResponse?.(clock, key, error.reason.response.headers, tokens)
1221
+ return loop(effect, request)
1222
+ }
1223
+ return Effect.fail(error)
1224
+ }
1225
+ })
1226
+ return Duration.isZero(delay) ? run : Effect.delay(run, delay)
1227
+ }
1228
+ )
1229
+ })
1230
+ })
1231
+
1232
+ interface RateLimiterState {
1233
+ readonly limit: number
1234
+ readonly window: Duration.Duration
1235
+ }
1236
+
1237
+ const parseRateLimiterState = (
1238
+ state: RateLimiterState,
1239
+ clock: Clock,
1240
+ headers: Headers.Headers,
1241
+ tokens: number | undefined
1242
+ ): RateLimiterState => {
1243
+ const limit = parseRateLimitLimit(headers, tokens) ?? state.limit
1244
+ const window = parseRateLimitWindow(clock, headers) ?? state.window
1245
+ if (limit === state.limit && Duration.equals(window, state.window)) {
1246
+ return state
1247
+ }
1248
+ return { limit, window }
1249
+ }
1250
+
1251
+ const parseRateLimitLimit = (headers: Headers.Headers, tokens: number | undefined): number | undefined => {
1252
+ const raw = getHeader(headers, "ratelimit-limit", "x-ratelimit-limit")
1253
+ const value = parseNumberHeader(raw)
1254
+ if (value !== undefined && value > 0) {
1255
+ return value
1256
+ }
1257
+ const remaining = parseRateLimitRemaining(headers)
1258
+ if (remaining === undefined) {
1259
+ return undefined
1260
+ }
1261
+ return Math.max(remaining + (tokens !== undefined && tokens > 0 ? tokens : 1), 1)
1262
+ }
1263
+
1264
+ const parseRateLimitRemaining = (headers: Headers.Headers): number | undefined => {
1265
+ const raw = getHeader(headers, "ratelimit-remaining", "x-ratelimit-remaining")
1266
+ const value = parseNumberHeader(raw)
1267
+ return value !== undefined && value >= 0 ? value : undefined
1268
+ }
1269
+
1270
+ const parseRateLimitWindow = (
1271
+ clock: Clock,
1272
+ headers: Headers.Headers
1273
+ ): Duration.Duration | undefined => {
1274
+ const retryAfter = parseRetryAfter(
1275
+ clock,
1276
+ getHeader(headers, "retry-after")
1277
+ )
1278
+ if (retryAfter !== undefined) {
1279
+ return retryAfter
1280
+ }
1281
+ const resetAfter = parseResetAfter(getHeader(headers, "ratelimit-reset-after", "x-ratelimit-reset-after"))
1282
+ if (resetAfter !== undefined) {
1283
+ return resetAfter
1284
+ }
1285
+ return parseResetHeader(clock, getHeader(headers, "ratelimit-reset", "x-ratelimit-reset"))
1286
+ }
1287
+
1288
+ const parseRetryAfter = (
1289
+ clock: Clock,
1290
+ value: string | undefined
1291
+ ): Duration.Duration | undefined => {
1292
+ if (value === undefined) {
1293
+ return undefined
1294
+ }
1295
+ const numeric = parseNumberHeader(value)
1296
+ if (numeric !== undefined) {
1297
+ return Duration.max(Duration.seconds(numeric), Duration.millis(1))
1298
+ }
1299
+ const parsedDate = Date.parse(value)
1300
+ if (Number.isNaN(parsedDate)) {
1301
+ return undefined
1302
+ }
1303
+ const millis = parsedDate - clock.currentTimeMillisUnsafe()
1304
+ if (millis <= 0) {
1305
+ return Duration.millis(1)
1306
+ }
1307
+ return Duration.millis(millis)
1308
+ }
1309
+
1310
+ const parseResetAfter = (value: string | undefined): Duration.Duration | undefined => {
1311
+ const numeric = parseNumberHeader(value)
1312
+ if (numeric === undefined || numeric <= 0) {
1313
+ return undefined
1314
+ }
1315
+ return Duration.max(Duration.seconds(numeric), Duration.millis(1))
1316
+ }
1317
+
1318
+ const parseResetHeader = (
1319
+ clock: Clock,
1320
+ value: string | undefined
1321
+ ): Duration.Duration | undefined => {
1322
+ const numeric = parseNumberHeader(value)
1323
+ if (numeric === undefined || numeric <= 0) {
1324
+ return undefined
1325
+ }
1326
+ const nowMillis = clock.currentTimeMillisUnsafe()
1327
+ if (numeric > 1_000_000_000_000) {
1328
+ return Duration.millis(Math.max(numeric - nowMillis, 1))
1329
+ }
1330
+ if (numeric > 1_000_000_000) {
1331
+ return Duration.millis(Math.max((numeric * 1_000) - nowMillis, 1))
1332
+ }
1333
+ return Duration.max(Duration.seconds(numeric), Duration.millis(1))
1334
+ }
1335
+
1336
+ const parseNumberHeader = (value: string | undefined): number | undefined => {
1337
+ if (value === undefined) {
1338
+ return undefined
1339
+ }
1340
+ const match = /-?\d+(?:\.\d+)?/.exec(value)
1341
+ if (match === null) {
1342
+ return undefined
1343
+ }
1344
+ const parsed = Number(match[0])
1345
+ return Number.isFinite(parsed) ? parsed : undefined
1346
+ }
1347
+
1348
+ const getHeader = (headers: Headers.Headers, ...keys: Array<string>): string | undefined => {
1349
+ for (let i = 0; i < keys.length; i++) {
1350
+ const value = headers[keys[i]]
1351
+ if (value !== undefined) {
1352
+ return value
1353
+ }
1354
+ }
1355
+ return undefined
1356
+ }
1357
+
1075
1358
  /**
1076
1359
  * Performs an additional effect after a successful request.
1077
1360
  *
@@ -1462,6 +1745,11 @@ const isTransientHttpError = (error: unknown) =>
1462
1745
  (error.reason._tag === "TransportError" ||
1463
1746
  (error.reason._tag === "StatusCodeError" && isTransientResponse(error.reason.response)))
1464
1747
 
1748
+ const isTooManyRequestsHttpClientError = (
1749
+ error: unknown
1750
+ ): error is Error.HttpClientError & { readonly reason: Error.StatusCodeError } =>
1751
+ Error.isHttpClientError(error) && error.reason._tag === "StatusCodeError" && error.reason.response.status === 429
1752
+
1465
1753
  const isTransientResponse = (response: HttpClientResponse.HttpClientResponse) =>
1466
1754
  response.status === 408 ||
1467
1755
  response.status === 429 ||
@@ -57,7 +57,7 @@ export interface HttpApi<
57
57
  /**
58
58
  * Prefix all endpoints in the `HttpApi`.
59
59
  */
60
- prefix<const Prefix extends PathInput>(prefix: Prefix): HttpApi<Id, Groups>
60
+ prefix<const Prefix extends PathInput>(prefix: Prefix): HttpApi<Id, HttpApiGroup.AddPrefix<Groups, Prefix>>
61
61
 
62
62
  /**
63
63
  * Add a middleware to a `HttpApi`. It will be applied to all endpoints in the