diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index bd844624d8b6..3f0bd82d5888 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,8 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { @@ -144,4 +147,91 @@ describe("DesktopLocalEnvironmentAuth", () => { ); }).pipe(Effect.provide(TestClock.layer())), ); + + const tokenResponse = (request: HttpClientRequest.HttpClientRequest) => + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Windows"), + currentConfig: Effect.succeedSome(config), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + // Answers the first `failures` exchanges with `failure`, then with a token. + const makeExchange = (failures: number, failure: () => Response) => + Effect.gen(function* () { + const requestCount = yield* Ref.make(0); + const layerHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Ref.updateAndGet(requestCount, (count) => count + 1).pipe( + Effect.map((count) => + count <= failures + ? HttpClientResponse.fromWeb(request, failure()) + : tokenResponse(request), + ), + ), + ), + ); + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(layerPool, layerHttpClient)), + ), + ), + ); + return { auth, requestCount }; + }); + + it.effect("retries a backend that is still starting", () => + Effect.gen(function* () { + const { auth, requestCount } = yield* makeExchange( + 2, + () => new Response("", { status: 503 }), + ); + + const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(1)); + + assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token"); + assert.strictEqual(yield* Ref.get(requestCount), 3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not retry a rejected bootstrap credential", () => + Effect.gen(function* () { + const { auth, requestCount } = yield* makeExchange(1, () => + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-1", + }, + { status: 401 }, + ), + ); + + const fiber = yield* auth.getBearerToken.pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(1)); + const error = yield* Fiber.join(fiber); + + assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + assert.strictEqual(yield* Ref.get(requestCount), 1); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 51109f8a4ecf..d137d502e6c5 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -1,18 +1,41 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; +import type { RemoteEnvironmentRequestError } from "@t3tools/client-runtime/rpc"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as HttpClient from "effect/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; +// A loopback /oauth/token exchange can fail transiently while the backend +// settles (a 502-504, a refused or reset connection, a timeout). Retry those +// for as long as the renderer's own bootstrap does; a rejected credential or a +// server error is final. +const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); +const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); +const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); + +const isTransientBearerBootstrapError = (error: RemoteEnvironmentRequestError): boolean => { + switch (error._tag) { + case "RemoteEnvironmentAuthFetchError": + case "RemoteEnvironmentAuthTimeoutError": + return true; + case "RemoteEnvironmentAuthUndeclaredStatusError": + return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); + default: + return false; + } +}; + export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedError()( "DesktopLocalEnvironmentAuthBackendNotConfiguredError", {}, @@ -86,6 +109,12 @@ export const make = Effect.gen(function* () { }, }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.retry({ + while: isTransientBearerBootstrapError, + schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL), + }), + // Bounds the attempts and any request still in flight at the deadline. + Effect.timeout(BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT), Effect.mapError( (cause) => new DesktopLocalEnvironmentAuthSessionBootstrapError({