Keep a page error or a busy engine from blanking the app
A user on Upload got a white page while the engine's proxy answered many requests with nginx's rate-limit 503. - Pages render inside an error boundary, so a crash shows a message with Try again and Reload instead of unmounting the whole app. - A 503 or 429 without the engine's JSON reads "The engine is busy", and queries wait 5s, then 10s, before asking again instead of 1s. - Upload asks for knowledge details two at a time and reuses them until the document's knowledge status changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
19
src/api/client.test.ts
Normal file
19
src/api/client.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { limiter } from './client'
|
||||||
|
|
||||||
|
describe('limiter', () => {
|
||||||
|
it('runs at most n calls at once, all of them, failures included', async () => {
|
||||||
|
const limit = limiter(2)
|
||||||
|
let running = 0, peak = 0
|
||||||
|
const call = (n: number) => limit(async () => {
|
||||||
|
peak = Math.max(peak, ++running)
|
||||||
|
await new Promise(r => setTimeout(r, 5))
|
||||||
|
running--
|
||||||
|
if (n === 2) throw new Error('503')
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
const out = await Promise.allSettled([1, 2, 3, 4, 5].map(call))
|
||||||
|
expect(peak).toBe(2)
|
||||||
|
expect(out.map(r => (r.status === 'fulfilled' ? r.value : 'x'))).toEqual([1, 'x', 3, 4, 5])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,6 +65,8 @@ export async function request<T>(path: string, { json, headers, as = 'json', ...
|
|||||||
const data = await res.json().catch(() => null)
|
const data = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 502 || res.status === 504) throw new EngineError(t('err.unreachable'), res.status)
|
if (res.status === 502 || res.status === 504) throw new EngineError(t('err.unreachable'), res.status)
|
||||||
|
// The proxy's rate limit answers 503 (or 429) with an HTML page, not the engine's JSON.
|
||||||
|
if ((res.status === 503 || res.status === 429) && !data) throw new EngineError(t('err.busy'), res.status)
|
||||||
const err = new EngineError(detail(data?.detail), res.status, data && typeof data === 'object' ? data : null)
|
const err = new EngineError(detail(data?.detail), res.status, data && typeof data === 'object' ? data : null)
|
||||||
if (res.status === 402) err.message = creditMessage(err)
|
if (res.status === 402) err.message = creditMessage(err)
|
||||||
throw err
|
throw err
|
||||||
@@ -76,3 +78,21 @@ export async function request<T>(path: string, { json, headers, as = 'json', ...
|
|||||||
export const opKey = (prefix: string) => `lle-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
export const opKey = (prefix: string) => `lle-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
/** Runs the calls it is given at most `n` at a time, for requests a page makes one per row. */
|
||||||
|
export function limiter(n: number) {
|
||||||
|
let running = 0
|
||||||
|
const waiting: (() => void)[] = []
|
||||||
|
return async <T,>(fn: () => Promise<T>): Promise<T> => {
|
||||||
|
// A finished call hands its slot straight to the next waiting one, so none can slip in between.
|
||||||
|
if (running >= n) await new Promise<void>(r => waiting.push(r))
|
||||||
|
else running++
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
const next = waiting.shift()
|
||||||
|
if (next) next()
|
||||||
|
else running--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Typed engine calls used by Express Mode. Ported from prototype/engine.js.
|
// Typed engine calls used by Express Mode. Ported from prototype/engine.js.
|
||||||
import { EngineError, opKey, request, sleep } from './client'
|
import { EngineError, limiter, opKey, request, sleep } from './client'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
|
|
||||||
// ── Documents (Brain) ──
|
// ── Documents (Brain) ──
|
||||||
@@ -76,7 +76,10 @@ export interface KnowledgeDetail {
|
|||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
|
// The Upload page asks once per document waiting on an answer; two at a time keeps a long list
|
||||||
|
// from tripping the proxy's rate limit.
|
||||||
|
const twoAtATime = limiter(2)
|
||||||
|
export const knowledgeDetail = (id: number) => twoAtATime(() => request<KnowledgeDetail>(`/upload/${id}/knowledge`))
|
||||||
|
|
||||||
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
|
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
|
||||||
// Reads a failed document's stored file again, for free. A knowledge base it already has is kept.
|
// Reads a failed document's stored file again, for free. A knowledge base it already has is kept.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
|
|||||||
import { workspaceCredits } from '../api/engine'
|
import { workspaceCredits } from '../api/engine'
|
||||||
import { Brand, Icon, type IconName } from './Icon'
|
import { Brand, Icon, type IconName } from './Icon'
|
||||||
import { Toaster } from './Toast'
|
import { Toaster } from './Toast'
|
||||||
|
import { ErrorBoundary } from './ErrorBoundary'
|
||||||
|
|
||||||
// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in.
|
// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in.
|
||||||
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||||
@@ -208,7 +209,7 @@ export function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/* Keyed by route so each page plays the fade-in from theme.css. */}
|
{/* Keyed by route so each page plays the fade-in from theme.css. */}
|
||||||
<main tabIndex={-1} key={pathname}><Outlet /></main>
|
<main tabIndex={-1} key={pathname}><ErrorBoundary><Outlet /></ErrorBoundary></main>
|
||||||
<footer className="app-footer">Learning Lab Express <span>by BAWANA</span><span>{t('layout.footer')}</span></footer>
|
<footer className="app-footer">Learning Lab Express <span>by BAWANA</span><span>{t('layout.footer')}</span></footer>
|
||||||
</div>
|
</div>
|
||||||
{help && <HelpDialog onClose={() => setHelp(false)} />}
|
{help && <HelpDialog onClose={() => setHelp(false)} />}
|
||||||
|
|||||||
35
src/components/ErrorBoundary.tsx
Normal file
35
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import { Empty } from './Page'
|
||||||
|
|
||||||
|
// Without this, one component throwing while rendering unmounts the whole app and leaves a white
|
||||||
|
// page. The layout keys it by route, so moving to another page clears the error.
|
||||||
|
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||||
|
state = { error: null as Error | null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error) {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
console.error('Page crashed', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error } = this.state
|
||||||
|
if (!error) return this.props.children
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<Empty icon="info" title={t('err.crashTitle')}>
|
||||||
|
{t('err.crashCopy')}
|
||||||
|
<br />
|
||||||
|
<small>{error.message}</small>
|
||||||
|
</Empty>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, paddingBottom: 24 }}>
|
||||||
|
<button className="btn small" onClick={() => this.setState({ error: null })}>{t('common.retry')}</button>
|
||||||
|
<button className="btn small primary" onClick={() => window.location.reload()}>{t('err.reload')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -504,6 +504,10 @@ const en = {
|
|||||||
// Errors from the engine client
|
// Errors from the engine client
|
||||||
'err.unreachable': 'Cannot reach the learning-lab engine.',
|
'err.unreachable': 'Cannot reach the learning-lab engine.',
|
||||||
'err.generic': 'The request to the engine failed.',
|
'err.generic': 'The request to the engine failed.',
|
||||||
|
'err.busy': 'The engine is busy right now. Try again in a moment.',
|
||||||
|
'err.crashTitle': 'This page could not be shown',
|
||||||
|
'err.crashCopy': 'Something went wrong while showing this page. Try again, or reload.',
|
||||||
|
'err.reload': 'Reload',
|
||||||
'err.credits': 'Not enough credits: needs {required}, balance {balance}.',
|
'err.credits': 'Not enough credits: needs {required}, balance {balance}.',
|
||||||
'err.creditsShort': 'Not enough credits.',
|
'err.creditsShort': 'Not enough credits.',
|
||||||
'err.docFailed': 'The document could not be processed.',
|
'err.docFailed': 'The document could not be processed.',
|
||||||
@@ -1001,6 +1005,10 @@ const id: Record<Key, string> = {
|
|||||||
|
|
||||||
'err.unreachable': 'Engine learning-lab tidak dapat dihubungi.',
|
'err.unreachable': 'Engine learning-lab tidak dapat dihubungi.',
|
||||||
'err.generic': 'Permintaan ke engine gagal.',
|
'err.generic': 'Permintaan ke engine gagal.',
|
||||||
|
'err.busy': 'Engine sedang sibuk. Coba lagi sebentar lagi.',
|
||||||
|
'err.crashTitle': 'Halaman ini tidak dapat ditampilkan',
|
||||||
|
'err.crashCopy': 'Terjadi kesalahan saat menampilkan halaman ini. Coba lagi, atau muat ulang.',
|
||||||
|
'err.reload': 'Muat ulang',
|
||||||
'err.credits': 'Kredit tidak cukup: butuh {required}, saldo {balance}.',
|
'err.credits': 'Kredit tidak cukup: butuh {required}, saldo {balance}.',
|
||||||
'err.creditsShort': 'Kredit tidak cukup.',
|
'err.creditsShort': 'Kredit tidak cukup.',
|
||||||
'err.docFailed': 'Dokumen gagal diproses.',
|
'err.docFailed': 'Dokumen gagal diproses.',
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ const queryClient: QueryClient = new QueryClient({
|
|||||||
mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }),
|
mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
// Retrying a 4xx (auth, credits, validation) only repeats the same answer.
|
// Retrying a 4xx (auth, credits, validation) only repeats the same answer.
|
||||||
queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) },
|
queries: {
|
||||||
|
retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500 && e.status !== 429),
|
||||||
|
// A busy engine (rate limited) is asked again only after it has had time to recover; an
|
||||||
|
// immediate retry from every query at once is what keeps it over the limit.
|
||||||
|
retryDelay: (n, e) => (e instanceof EngineError && (e.status === 503 || e.status === 429) ? 5000 : 1000) * 2 ** n,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -296,7 +296,8 @@ function Progress({ doc }: { doc: Source }) {
|
|||||||
// Each stop the engine can make before building, with the one answer it needs.
|
// Each stop the engine can make before building, with the one answer it needs.
|
||||||
function KnowledgeAction({ doc }: { doc: Source }) {
|
function KnowledgeAction({ doc }: { doc: Source }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const detail = useQuery({ queryKey: ['knowledge', doc.docId, doc.knowledge], queryFn: () => knowledgeDetail(doc.docId) })
|
// Keyed by the knowledge status, so it is read again when that changes and reused otherwise.
|
||||||
|
const detail = useQuery({ queryKey: ['knowledge', doc.docId, doc.knowledge], queryFn: () => knowledgeDetail(doc.docId), staleTime: 5 * 60_000 })
|
||||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||||
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
|
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
|
||||||
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
|
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
|
||||||
|
|||||||
Reference in New Issue
Block a user