feat(userscript): retry queue storage and status-carrying API errors

This commit is contained in:
2026-07-27 17:04:35 +07:00
parent 6af49e6790
commit bbe04b1e81
+104 -2
View File
@@ -265,6 +265,14 @@
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
}
// The retry queue treats a 400 differently from a dropped connection, so the
// status has to survive the throw. A network failure leaves it undefined.
function httpError(what, status) {
const e = new Error(what + " " + status);
e.status = status;
return e;
}
async function apiGet() {
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
@@ -283,7 +291,7 @@
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
if (!res.ok) throw httpError("PUT /bookmarks", res.status);
return res.json();
}
@@ -292,7 +300,8 @@
method: "DELETE",
headers: authHeaders(),
});
if (!res.ok) throw new Error("DELETE /bookmarks " + res.status);
// 404 means the row is already gone, which is what the caller wanted.
if (!res.ok && res.status !== 404) throw httpError("DELETE /bookmarks", res.status);
}
// ============================================================
@@ -338,6 +347,99 @@
return b.status || "reading";
}
// ============================================================
// Retry queue
//
// A failed write is not rolled back and not lost: the key is parked here and
// replayed when the backend is next reachable. Entries carry no payload — the
// body is read from state.byKey at send time, because state.list *is* the
// desired state and is already persisted in mangabm:cache. One entry per key,
// so two writes to the same series cannot replay out of order, an
// archive-then-unarchive collapses to whatever the cache now says, and a
// queued DELETE replaces a queued PUT rather than racing it.
// ============================================================
const QUEUE_KEY = "mangabm:queue";
const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count
const QUEUE_MAX_ATTEMPTS = 10;
let authFailed = false; // a 401 was seen; retrying cannot help until the token changes
function loadQueue() {
try {
const raw = localStorage.getItem(QUEUE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveQueue(q) {
try {
localStorage.setItem(QUEUE_KEY, JSON.stringify(q));
} catch (e) {
/* quota / private mode — ignore */
}
}
const queue = loadQueue();
function queueCount() {
return queue.length;
}
function queueGet(key) {
return queue.find((e) => e.key === key) || null;
}
function titleFor(key) {
const b = state.byKey[key];
return (b && (b.title || b.series_id)) || key;
}
// Oldest goes first when the cap is hit: the newest entry is the action the
// user just took, and dropping that is the bug this queue exists to fix.
function queuePush(entry) {
if (queue.length >= QUEUE_MAX) {
const evicted = queue.shift();
toast("Sync queue full — dropped " + titleFor(evicted.key), true);
}
queue.push(entry);
return entry;
}
function queueDrop(key) {
const i = queue.findIndex((e) => e.key === key);
if (i >= 0) {
queue.splice(i, 1);
saveQueue(queue);
}
}
// Records a failed write and classifies why it failed. A 400 is a payload the
// server will never accept, so it is dropped now instead of being retried ten
// times; a 401 is the wrong token, so the entry is kept untouched and the
// drain gives up until the token changes.
function queueEnqueue(key, op, sendStatus, err) {
const status = err && err.status;
if (status === 400) {
queueDrop(key);
toast("Couldn't sync " + titleFor(key) + " — change lost", true);
return;
}
const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 });
e.op = op; // a delete replaces a put, and a put replaces a delete
e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped
if (status === 401) {
authFailed = true;
} else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) {
queueDrop(key);
toast("Gave up syncing " + titleFor(key), true);
return;
}
saveQueue(queue);
}
// ============================================================
// Mutations (optimistic: update UI/cache first, then sync)
// ============================================================