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 { useTranslations } from 'next-intl'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { LanguageSwitcherSelect } from '@/components/ui/language-switcher'
|
import { LanguageSwitcherSelect } from '@/components/ui/language-switcher'
|
||||||
|
import { fetchSetupStatusWithRetry } from '@/lib/setup-status'
|
||||||
|
|
||||||
type SetupStep = 'form' | 'creating'
|
type SetupStep = 'form' | 'creating'
|
||||||
|
|
||||||
|
|
@ -13,6 +14,9 @@ interface ProgressStep {
|
||||||
status: 'pending' | 'active' | 'done' | 'error'
|
status: 'pending' | 'active' | 'done' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SETUP_STATUS_TIMEOUT_MS = 5000
|
||||||
|
const SETUP_STATUS_ATTEMPTS = 3
|
||||||
|
|
||||||
function getInitialProgress(t: (key: string) => string): ProgressStep[] {
|
function getInitialProgress(t: (key: string) => string): ProgressStep[] {
|
||||||
return [
|
return [
|
||||||
{ label: t('validatingCredentials'), status: 'pending' },
|
{ label: t('validatingCredentials'), status: 'pending' },
|
||||||
|
|
@ -72,24 +76,36 @@ export default function SetupPage() {
|
||||||
const [progress, setProgress] = useState<ProgressStep[]>(() => getInitialProgress(t))
|
const [progress, setProgress] = useState<ProgressStep[]>(() => getInitialProgress(t))
|
||||||
const [checking, setChecking] = useState(true)
|
const [checking, setChecking] = useState(true)
|
||||||
const [setupAvailable, setSetupAvailable] = useState(false)
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/setup')
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!data.needsSetup) {
|
if (!data.needsSetup) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSetupAvailable(true)
|
setSetupAvailable(true)
|
||||||
setChecking(false)
|
setChecking(false)
|
||||||
})
|
} catch {
|
||||||
.catch(() => {
|
setSetupAvailable(false)
|
||||||
setError(t('failedToCheckSetup'))
|
setError(t('failedToCheckSetup'))
|
||||||
setChecking(false)
|
setChecking(false)
|
||||||
})
|
}
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSetupStatus()
|
||||||
|
}, [checkSetupStatus, setupCheckTick])
|
||||||
|
|
||||||
const updateProgress = useCallback((index: number, status: ProgressStep['status']) => {
|
const updateProgress = useCallback((index: number, status: ProgressStep['status']) => {
|
||||||
setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s)))
|
setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -174,7 +190,21 @@ export default function SetupPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!setupAvailable) {
|
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 (
|
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