From 2b2b45a0c799468f8da9146bc2f5fa0ab4a05755 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:40:10 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P3-Endpr=C3=BCfung=20W1:=20createJWT?= =?UTF-8?q?=20sperrt=20nur=20bei=20echter=20Ablehnung=20durch=20Appwrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nur ein HTTP-Code ab 400 von Appwrite startet die 2-Minuten-Sperre. Übertragungsfehler (TypeError, SDK-Fehler mit code 0, Fehler ohne Code), auch wenn sie erst nach der Zeitgrenze eintreffen, geben die Dienst-Meldung (status 0) und sperren nichts: nach dem Aufwachen aus dem Ruhezustand erholt sich das Hochhaus mit dem nächsten Takt. Eine späte echte Ablehnung (etwa 429) sperrt weiter. Co-Authored-By: Claude Sonnet 5.5 --- src/lib/workersApi.js | 24 ++++-- tests/unit/workersApi.test.mjs | 131 ++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/src/lib/workersApi.js b/src/lib/workersApi.js index 1a23e02..5bf054b 100644 --- a/src/lib/workersApi.js +++ b/src/lib/workersApi.js @@ -12,7 +12,7 @@ export const INVALID_ID_TEXT = 'Ungültige Kennung.' // Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung. export const REQUEST_TIMEOUT_MS = 20000 // Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder lässt sich nicht erzeugen) wird so lange kein frisches JWT mehr -// erzwungen, und scheitert createJWT selbst (z. B. Appwrite 429), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt +// erzwungen, und lehnt Appwrite createJWT ab (HTTP-Code ab 400, z. B. 429; kein Übertragungsfehler), wird so lange gar kein JWT mehr geholt: Appwrite begrenzt // createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen und damit auch Serverpuls, // Finanzen und Vorschauen sperren. 2 Minuten ergeben höchstens 30 Versuche je Stunde und Tab. export const AUTH_COOLDOWN_MS = 120000 @@ -71,7 +71,7 @@ export function createWorkersApi({ now = Date.now, }) { let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Folge kein frisches JWT erzeugt und nach einem gescheiterten createJWT gar keins (AUTH_COOLDOWN_MS) - let mintFailed = false // getJwt (createJWT) ist gescheitert: bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache + let mintFailed = false // Appwrite hat createJWT abgelehnt (HTTP-Code ab 400, kein Übertragungsfehler): bis zum Ende der Sperre wird getJwt nicht mehr gerufen, auch nicht bei leerem Cache // Führt fn(signal) mit Zeitgrenze aus: nach timeoutMs scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird // abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung). @@ -105,12 +105,20 @@ export function createWorkersApi({ } try { return await getJwt(options) - } catch { - // createJWT selbst scheitert (z. B. Appwrite 429, Sitzung weg): dieselbe Sperre wie nach der zweiten 401. - // Ein hängendes getJwt zählt nicht dazu: es endet in withDeadline mit der Dienst-Meldung, bevor dieser Zweig erreicht wird. - mintFailed = true - freshBlockedUntil = now() + authCooldownMs - throw statusError(SESSION_TEXT, 401) + } catch (e) { + // Gesperrt wird nur, wenn Appwrite geantwortet und abgelehnt hat (HTTP-Code ab 400: 401/403 Sitzung, 429 Begrenzung, 5xx): + // dieselbe Sperre wie nach der zweiten 401. Auch eine späte Ablehnung nach der Zeitgrenze mit echtem Code (etwa ein 429) + // sperrt weiter, denn sie zählt gegen die Begrenzung von createJWT. + if (Number.isInteger(e?.code) && e.code >= 400) { + mintFailed = true + freshBlockedUntil = now() + authCooldownMs + throw statusError(SESSION_TEXT, 401) + } + // Alles andere ist ein Übertragungsfehler (Netz weg, Rechner gerade aus dem Ruhezustand erwacht): Die Anfrage hat Appwrite nicht + // erreicht und verbraucht keine createJWT-Begrenzung. Das Appwrite-SDK meldet das als Fehler mit code 0, der Browser als TypeError. + // Keine Sperre: der nächste Takt (5-15 s) versucht es wieder und fängt sich, sobald das Netz zurück ist. Auch ein solcher Fehler, + // der erst nach der Zeitgrenze eintrifft, sperrt nichts. Ein JWT-Abruf, der nie endet, endet in withDeadline mit derselben Meldung. + throw statusError(DOWN_TEXT, 0) } } diff --git a/tests/unit/workersApi.test.mjs b/tests/unit/workersApi.test.mjs index 6bebe7e..5d75837 100644 --- a/tests/unit/workersApi.test.mjs +++ b/tests/unit/workersApi.test.mjs @@ -78,7 +78,7 @@ test('Fehlertexte (Review Focus 1)', async () => { assert.deepEqual(await run({ status: 400, body: { error: 'Tagesgrenze ungültig.' } }), ['Tagesgrenze ungültig.', 400]) assert.deepEqual(await run({ status: 500, body: {} }), ['Hustler-Fehler 500', 500]) await assert.rejects( - createWorkersApi({ getJwt: async () => { throw new Error('x') }, fetchImpl: fakeFetch([]) }).state(), + createWorkersApi({ getJwt: async () => { throw Object.assign(new Error('x'), { code: 401 }) }, fetchImpl: fakeFetch([]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 }, ) }) @@ -248,7 +248,7 @@ test('401-Folge: scheitert schon die Erzeugung des frischen JWT, beginnt dieselb const mints = [] const brokenFresh = async (o) => { mints.push(o?.fresh === true) - if (o?.fresh) throw new Error('429 Too Many Requests') + if (o?.fresh) throw Object.assign(new Error('429 Too Many Requests'), { code: 429 }) return 'jwt-alt' } const api = createWorkersApi({ getJwt: brokenFresh, fetchImpl: fakeFetch(Array.from({ length: 6 }, () => ({ status: 401, body: {} }))), now: () => t, authCooldownMs: 60000 }) @@ -393,7 +393,7 @@ test('resetAuth (Abmelden): hebt die Sperre auf, der nächste Abruf holt sofort const failing = { on: true } const getJwtFlaky = async (o) => { asked.push(o?.fresh === true) - if (failing.on) throw new Error('429') + if (failing.on) throw Object.assign(new Error('429'), { code: 429 }) return 'jwt-neu' } const f = fakeFetch(okReplies(2)) @@ -427,3 +427,128 @@ test('ein hängendes createJWT (Zeitgrenze) ist kein Scheitern und löst keine S await assert.rejects(api.state(), { message: DOWN, status: 0 }) assert.equal(asked, 2) // die Zeitgrenze (Netz/Ruhezustand) sperrt nichts: ein nächster Abruf versucht es wieder }) + +// ---- Endprüfung P3 (W1): gesperrt wird nur, wenn Appwrite createJWT mit einem HTTP-Code ab 400 abgelehnt hat; Übertragungsfehler nicht ---- + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +test('createJWT: Übertragungsfehler (TypeError, SDK-Fehler mit code 0, Fehler ohne Code) geben DOWN/0 und sperren nichts', async () => { + const transport = [ + new TypeError('Failed to fetch'), + Object.assign(new Error('Load failed'), { code: 0 }), // so meldet das Appwrite-SDK einen Netzfehler (client.ts: AppwriteException mit code 0) + new Error('irgendwas'), + Object.assign(new Error('Code ist keine Zahl'), { code: '429' }), + Object.assign(new Error('Code nicht ganzzahlig'), { code: 429.5 }), + Object.assign(new Error('Code unter 400'), { code: 399 }), + null, + undefined, + ] + for (const failure of transport) { + const calls = [] + const getJwtFlaky = async () => { calls.push(1); throw failure } + const f = fakeFetch(okReplies(1)) + const api = createWorkersApi({ getJwt: getJwtFlaky, fetchImpl: f, now: () => 0 }) + await assert.rejects(api.state(), { message: DOWN, status: 0 }, String(failure)) + await assert.rejects(api.state(), { message: DOWN, status: 0 }, String(failure)) // zweiter Abruf: nicht gesperrt, getJwt wird wieder gerufen + assert.equal(calls.length, 2, String(failure)) + assert.equal(f.calls.length, 0) + } +}) + +test('createJWT: jeder HTTP-Code ab 400 sperrt (401, 403, 429, 500, 503), nur Appwrites echte Ablehnung zählt', async () => { + for (const code of [400, 401, 403, 429, 500, 503]) { + let t = 0 + let creates = 0 + const getJwtRefused = async () => { creates += 1; throw Object.assign(new Error('abgelehnt'), { code }) } + const api = createWorkersApi({ getJwt: getJwtRefused, fetchImpl: fakeFetch([]), now: () => t, authCooldownMs: 60000 }) + await assert.rejects(api.state(), { message: SESSION, status: 401 }, `code ${code}`) + t += 10000 + await assert.rejects(api.events(null), { message: SESSION, status: 401 }, `code ${code}`) + assert.equal(creates, 1, `code ${code}`) // gesperrt: kein zweiter createJWT-Aufruf + } +}) + +test('createJWT: offline (TypeError) gibt DOWN/0, ein Abruf 10 s später mit zurückgekehrtem Netz klappt sofort', async () => { + let t = 0 + let online = false + let creates = 0 + const getJwtNet = async () => { + creates += 1 + if (!online) throw new TypeError('Failed to fetch') + return 'jwt-1' + } + const f = fakeFetch(okReplies(2)) + const api = createWorkersApi({ getJwt: getJwtNet, fetchImpl: f, now: () => t }) // Standard-Sperre 2 min: sie darf nicht greifen + await assert.rejects(api.state(), { message: DOWN, status: 0 }) + assert.equal(creates, 1) + assert.equal(f.calls.length, 0) + t += 10000 + online = true + assert.deepEqual(await api.state(), {}) + assert.equal(creates, 2) + assert.equal(f.calls.length, 1) + assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-1') +}) + +test('createJWT: späte Ablehnung nach der Zeitgrenze (TypeError und code 0) sperrt nicht', async () => { + for (const failure of [new TypeError('Failed to fetch'), Object.assign(new Error('Load failed'), { code: 0 })]) { + let t = 0 + let creates = 0 + let online = false + const getJwtLate = async () => { + creates += 1 + if (online) return 'jwt-1' + await sleep(25) // trifft erst nach der Zeitgrenze (10 ms) ein: der Aufrufer hat DOWN/0 schon bekommen + throw failure + } + const f = fakeFetch(okReplies(1)) + const api = createWorkersApi({ getJwt: getJwtLate, fetchImpl: f, now: () => t, timeoutMs: 10 }) + await assert.rejects(api.state(), { message: DOWN, status: 0 }) + await sleep(40) // die späte Ablehnung ist inzwischen angekommen + t += 5000 + online = true + assert.deepEqual(await api.state(), {}) // nicht gesperrt: der nächste Takt holt ein JWT + assert.equal(creates, 2) + assert.equal(f.calls.length, 1) + } +}) + +test('createJWT: späte Ablehnung nach der Zeitgrenze mit code 429 sperrt weiter', async () => { + let t = 0 + let creates = 0 + const getJwtLate429 = async () => { + creates += 1 + await sleep(25) + throw Object.assign(new Error('Rate limit'), { code: 429 }) + } + const f = fakeFetch(okReplies(2)) + const api = createWorkersApi({ getJwt: getJwtLate429, fetchImpl: f, now: () => t, timeoutMs: 10, authCooldownMs: 60000 }) + await assert.rejects(api.state(), { message: DOWN, status: 0 }) // dem Aufrufer wurde schon DOWN/0 gemeldet + await sleep(40) + t += 5000 + await assert.rejects(api.state(), { message: SESSION, status: 401 }) // die echte Ablehnung zählt gegen die Begrenzung: Sperre + assert.equal(creates, 1) + assert.equal(f.calls.length, 0) + t += 60000 + await assert.rejects(api.state(), { message: DOWN, status: 0 }) // nach der Sperre genau ein neuer Versuch + assert.equal(creates, 2) +}) + +test('createJWT: Übertragungsfehler beim frischen JWT nach einer 401 sperrt nicht (nur die 401-Folge selbst)', async () => { + let t = 0 + const mints = [] + let online = true + const getJwtFresh = async (o) => { + mints.push(o?.fresh === true) + if (o?.fresh && !online) throw new TypeError('Failed to fetch') + return o?.fresh ? 'jwt-neu' : 'jwt-alt' + } + const f = fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }, { status: 200, body: { ok: 1 } }]) + const api = createWorkersApi({ getJwt: getJwtFresh, fetchImpl: f, now: () => t }) + online = false + await assert.rejects(api.state(), { message: DOWN, status: 0 }) // 401, dann Netz weg beim frischen JWT + t += 5000 + online = true + assert.deepEqual(await api.state(), { ok: 1 }) // 401 mit dem gespeicherten JWT, die Wiederholung mit frischem JWT darf wieder erfolgen und klappt + assert.deepEqual(mints, [false, true, false, true]) // keine Sperre: weder für frische JWTs noch für createJWT überhaupt +})