@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.
package/app/stores/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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 ?? []
|
package/app/stores/workspace.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|