Workers P2: Zeitgrenze je Abruf und Sperre für frische JWTs nach einer 401-Folge

Endprüfung P2. Ein Abruf auf toter Verbindung (Rechner im Ruhezustand, Netzwechsel) blieb offen, und die
Feed-Schleifen laufen mit einem Abruf je Schleife: die Seite stand still und zeigte veraltete Daten als aktuell.
createWorkersApi bricht jetzt jeden Versuch (JWT holen, senden, Antwort lesen) nach 20 s mit "Der Hustler-Dienst ist
gerade nicht erreichbar." (status 0) ab, auch wenn fetch oder createJWT das Signal nicht beachten.

Außerdem erzwang jede 401-Antwort ein frisches createJWT, bei dauerhaftem 401 etwa 16 bis 20 Mal je Minute und Tab.
Appwrite begrenzt createJWT je Nutzer; das Limit hätte auch Serverpuls, Finanzen und Vorschauen gesperrt. Nach einer
401-Folge (frisches JWT wird ebenfalls abgelehnt oder lässt sich nicht erzeugen) gibt es 2 Minuten lang kein frisches
JWT mehr, die Meldung "Anmeldung abgelaufen" bleibt; Erfolg hebt die Sperre auf.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 05:34:51 +00:00
1 parent 820ccd510d
commit 9ebc562c83
3 files changed
+234 -11

No files matched your search

+65 -10
View File
@@ -7,6 +7,14 @@ export const BAD_REPLY_TEXT = 'Der Hustler-Dienst antwortet nicht richtig.'
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.'
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
// 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: 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
// Pult-Status des Dienstes (Übersicht §4.11)
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
@@ -46,13 +54,35 @@ function pultStatusList(status) {
return list
}
export function createWorkersApi({ getJwt, fetchImpl = (...args) => globalThis.fetch(...args) }) {
export function createWorkersApi({
getJwt,
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = REQUEST_TIMEOUT_MS,
authCooldownMs = AUTH_COOLDOWN_MS,
now = Date.now,
}) {
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Antwort kein frisches JWT erzeugt (AUTH_COOLDOWN_MS)
// 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).
function withDeadline(fn) {
const controller = new AbortController()
let timer
const expired = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(statusError(DOWN_TEXT, 0)) // zuerst ablehnen, dann abbrechen: das Abbruch-Ergebnis von fn soll nicht gewinnen
controller.abort()
}, timeoutMs)
})
return Promise.race([fn(controller.signal), expired]).finally(() => clearTimeout(timer))
}
// fetch wirft (Netz, Dienst aus) ⇒ DOWN_TEXT mit status 0
async function send(path, method, body, jwt) {
async function send(path, method, body, jwt, signal) {
const headers = { 'X-Appwrite-JWT': jwt }
if (body !== undefined) headers['Content-Type'] = 'application/json'
try {
return await fetchImpl(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
return await fetchImpl(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal })
} catch {
throw statusError(DOWN_TEXT, 0)
}
@@ -66,13 +96,8 @@ export function createWorkersApi({ getJwt, fetchImpl = (...args) => globalThis.f
}
}
async function request(path, { method = 'GET', body } = {}) {
let res = await send(path, method, body, await jwtFor())
if (res.status === 401) {
// einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen
res = await send(path, method, body, await jwtFor({ fresh: true }))
if (res.status === 401) throw statusError(SESSION_TEXT, 401)
}
// Antwort auswerten (kein 401): Daten oder Error mit deutscher Meldung
async function read(res) {
let data
try {
data = await res.json()
@@ -90,6 +115,36 @@ export function createWorkersApi({ getJwt, fetchImpl = (...args) => globalThis.f
return data ?? {}
}
// Ein Versuch mit Zeitgrenze: JWT holen, senden, auswerten. { unauthorized: true } bei 401, sonst { data }.
function attempt(path, method, body, options) {
return withDeadline(async (signal) => {
const res = await send(path, method, body, await jwtFor(options), signal)
if (res.status === 401) return { unauthorized: true }
freshBlockedUntil = 0 // der Dienst hat das JWT angenommen: die nächste 401-Folge darf wieder einmal erneuern
return { data: await read(res) }
})
}
async function request(path, { method = 'GET', body } = {}) {
let out = await attempt(path, method, body)
if (out.unauthorized) {
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
// sonst einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen und die Sperre beginnt
try {
out = await attempt(path, method, body, { fresh: true })
} catch (err) {
if (err && err.status === 401) freshBlockedUntil = now() + authCooldownMs // auch das frische JWT ließ sich nicht erzeugen
throw err
}
if (out.unauthorized) {
freshBlockedUntil = now() + authCooldownMs
throw statusError(SESSION_TEXT, 401)
}
}
return out.data
}
return {
state: () => request('/state'),
events: (after, limit = EVENTS_LIMIT) => request(`/events${query([['after', after], ['limit', clampLimit(limit, EVENTS_LIMIT)]])}`),