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
10/**
11 * Normalizes an arbitrary readiness probe failure into a plain, structured value
12 * suitable for diagnostic logging. Preserves the tagged-error `_tag` (and
13 * message/cause) shape for Effect tagged errors while recursing through nested
14 * `cause`/`reason` chains.
15 */
16export function describeReadinessCause(cause: unknown): unknown {
17 if (cause instanceof Error) {
18 const tag = (cause as { readonly _tag?: unknown })._tag;
19 const nested = (cause as { readonly cause?: unknown }).cause;
20 return {
21 ...(typeof tag === "string" ? { _tag: tag } : { name: cause.name }),
22 message: cause.message,
23 ...(nested === undefined ? {} : { cause: describeReadinessCause(nested) }),
24 };
25 }
26 if (typeof cause !== "object" || cause === null) {
27 return cause;
28 }
29
30 const record = cause as Readonly<Record<string, unknown>>;
31 return {
32 ...(typeof record._tag === "string" ? { _tag: record._tag } : {}),
33 ...(typeof record.message === "string" ? { message: record.message } : {}),
34 ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }),
35 ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }),
36 };
37}
38
39/**
40 * Generic HTTP readiness probe shared by the SSH tunnel and the desktop backend
41 * manager. Polls `baseUrl + path` until it returns a 2xx response or the overall
42 * `timeoutMs` elapses. Each individual probe is bounded by `probeTimeoutMs` so a
43 * single hung request cannot stall the retry loop, and the retry cadence is
44 * `intervalMs` bounded to roughly `timeoutMs / intervalMs` attempts.
45 *
46 * The error type is left to the caller via `makeError`, so each consumer keeps
47 * its own tagged error. `makeError` is called at every failure site; callers can
48 * inspect `cause` (which carries a `kind` discriminator for the probe-timeout and
49 * overall-timeout cases) to reproduce phase-specific messages, or ignore it.
50 */
51export const waitForHttpReady = Effect.fn("shared.httpReadiness.waitForHttpReady")(function* <
52 E,
53>(input: {
54 readonly baseUrl: string;
55 readonly path?: string;
56 readonly timeoutMs?: number;
57 readonly intervalMs?: number;
58 readonly probeTimeoutMs?: number;
59 readonly makeError: (info: {
60 readonly requestUrl: string;
61 readonly probeTimeoutMs: number;
62 readonly attempt: number;
63 readonly cause: unknown;
64 }) => E;
65}): Effect.fn.Return<void, E, HttpClient.HttpClient> {
66 const timeoutMs = input.timeoutMs ?? 30_000;
67 const intervalMs = input.intervalMs ?? 100;
68 const probeTimeoutMs = input.probeTimeoutMs ?? DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS;
69 const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe(
70 Schedule.upTo({ times: Math.max(0, Math.ceil(timeoutMs / intervalMs)) }),
71 );
72 const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString();
73 const client = yield* HttpClient.HttpClient;
74 const lastProbeFailure = yield* Ref.make<unknown>(null);
75 let attempt = 0;
76
77 // Tracks errors this function itself produced via `makeError`, so the
78 // pass-through guards below never double-wrap an already-constructed error
79 // (mirrors the SSH original's `cause instanceof SshReadinessError` checks).
80 const makeError = input.makeError;
81 const madeErrors = new WeakSet<object>();
82 const fail = (cause: unknown): E => {
83 const error = makeError({ requestUrl, probeTimeoutMs, attempt, cause });
84 if (typeof error === "object" && error !== null) {
85 madeErrors.add(error);
86 }
87 return error;
88 };
89 const isMadeError = (value: unknown): value is E =>
90 typeof value === "object" && value !== null && madeErrors.has(value);
91
92 yield* Effect.logDebug("httpReadiness.start", {
93 baseUrl: input.baseUrl,
94 requestUrl,
95 timeoutMs,
96 intervalMs,
97 probeTimeoutMs,
98 });
99
100 const readinessClient = client.pipe(
101 HttpClient.filterStatusOk,
102 HttpClient.transform((effect) =>
103 Effect.gen(function* () {
104 attempt += 1;
105 const responseOption = yield* effect.pipe(
106 Effect.timeoutOption(Duration.millis(probeTimeoutMs)),
107 Effect.mapError((cause) => fail(cause)),
108 );
109 return yield* Effect.fromOption(responseOption, () =>
110 fail({
111 kind: "probe-timeout",
112 attempt,
113 probeTimeoutMs,
114 }),
115 );
116 }).pipe(
117 Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))),
118 Effect.tapError((cause) =>
119 Ref.set(lastProbeFailure, {
120 attempt,
121 cause: describeReadinessCause(cause),
122 }),
123 ),
124 ),
125 ),
126 HttpClient.tap((response) => response.text.pipe(Effect.ignore)),
127 HttpClient.retry(retryPolicy),
128 );
129
130 const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe(
131 Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))),
132 Effect.timeoutOption(Duration.millis(timeoutMs)),
133 );
134
135 return yield* Option.match(result, {
136 onSome: () =>
137 Effect.logDebug("httpReadiness.succeeded", {
138 baseUrl: input.baseUrl,
139 requestUrl,
140 attempts: attempt,
141 }),
142 onNone: () =>
143 Effect.gen(function* () {
144 const lastFailure = yield* Ref.get(lastProbeFailure);
145 yield* Effect.logWarning("httpReadiness.timedOut", {
146 baseUrl: input.baseUrl,
147 requestUrl,
148 timeoutMs,
149 intervalMs,
150 probeTimeoutMs,
151 attempts: attempt,
152 lastFailure,
153 });
154 return yield* Effect.fail(
155 fail({
156 kind: "overall-timeout",
157 baseUrl: input.baseUrl,
158 timeoutMs,
159 lastFailure,
160 }),
161 );
162 }),
163 });
164});