feat(userscript): retry queue storage and status-carrying API errors
This commit is contained in:
@@ -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)
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user