| author | |
| committer | |
| log | 76d3c96fd5b89cca2c06aeaceaea16100c75084a |
| tree | 58f7dcca6b955af772228a71ba4ed5be0af68f18 |
| parent | 4ae976dbae39b3e80243b864a8b61da00f642dc4 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 119 insertions(+), 0 deletions(-)
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts+90| ... | @@ -1,8 +1,11 @@ | ... | @@ -1,8 +1,11 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Duration from "effect/Duration"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 3 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 4 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 5 | import * as HttpClient from "effect/http/HttpClient"; | 7 | import * as HttpClient from "effect/http/HttpClient"; |
| 8 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | ||
| 6 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | 9 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 7 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | 10 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 8 | import { | 11 | import { |
| ... | @@ -144,4 +147,91 @@ describe("DesktopLocalEnvironmentAuth", () => { | ... | @@ -144,4 +147,91 @@ describe("DesktopLocalEnvironmentAuth", () => { |
| 144 | ); | 147 | ); |
| 145 | }).pipe(Effect.provide(TestClock.layer())), | 148 | }).pipe(Effect.provide(TestClock.layer())), |
| 146 | ); | 149 | ); |
| 150 | |||
| 151 | const tokenResponse = (request: HttpClientRequest.HttpClientRequest) => | ||
| 152 | HttpClientResponse.fromWeb( | ||
| 153 | request, | ||
| 154 | new Response( | ||
| 155 | JSON.stringify({ | ||
| 156 | access_token: "desktop-bearer-token", | ||
| 157 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 158 | token_type: "Bearer", | ||
| 159 | expires_in: 3600, | ||
| 160 | scope: "orchestration:read", | ||
| 161 | }), | ||
| 162 | { status: 200, headers: { "content-type": "application/json" } }, | ||
| 163 | ), | ||
| 164 | ); | ||
| 165 | const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { | ||
| 166 | list: Effect.succeed([ | ||
| 167 | { | ||
| 168 | id: PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 169 | label: Effect.succeed("Windows"), | ||
| 170 | currentConfig: Effect.succeedSome(config), | ||
| 171 | }, | ||
| 172 | ]), | ||
| 173 | } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); | ||
| 174 | // Answers the first `failures` exchanges with `failure`, then with a token. | ||
| 175 | const makeExchange = (failures: number, failure: () => Response) => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const requestCount = yield* Ref.make(0); | ||
| 178 | const layerHttpClient = Layer.succeed( | ||
| 179 | HttpClient.HttpClient, | ||
| 180 | HttpClient.make((request) => | ||
| 181 | Ref.updateAndGet(requestCount, (count) => count + 1).pipe( | ||
| 182 | Effect.map((count) => | ||
| 183 | count <= failures | ||
| 184 | ? HttpClientResponse.fromWeb(request, failure()) | ||
| 185 | : tokenResponse(request), | ||
| 186 | ), | ||
| 187 | ), | ||
| 188 | ), | ||
| 189 | ); | ||
| 190 | const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( | ||
| 191 | Effect.provide( | ||
| 192 | DesktopLocalEnvironmentAuth.layer.pipe( | ||
| 193 | Layer.provide(Layer.mergeAll(layerPool, layerHttpClient)), | ||
| 194 | ), | ||
| 195 | ), | ||
| 196 | ); | ||
| 197 | return { auth, requestCount }; | ||
| 198 | }); | ||
| 199 | |||
| 200 | it.effect("retries a backend that is still starting", () => | ||
| 201 | Effect.gen(function* () { | ||
| 202 | const { auth, requestCount } = yield* makeExchange( | ||
| 203 | 2, | ||
| 204 | () => new Response("", { status: 503 }), | ||
| 205 | ); | ||
| 206 | |||
| 207 | const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); | ||
| 208 | yield* TestClock.adjust(Duration.seconds(1)); | ||
| 209 | |||
| 210 | assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token"); | ||
| 211 | assert.strictEqual(yield* Ref.get(requestCount), 3); | ||
| 212 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 213 | ); | ||
| 214 | |||
| 215 | it.effect("does not retry a rejected bootstrap credential", () => | ||
| 216 | Effect.gen(function* () { | ||
| 217 | const { auth, requestCount } = yield* makeExchange(1, () => | ||
| 218 | Response.json( | ||
| 219 | { | ||
| 220 | _tag: "EnvironmentAuthInvalidError", | ||
| 221 | code: "auth_invalid", | ||
| 222 | reason: "invalid_credential", | ||
| 223 | traceId: "trace-1", | ||
| 224 | }, | ||
| 225 | { status: 401 }, | ||
| 226 | ), | ||
| 227 | ); | ||
| 228 | |||
| 229 | const fiber = yield* auth.getBearerToken.pipe(Effect.flip, Effect.forkChild); | ||
| 230 | yield* TestClock.adjust(Duration.seconds(1)); | ||
| 231 | const error = yield* Fiber.join(fiber); | ||
| 232 | |||
| 233 | assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); | ||
| 234 | assert.strictEqual(yield* Ref.get(requestCount), 1); | ||
| 235 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 236 | ); | ||
| 147 | }); | 237 | }); |
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+29| ... | @@ -1,18 +1,41 @@ | ... | @@ -1,18 +1,41 @@ |
| 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; | 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; |
| 2 | import type { RemoteEnvironmentRequestError } from "@t3tools/client-runtime/rpc"; | ||
| 2 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | 3 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 3 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; | 4 | import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; |
| 4 | import * as Clock from "effect/Clock"; | 5 | import * as Clock from "effect/Clock"; |
| 5 | import * as Context from "effect/Context"; | 6 | import * as Context from "effect/Context"; |
| 7 | import * as Duration from "effect/Duration"; | ||
| 6 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 9 | import * as Ref from "effect/Ref"; | 11 | import * as Ref from "effect/Ref"; |
| 12 | import * as Schedule from "effect/Schedule"; | ||
| 10 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 11 | import * as Semaphore from "effect/Semaphore"; | 14 | import * as Semaphore from "effect/Semaphore"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; | 15 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | 16 | ||
| 14 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; | 17 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; |
| 15 | 18 | ||
| 19 | // A loopback /oauth/token exchange can fail transiently while the backend | ||
| 20 | // settles (a 502-504, a refused or reset connection, a timeout). Retry those | ||
| 21 | // for as long as the renderer's own bootstrap does; a rejected credential or a | ||
| 22 | // server error is final. | ||
| 23 | const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); | ||
| 24 | const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); | ||
| 25 | const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); | ||
| 26 | |||
| 27 | const isTransientBearerBootstrapError = (error: RemoteEnvironmentRequestError): boolean => { | ||
| 28 | switch (error._tag) { | ||
| 29 | case "RemoteEnvironmentAuthFetchError": | ||
| 30 | case "RemoteEnvironmentAuthTimeoutError": | ||
| 31 | return true; | ||
| 32 | case "RemoteEnvironmentAuthUndeclaredStatusError": | ||
| 33 | return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); | ||
| 34 | default: | ||
| 35 | return false; | ||
| 36 | } | ||
| 37 | }; | ||
| 38 | |||
| 16 | export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedError<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()( | 39 | export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedError<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()( |
| 17 | "DesktopLocalEnvironmentAuthBackendNotConfiguredError", | 40 | "DesktopLocalEnvironmentAuthBackendNotConfiguredError", |
| 18 | {}, | 41 | {}, |
| ... | @@ -86,6 +109,12 @@ export const make = Effect.gen(function* () { | ... | @@ -86,6 +109,12 @@ export const make = Effect.gen(function* () { |
| 86 | }, | 109 | }, |
| 87 | }).pipe( | 110 | }).pipe( |
| 88 | Effect.provideService(HttpClient.HttpClient, httpClient), | 111 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 112 | Effect.retry({ | ||
| 113 | while: isTransientBearerBootstrapError, | ||
| 114 | schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL), | ||
| 115 | }), | ||
| 116 | // Bounds the attempts and any request still in flight at the deadline. | ||
| 117 | Effect.timeout(BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT), | ||
| 89 | Effect.mapError( | 118 | Effect.mapError( |
| 90 | (cause) => | 119 | (cause) => |
| 91 | new DesktopLocalEnvironmentAuthSessionBootstrapError({ | 120 | new DesktopLocalEnvironmentAuthSessionBootstrapError({ |