diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..a984cde --- /dev/null +++ b/src/api/client.test.ts @@ -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]) + }) +}) diff --git a/src/api/client.ts b/src/api/client.ts index ec9ddb0..72f3a6d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -65,6 +65,8 @@ export async function request(path: string, { json, headers, as = 'json', ... const data = await res.json().catch(() => null) if (!res.ok) { 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) if (res.status === 402) err.message = creditMessage(err) throw err @@ -76,3 +78,21 @@ export async function request(path: string, { json, headers, as = 'json', ... 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)) + +/** 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 (fn: () => Promise): Promise => { + // A finished call hands its slot straight to the next waiting one, so none can slip in between. + if (running >= n) await new Promise(r => waiting.push(r)) + else running++ + try { + return await fn() + } finally { + const next = waiting.shift() + if (next) next() + else running-- + } + } +} diff --git a/src/api/engine.ts b/src/api/engine.ts index d94386a..73cdb40 100644 --- a/src/api/engine.ts +++ b/src/api/engine.ts @@ -1,5 +1,5 @@ // 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' // ── Documents (Brain) ── @@ -76,7 +76,10 @@ export interface KnowledgeDetail { error: string | null } -export const knowledgeDetail = (id: number) => request(`/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(`/upload/${id}/knowledge`)) // 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. diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 012211b..63aebcb 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -6,6 +6,7 @@ import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n' import { workspaceCredits } from '../api/engine' import { Brand, Icon, type IconName } from './Icon' 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. const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL @@ -208,7 +209,7 @@ export function AppLayout() { {/* Keyed by route so each page plays the fade-in from theme.css. */} -
+
Learning Lab Express by BAWANA{t('layout.footer')}
{help && setHelp(false)} />} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..4b5ee4b --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 ( +
+ + {t('err.crashCopy')} +
+ {error.message} +
+
+ + +
+
+ ) + } +} diff --git a/src/i18n.ts b/src/i18n.ts index 0dc669b..0ae85d6 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -504,6 +504,10 @@ const en = { // Errors from the engine client 'err.unreachable': 'Cannot reach the learning-lab engine.', '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.creditsShort': 'Not enough credits.', 'err.docFailed': 'The document could not be processed.', @@ -1001,6 +1005,10 @@ const id: Record = { 'err.unreachable': 'Engine learning-lab tidak dapat dihubungi.', '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.creditsShort': 'Kredit tidak cukup.', 'err.docFailed': 'Dokumen gagal diproses.', diff --git a/src/main.tsx b/src/main.tsx index ea070fc..fdb412d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,7 +21,12 @@ const queryClient: QueryClient = new QueryClient({ mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }), defaultOptions: { // 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, + }, }, }) diff --git a/src/pages/Brain.tsx b/src/pages/Brain.tsx index b53daa8..e25a725 100644 --- a/src/pages/Brain.tsx +++ b/src/pages/Brain.tsx @@ -296,7 +296,8 @@ function Progress({ doc }: { doc: Source }) { // Each stop the engine can make before building, with the one answer it needs. function KnowledgeAction({ doc }: { doc: Source }) { 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 build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh }) const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })