fix: add timeout/retry for setup status check (#457)
- add bounded timeout+retry helper for /api/setup checks - show actionable setup error state with Retry button - avoid blank-screen fallback when setup status check fails - add unit tests for retry helper Fixes #456
This commit is contained in:
parent
465cd96107
commit
69e89a97a1
|
|
@ -5,6 +5,7 @@ import Image from 'next/image'
|
|||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LanguageSwitcherSelect } from '@/components/ui/language-switcher'
|
||||
import { fetchSetupStatusWithRetry } from '@/lib/setup-status'
|
||||
|
||||
type SetupStep = 'form' | 'creating'
|
||||
|
||||
|
|
@ -13,6 +14,9 @@ interface ProgressStep {
|
|||
status: 'pending' | 'active' | 'done' | 'error'
|
||||
}
|
||||
|
||||
const SETUP_STATUS_TIMEOUT_MS = 5000
|
||||
const SETUP_STATUS_ATTEMPTS = 3
|
||||
|
||||
function getInitialProgress(t: (key: string) => string): ProgressStep[] {
|
||||
return [
|
||||
{ label: t('validatingCredentials'), status: 'pending' },
|
||||
|
|
@ -72,23 +76,35 @@ export default function SetupPage() {
|
|||
const [progress, setProgress] = useState<ProgressStep[]>(() => getInitialProgress(t))
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [setupAvailable, setSetupAvailable] = useState(false)
|
||||
const [setupCheckTick, setSetupCheckTick] = useState(0)
|
||||
|
||||
const checkSetupStatus = useCallback(async () => {
|
||||
setChecking(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await fetchSetupStatusWithRetry(fetch, {
|
||||
attempts: SETUP_STATUS_ATTEMPTS,
|
||||
timeoutMs: SETUP_STATUS_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
if (!data.needsSetup) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
setSetupAvailable(true)
|
||||
setChecking(false)
|
||||
} catch {
|
||||
setSetupAvailable(false)
|
||||
setError(t('failedToCheckSetup'))
|
||||
setChecking(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/setup')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.needsSetup) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
setSetupAvailable(true)
|
||||
setChecking(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t('failedToCheckSetup'))
|
||||
setChecking(false)
|
||||
})
|
||||
}, [t])
|
||||
checkSetupStatus()
|
||||
}, [checkSetupStatus, setupCheckTick])
|
||||
|
||||
const updateProgress = useCallback((index: number, status: ProgressStep['status']) => {
|
||||
setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s)))
|
||||
|
|
@ -174,7 +190,21 @@ export default function SetupPage() {
|
|||
}
|
||||
|
||||
if (!setupAvailable) {
|
||||
return null
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm rounded-lg border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive space-y-3">
|
||||
<p>{error || t('failedToCheckSetup')}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setSetupCheckTick((v) => v + 1)}
|
||||
className="w-full"
|
||||
>
|
||||
{tc('retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { fetchSetupStatusWithRetry } from '@/lib/setup-status'
|
||||
|
||||
describe('fetchSetupStatusWithRetry', () => {
|
||||
it('returns setup status on success', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: true }),
|
||||
})
|
||||
|
||||
const result = await fetchSetupStatusWithRetry(fetchMock as any, { attempts: 2, timeoutMs: 2000 })
|
||||
|
||||
expect(result).toEqual({ needsSetup: true })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries on failure and eventually succeeds', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
})
|
||||
|
||||
const result = await fetchSetupStatusWithRetry(fetchMock as any, { attempts: 3, timeoutMs: 2000 })
|
||||
|
||||
expect(result).toEqual({ needsSetup: false })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws after attempts are exhausted', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('still failing'))
|
||||
|
||||
await expect(fetchSetupStatusWithRetry(fetchMock as any, { attempts: 2, timeoutMs: 2000 }))
|
||||
.rejects.toThrow('still failing')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws on invalid payload shape', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: 'yes' }),
|
||||
})
|
||||
|
||||
await expect(fetchSetupStatusWithRetry(fetchMock as any, { attempts: 1, timeoutMs: 2000 }))
|
||||
.rejects.toThrow('Invalid setup status response')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
export interface FetchSetupStatusOptions {
|
||||
attempts?: number
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface SetupStatusResponse {
|
||||
needsSetup: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch setup status with timeout + bounded retries.
|
||||
*/
|
||||
export async function fetchSetupStatusWithRetry(
|
||||
fetchFn: typeof fetch,
|
||||
options: FetchSetupStatusOptions = {}
|
||||
): Promise<SetupStatusResponse> {
|
||||
const attempts = Math.max(1, options.attempts ?? 3)
|
||||
const timeoutMs = Math.max(1000, options.timeoutMs ?? 5000)
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
|
||||
|
||||
try {
|
||||
const res = await fetchFn('/api/setup', { signal: controller.signal })
|
||||
if (!res.ok) {
|
||||
throw new Error(`Setup status check failed (${res.status})`)
|
||||
}
|
||||
const data = await res.json() as SetupStatusResponse
|
||||
if (typeof data?.needsSetup !== 'boolean') {
|
||||
throw new Error('Invalid setup status response')
|
||||
}
|
||||
return data
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
lastError = new Error('Setup status request timed out')
|
||||
} else {
|
||||
lastError = error instanceof Error ? error : new Error('Setup status request failed')
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
// tiny backoff to avoid immediate hammering during transient stalls
|
||||
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
throw (lastError ?? new Error('Failed to check setup status'))
|
||||
}
|
||||
Loading…
Reference in New Issue