import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; /** * Normalizes an arbitrary readiness probe failure into a plain, structured value * suitable for diagnostic logging. Preserves the tagged-error `_tag` (and * message/cause) shape for Effect tagged errors while recursing through nested * `cause`/`reason` chains. */ export function describeReadinessCause(cause: unknown): unknown { if (cause instanceof Error) { const tag = (cause as { readonly _tag?: unknown })._tag; const nested = (cause as { readonly cause?: unknown }).cause; return { ...(typeof tag === "string" ? { _tag: tag } : { name: cause.name }), message: cause.message, ...(nested === undefined ? {} : { cause: describeReadinessCause(nested) }), }; } if (typeof cause !== "object" || cause === null) { return cause; } const record = cause as Readonly>; return { ...(typeof record._tag === "string" ? { _tag: record._tag } : {}), ...(typeof record.message === "string" ? { message: record.message } : {}), ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }), ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }), }; } /** * Generic HTTP readiness probe shared by the SSH tunnel and the desktop backend * manager. Polls `baseUrl + path` until it returns a 2xx response or the overall * `timeoutMs` elapses. Each individual probe is bounded by `probeTimeoutMs` so a * single hung request cannot stall the retry loop, and the retry cadence is * `intervalMs` bounded to roughly `timeoutMs / intervalMs` attempts. * * The error type is left to the caller via `makeError`, so each consumer keeps * its own tagged error. `makeError` is called at every failure site; callers can * inspect `cause` (which carries a `kind` discriminator for the probe-timeout and * overall-timeout cases) to reproduce phase-specific messages, or ignore it. */ export const waitForHttpReady = Effect.fn("shared.httpReadiness.waitForHttpReady")(function* < E, >(input: { readonly baseUrl: string; readonly path?: string; readonly timeoutMs?: number; readonly intervalMs?: number; readonly probeTimeoutMs?: number; readonly makeError: (info: { readonly requestUrl: string; readonly probeTimeoutMs: number; readonly attempt: number; readonly cause: unknown; }) => E; }): Effect.fn.Return { const timeoutMs = input.timeoutMs ?? 30_000; const intervalMs = input.intervalMs ?? 100; const probeTimeoutMs = input.probeTimeoutMs ?? DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS; const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe( Schedule.upTo({ times: Math.max(0, Math.ceil(timeoutMs / intervalMs)) }), ); const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); const client = yield* HttpClient.HttpClient; const lastProbeFailure = yield* Ref.make(null); let attempt = 0; // Tracks errors this function itself produced via `makeError`, so the // pass-through guards below never double-wrap an already-constructed error // (mirrors the SSH original's `cause instanceof SshReadinessError` checks). const makeError = input.makeError; const madeErrors = new WeakSet(); const fail = (cause: unknown): E => { const error = makeError({ requestUrl, probeTimeoutMs, attempt, cause }); if (typeof error === "object" && error !== null) { madeErrors.add(error); } return error; }; const isMadeError = (value: unknown): value is E => typeof value === "object" && value !== null && madeErrors.has(value); yield* Effect.logDebug("httpReadiness.start", { baseUrl: input.baseUrl, requestUrl, timeoutMs, intervalMs, probeTimeoutMs, }); const readinessClient = client.pipe( HttpClient.filterStatusOk, HttpClient.transform((effect) => Effect.gen(function* () { attempt += 1; const responseOption = yield* effect.pipe( Effect.timeoutOption(Duration.millis(probeTimeoutMs)), Effect.mapError((cause) => fail(cause)), ); return yield* Option.match(responseOption, { onSome: Effect.succeed, onNone: () => Effect.fail( fail({ kind: "probe-timeout", attempt, probeTimeoutMs, }), ), }); }).pipe( Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))), Effect.tapError((cause) => Ref.set(lastProbeFailure, { attempt, cause: describeReadinessCause(cause), }), ), ), ), HttpClient.tap((response) => response.text.pipe(Effect.ignore)), HttpClient.retry(retryPolicy), ); const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe( Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))), Effect.timeoutOption(Duration.millis(timeoutMs)), ); return yield* Option.match(result, { onSome: () => Effect.logDebug("httpReadiness.succeeded", { baseUrl: input.baseUrl, requestUrl, attempts: attempt, }), onNone: () => Effect.gen(function* () { const lastFailure = yield* Ref.get(lastProbeFailure); yield* Effect.logWarning("httpReadiness.timedOut", { baseUrl: input.baseUrl, requestUrl, timeoutMs, intervalMs, probeTimeoutMs, attempts: attempt, lastFailure, }); return yield* Effect.fail( fail({ kind: "overall-timeout", baseUrl: input.baseUrl, timeoutMs, lastFailure, }), ); }), }); });