authorgravatar for 8172873+jamesvillarrubia@users.noreply.github.comJames Villarrubia <8172873+jamesvillarrubia@users.noreply.github.com> 2026-10-06 03:09:22-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 00:09:22-07:00
log76d3c96fd5b89cca2c06aeaceaea16100c75084a
tree58f7dcca6b955af772228a71ba4ed5be0af68f18
parent4ae976dbae39b3e80243b864a8b61da00f642dc4
signature Signed by PGP key B5690EEEBB952194

fix(desktop): retry transient bearer bootstrap and degrade on session fetch failure (#12919)


2 files changed, 119 insertions(+), 0 deletions(-)

apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts+90
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
3import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
4import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
5import * as HttpClient from "effect/http/HttpClient";7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
6import * as HttpClientResponse from "effect/http/HttpClientResponse";9import * as HttpClientResponse from "effect/http/HttpClientResponse";
7import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";10import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
8import {11import {
...@@ -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 @@
1import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";1import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
2import type { RemoteEnvironmentRequestError } from "@t3tools/client-runtime/rpc";
2import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";3import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
3import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";4import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
4import * as Clock from "effect/Clock";5import * as Clock from "effect/Clock";
5import * as Context from "effect/Context";6import * as Context from "effect/Context";
7import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";10import * as Option from "effect/Option";
9import * as Ref from "effect/Ref";11import * as Ref from "effect/Ref";
12import * as Schedule from "effect/Schedule";
10import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
11import * as Semaphore from "effect/Semaphore";14import * as Semaphore from "effect/Semaphore";
12import * as HttpClient from "effect/http/HttpClient";15import * as HttpClient from "effect/http/HttpClient";
1316
14import * as DesktopBackendPool from "./DesktopBackendPool.ts";17import * as DesktopBackendPool from "./DesktopBackendPool.ts";
1518
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.
23const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15);
24const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500);
25const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]);
26
27const 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
16export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedError<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()(39export 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({