Recover from expired sessions and malformed requests in the dashboard

Three failure paths that ended at an English HTTP status line or at no
recovery at all:

An empty or non-numeric withdrawal amount parsed to NaN, which JSON.stringify
sends as null, which pydantic rejects with a 422 — and FastAPI's validation
errors use a list of field objects rather than the {code, message} shape, so
apiErrorMessage fell through to res.statusText and the user read
"Unprocessable Content". The amount is now checked before the request, and the
list shape maps to a translated "invalid request" as a backstop for any other
field that fails validation.

A token the server no longer accepts left the dashboard looking logged in
while every poll failed, re-toasting "session expired" indefinitely. call()
now logs out on that specific code, dropping back to the login form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 21:54:56 +02:00
co-authored by Claude Opus 5
parent 5c9ccc0344
commit 28c1179e9b
2 changed files with 35 additions and 4 deletions
+21 -4
View File
@@ -51,7 +51,13 @@ async function call(method, path, body) {
clearTimeout(timeoutId);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(apiErrorMessage(data.detail) || res.statusText);
if (!res.ok) {
// A token the server no longer accepts can't be recovered from by retrying:
// without this every poll keeps failing against a dashboard that still looks
// logged in, toasting "session expired" forever. Drop back to the login form.
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
throw new Error(apiErrorMessage(data.detail) || res.statusText);
}
return data;
}
@@ -62,6 +68,11 @@ async function call(method, path, body) {
function apiErrorMessage(detail) {
if (!detail) return null;
if (typeof detail === 'string') return detail; // endpoints still returning a bare string
// FastAPI's own request-validation failures (422) use a list of field errors
// instead, in English and phrased for an API client ("Input should be a valid
// integer"). Nothing here can act on which field it was, so say the one useful
// thing — the request was malformed — in the user's language.
if (Array.isArray(detail)) return t('error.invalid_request');
return tOrNull('error.' + detail.code, errorParams(detail.params)) || detail.message || null;
}
@@ -702,9 +713,15 @@ async function withdraw() {
const btn = document.getElementById('withdraw-btn');
const ext = document.getElementById('wd-address').value;
const isFullAmount = document.getElementById('wd-full-amount').checked;
const amtSats = isFullAmount
? myBalanceSats
: Math.round(parseFloat(document.getElementById('wd-amount').value) * SATS_PER_PLM);
const amount = parseFloat(document.getElementById('wd-amount').value);
// Caught here rather than left to the server: an empty or non-numeric field
// parses to NaN, which JSON.stringify sends as null, which comes back as a
// 422 whose only readable text is an English HTTP status line.
if (!isFullAmount && !(amount > 0)) {
toast(t('error.invalid_amount'), 'error');
return;
}
const amtSats = isFullAmount ? myBalanceSats : Math.round(amount * SATS_PER_PLM);
await withLoading(btn, t('loading.sending'), async () => {
try {
await call('POST', '/withdrawals', { external_address: ext, amount_sats: amtSats });