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:
nyk 2026-03-19 23:59:02 +07:00 committed by GitHub
parent 465cd96107
commit 69e89a97a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 148 additions and 16 deletions

View File

@ -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 (

View File

@ -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')
})
})

53
src/lib/setup-status.ts Normal file
View File

@ -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'))
}