@cat-factory/app 0.136.0 → 0.136.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import type {
6
6
  import { defineStore } from 'pinia'
7
7
  import { computed, ref } from 'vue'
8
8
  import type { AuthUser } from '~/types/domain'
9
+ import { retryWhileBackendUnreachable } from '~/utils/backendReady'
9
10
 
10
11
  /**
11
12
  * "Login with GitHub" session state. The backend mints a signed session token
@@ -184,7 +185,10 @@ export const useAuthStore = defineStore(
184
185
  // which must be exchanged — not stored as a local token by `consumeRedirectToken`).
185
186
  if (!(await maybeConnectMothership())) consumeRedirectToken()
186
187
  try {
187
- const config = await api.getAuthConfig()
188
+ // Tolerate a cold-start race: when the SPA and backend boot together, this first call
189
+ // can beat the backend's listener by a second or two. Retry a not-listening-yet socket
190
+ // (the gate keeps showing its spinner) instead of degrading to the unreachable screen.
191
+ const config = await retryWhileBackendUnreachable(() => api.getAuthConfig())
188
192
  required.value = config.enabled
189
193
  if (config.providers) providers.value = config.providers
190
194
  patProviders.value = config.patLogin?.providers ?? []
@@ -35,6 +35,7 @@ import { useGitHubStore } from '~/stores/github'
35
35
  import { useFragmentsStore } from '~/stores/fragments'
36
36
  import { useProviderConnectionsStore } from '~/stores/providerConnections'
37
37
  import { markBoot } from '~/utils/bootMarks'
38
+ import { retryWhileBackendUnreachable } from '~/utils/backendReady'
38
39
 
39
40
  /**
40
41
  * Owns the active workspace and bootstraps the app against the backend. On load
@@ -207,7 +208,10 @@ export const useWorkspaceStore = defineStore(
207
208
  useAccountsStore()
208
209
  .load()
209
210
  .catch(() => {}),
210
- api.listWorkspaces(),
211
+ // Retry a not-listening-yet backend (cold-start race) before surfacing the
212
+ // unreachable screen. This gates the rest of init, so once it resolves the
213
+ // backend is up and the speculative/follow-up snapshot fetches succeed too.
214
+ retryWhileBackendUnreachable(() => api.listWorkspaces()),
211
215
  ])
212
216
  markBoot('workspaces-listed')
213
217
  workspaces.value = workspaceList
@@ -0,0 +1,69 @@
1
+ import { SchemaValidationError } from '@toad-contracts/core'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { ApiError } from '~/composables/api/errors'
4
+ import { isBackendUnreachable, retryWhileBackendUnreachable } from '~/utils/backendReady'
5
+
6
+ // The cold-start race: the SPA's first fetch can beat the backend's listener, throwing a
7
+ // status-less network fault. These lock in that we wait THAT out (with a deadline) but
8
+ // surface a real HTTP error response — a live-but-erroring backend — immediately.
9
+
10
+ describe('isBackendUnreachable', () => {
11
+ it('is true for a status-less network fault', () => {
12
+ expect(isBackendUnreachable(new Error('Failed to fetch'))).toBe(true)
13
+ })
14
+
15
+ it('is false for an HTTP error response (the backend answered)', () => {
16
+ expect(isBackendUnreachable(new ApiError(500, {}))).toBe(false)
17
+ expect(isBackendUnreachable({ status: 503 })).toBe(false)
18
+ })
19
+
20
+ it('is false for a schema-validation failure (a deterministic answer, not a dead socket)', () => {
21
+ // The backend answered but its body (or our request) didn't match the contract — retrying
22
+ // can never clear it, so it must surface at once rather than wait out the deadline.
23
+ expect(isBackendUnreachable(new SchemaValidationError([]))).toBe(false)
24
+ })
25
+ })
26
+
27
+ describe('retryWhileBackendUnreachable', () => {
28
+ afterEach(() => vi.useRealTimers())
29
+
30
+ it('returns the first result without waiting when the backend answers', async () => {
31
+ const fn = vi.fn(async () => 'ok')
32
+ await expect(retryWhileBackendUnreachable(fn)).resolves.toBe('ok')
33
+ expect(fn).toHaveBeenCalledTimes(1)
34
+ })
35
+
36
+ it('retries a not-listening-yet backend, then succeeds', async () => {
37
+ vi.useFakeTimers()
38
+ let calls = 0
39
+ const fn = vi.fn(async () => {
40
+ if (++calls < 3) throw new Error('connection refused') // status-less
41
+ return 'ok'
42
+ })
43
+ const promise = retryWhileBackendUnreachable(fn)
44
+ await vi.advanceTimersByTimeAsync(10_000)
45
+ await expect(promise).resolves.toBe('ok')
46
+ expect(fn).toHaveBeenCalledTimes(3)
47
+ })
48
+
49
+ it('rethrows an HTTP error response immediately (no retry)', async () => {
50
+ const fn = vi.fn(async () => {
51
+ throw new ApiError(500, {})
52
+ })
53
+ await expect(retryWhileBackendUnreachable(fn)).rejects.toBeInstanceOf(ApiError)
54
+ expect(fn).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it('gives up and rethrows the last fault once the deadline passes', async () => {
58
+ vi.useFakeTimers()
59
+ const fn = vi.fn(async () => {
60
+ throw new Error('down')
61
+ })
62
+ const rejects = expect(retryWhileBackendUnreachable(fn, { deadlineMs: 1_000 })).rejects.toThrow(
63
+ 'down',
64
+ )
65
+ await vi.advanceTimersByTimeAsync(2_000)
66
+ await rejects
67
+ expect(fn.mock.calls.length).toBeGreaterThan(1)
68
+ })
69
+ })
@@ -0,0 +1,54 @@
1
+ import { SchemaValidationError } from '@toad-contracts/core'
2
+ import { apiErrorStatus } from '~/composables/api/errors'
3
+
4
+ /**
5
+ * True only for a not-listening-yet backend — a connection-level fault (connection refused /
6
+ * reset / DNS) that throws before any HTTP round-trip completes, so it carries neither an HTTP
7
+ * status nor schema issues. Everything else is a real, deterministic answer that retrying can
8
+ * never clear, so it must surface at once, not loop:
9
+ *
10
+ * - An HTTP RESPONSE arrived — a declared non-2xx (`ApiError`) or an undeclared status
11
+ * (`UnexpectedResponseError`); both carry a `statusCode`, so `apiErrorStatus` returns it.
12
+ * - A `SchemaValidationError` — the backend answered but its body (or our own request) didn't
13
+ * match the contract, i.e. a version skew / client bug, NOT a dead socket.
14
+ */
15
+ export function isBackendUnreachable(error: unknown): boolean {
16
+ if (error instanceof SchemaValidationError) return false
17
+ return apiErrorStatus(error) === undefined
18
+ }
19
+
20
+ export interface BackendRetryOptions {
21
+ /** Total wall-clock budget before we give up and rethrow (default 15s). */
22
+ deadlineMs?: number
23
+ /** First backoff delay; doubles each attempt up to {@link BackendRetryOptions.maxDelayMs}. */
24
+ baseDelayMs?: number
25
+ /** Backoff ceiling (default 2s). */
26
+ maxDelayMs?: number
27
+ }
28
+
29
+ /**
30
+ * Run `fn`, retrying while the backend is merely not-listening-yet (see
31
+ * {@link isBackendUnreachable}) with exponential backoff, up to a wall-clock deadline.
32
+ *
33
+ * This exists for the cold-open race: when the frontend and backend are started together,
34
+ * the SPA's first fetch can beat the backend's listener by a second or two. A single attempt
35
+ * would strand the board on the "Can't reach the backend" screen until a manual reload, even
36
+ * though the backend comes up moments later. An HTTP error response (any status) is a real
37
+ * answer, so it rethrows immediately rather than looping — we only wait out a dead socket.
38
+ */
39
+ export async function retryWhileBackendUnreachable<T>(
40
+ fn: () => Promise<T>,
41
+ { deadlineMs = 15_000, baseDelayMs = 300, maxDelayMs = 2_000 }: BackendRetryOptions = {},
42
+ ): Promise<T> {
43
+ const start = Date.now()
44
+ for (let attempt = 0; ; attempt++) {
45
+ try {
46
+ return await fn()
47
+ } catch (error) {
48
+ const elapsed = Date.now() - start
49
+ if (!isBackendUnreachable(error) || elapsed >= deadlineMs) throw error
50
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt, deadlineMs - elapsed)
51
+ await new Promise<void>((resolve) => setTimeout(resolve, delay))
52
+ }
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.136.0",
3
+ "version": "0.136.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",