1/**
2 * @title Handling GraphQL errors, retries and rate limits
3 *
4 * Read rate-limit headers in a middleware, tell GraphQL errors apart by their
5 * fields, and retry timeouts and rate limits with `Effect.retry`.
6 */
7import { Duration, Effect, Layer, Schedule } from "effect"
8import { type GraphQLClientError, GraphQLMiddleware } from "effect/graphql"
9import { GitHub } from "./10_basics.ts"
10
11// Middleware see each response's HTTP `status` and `headers` on the result
12// `next` returns, including responses with GraphQL `errors`. A failed call
13// keeps them, and `GraphQLClientError` exposes them as `headers` and
14// `status`. Attach it with `GraphQLGroup.make(...).middleware(RateLimit)`.
15export class RateLimit extends GraphQLMiddleware.Service<RateLimit>()("app/RateLimit") {
16 static readonly layer = Layer.succeed(RateLimit, {
17 execute: ({ next, request }) =>
18 Effect.tap(next(request), (result) => {
19 const remaining = Number(result.headers?.["x-ratelimit-remaining"])
20 return remaining < 500
21 ? Effect.logWarning(`GitHub rate limit: ${remaining} points left`)
22 : Effect.void
23 }),
24 subscribe: ({ next, request }) => next(request)
25 })
26}
27
28// GraphQL errors keep fields the specification doesn't define as JSON, such
29// as GitHub's top-level `type` (`NOT_FOUND`, `FORBIDDEN`, `RATE_LIMITED`).
30export const hasErrorType = (error: GraphQLClientError.GraphQLClientError, type: string) =>
31 error.reason._tag === "ResponseError" && error.reason.errors.every((entry) => entry.type === type)
32
33// `isRetryable` is true for network failures, for 429 or 5xx responses, and
34// for any error with a `Retry-After` header, whatever its status. That covers
35// GitHub's 502 and 504 query timeouts, even with a GraphQL `errors` body, and
36// its secondary rate limits (403 or 429 with `retry-after`). A primary rate
37// limit can come without `retry-after`, with `x-ratelimit-remaining: 0`, so
38// check that header too.
39const rateLimited = (error: GraphQLClientError.GraphQLClientError) => error.headers?.["x-ratelimit-remaining"] === "0"
40
41// Wait for `retryAfter` when it is set, and until `x-ratelimit-reset` (epoch
42// seconds) on a primary rate limit. GitHub may ban an integration that keeps
43// retrying while rate limited, so don't retry with a plain backoff.
44const gitHubRetry = Schedule.exponential("1 second").pipe(
45 Schedule.setInputType<GraphQLClientError.GraphQLClientError>(),
46 Schedule.modifyDelay(({ duration, input, now }) => {
47 if (input.retryAfter !== undefined) return Effect.succeed(input.retryAfter)
48 if (!rateLimited(input)) return Effect.succeed(duration)
49 const reset = Number(input.headers?.["x-ratelimit-reset"]) * 1000
50 return Effect.succeed(Number.isFinite(reset) ? Duration.millis(Math.max(0, reset - now)) : Duration.minutes(1))
51 })
52)
53
54// The client never retries queries or mutations on its own. Retry only
55// mutations that are safe to send twice, and with `GraphQLClient.pages`,
56// retry inside a middleware, because `Stream.retry` starts again from the
57// first page. Subscriptions reconnect on their own after a retryable failure,
58// on `GraphQLClient.defaultSubscriptionRetry`, which waits for `retryAfter`.
59// A primary rate limit without `retry-after` isn't retryable, so it ends the
60// subscription; wait until `x-ratelimit-reset` before subscribing again.
61export const retryGitHub = <A, R>(effect: Effect.Effect<A, GraphQLClientError.GraphQLClientError, R>) =>
62 Effect.retry(effect, {
63 schedule: gitHubRetry,
64 while: (error) => error.isRetryable || rateLimited(error),
65 times: 5
66 })
67
68export const program = Effect.gen(function*() {
69 const github = yield* GitHub
70 const result = yield* retryGitHub(github.RepoIssues({ owner: "Effect-TS", name: "missing" })).pipe(
71 Effect.catchIf((error) => hasErrorType(error, "NOT_FOUND"), () => Effect.succeed(null))
72 )
73 yield* Effect.log(result === null ? "No such repository" : `${result.repository?.issues.nodes?.length} issues`)
74}).pipe(Effect.provide(GitHub.layer))