Skip to content
Source
Sign inSign up

httpReadiness.ts

143 lines4.7 KB

1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Ref from "effect/Ref";
5import * as Schedule from "effect/Schedule";
6import { HttpClient, HttpClientRequest } from "effect/http";
7​
8const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000;
9​
10function describeReadinessCause(cause: unknown): unknown {
11 if (cause instanceof Error) {
12 const tag = (cause as { readonly _tag?: unknown })._tag;
13 const nested = (cause as { readonly cause?: unknown }).cause;
14 return {
15 ...(typeof tag === "string" ? { _tag: tag } : { name: cause.name }),
16 message: cause.message,
17 ...(nested === undefined ? {} : { cause: describeReadinessCause(nested) }),
18 };
19 }
20 if (typeof cause !== "object" || cause === null) {
21 return cause;
22 }
23​
24 const record = cause as Readonly<Record<string, unknown>>;
25 return {
26 ...(typeof record._tag === "string" ? { _tag: record._tag } : {}),
27 ...(typeof record.message === "string" ? { message: record.message } : {}),
28 ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }),
29 ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }),
30 };
32​
33export const waitForHttpReady = Effect.fn("shared.httpReadiness.waitForHttpReady")(function* <
34 E,
35>(input: {
36 readonly baseUrl: string;
37 readonly path?: string;
38 readonly timeoutMs?: number;
39 readonly intervalMs?: number;
40 readonly probeTimeoutMs?: number;
41 readonly makeError: (info: {
42 readonly requestUrl: string;
43 readonly probeTimeoutMs: number;
44 readonly attempt: number;
45 readonly cause: unknown;
46 }) => E;
47}): Effect.fn.Return<void, E, HttpClient.HttpClient> {
48 const timeoutMs = input.timeoutMs ?? 30_000;
49 const intervalMs = input.intervalMs ?? 100;
50 const probeTimeoutMs = input.probeTimeoutMs ?? DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS;
51 const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe(
52 Schedule.upTo({ times: Math.max(0, Math.ceil(timeoutMs / intervalMs)) }),
53 );
54 const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString();
55 const client = yield* HttpClient.HttpClient;
56 const lastProbeFailure = yield* Ref.make<unknown>(null);
57 let attempt = 0;
58​
59 const makeError = input.makeError;
60 const madeErrors = new WeakSet<object>();
61 const fail = (cause: unknown): E => {
62 const error = makeError({ requestUrl, probeTimeoutMs, attempt, cause });
63 if (typeof error === "object" && error !== null) {
64 madeErrors.add(error);
65 }
66 return error;
67 };
68 const isMadeError = (value: unknown): value is E =>
69 typeof value === "object" && value !== null && madeErrors.has(value);
70​
71 yield* Effect.logDebug("httpReadiness.start", {
72 baseUrl: input.baseUrl,
73 requestUrl,
74 timeoutMs,
75 intervalMs,
76 probeTimeoutMs,
77 });
78​
79 const readinessClient = client.pipe(
80 HttpClient.filterStatusOk,
81 HttpClient.transform((effect) =>
82 Effect.gen(function* () {
83 attempt += 1;
84 const responseOption = yield* effect.pipe(
85 Effect.timeoutOption(Duration.millis(probeTimeoutMs)),
86 Effect.mapError((cause) => fail(cause)),
87 );
88 return yield* Effect.fromOption(responseOption, () =>
89 fail({
90 kind: "probe-timeout",
91 attempt,
92 probeTimeoutMs,
93 }),
94 );
95 }).pipe(
96 Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))),
97 Effect.tapError((cause) =>
98 Ref.set(lastProbeFailure, {
99 attempt,
100 cause: describeReadinessCause(cause),
101 }),
102 ),
103 ),
104 ),
105 HttpClient.tap((response) => response.text.pipe(Effect.ignore)),
106 HttpClient.retry(retryPolicy),
107 );
108​
109 const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe(
110 Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))),
111 Effect.timeoutOption(Duration.millis(timeoutMs)),
112 );
113​
114 return yield* Option.match(result, {
115 onSome: () =>
116 Effect.logDebug("httpReadiness.succeeded", {
117 baseUrl: input.baseUrl,
118 requestUrl,
119 attempts: attempt,
120 }),
121 // The enclosing operation owns the span for this inline handler.
122 onNone: Effect.fnUntraced(function* () {
123 const lastFailure = yield* Ref.get(lastProbeFailure);
124 yield* Effect.logWarning("httpReadiness.timedOut", {
125 baseUrl: input.baseUrl,
126 requestUrl,
127 timeoutMs,
128 intervalMs,
129 probeTimeoutMs,
130 attempts: attempt,
131 lastFailure,
132 });
133 return yield* Effect.fail(
134 fail({
135 kind: "overall-timeout",
136 baseUrl: input.baseUrl,
137 timeoutMs,
138 lastFailure,
139 }),
140 );
141 }),
142 });
143});