Files
rustification-server/docs/realtime/research/03-ws-auth-reconnect.md
MDietermann 55a2af4756 docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
2026-08-14 19:04:41 +02:00

102 KiB
Raw Permalink Blame History

Recherche: WS-Auth, Heartbeat und Reconnect

Ticket: ../issues/03-research-ws-auth-reconnect.md Parent: ../map.md Stand: 14.08.2026

Dieses Dokument gibt bewusst keine Empfehlung ab. Es stellt pro Teilfrage die Optionen mit ihren belegten Vor- und Nachteilen gegenüber. Die Entscheidung fällt im Ticket Verbindungsaufbau & Auth über WS.

Vorbemerkung zum Bedrohungsmodell

Die Map setzt fest: der Serverbetreiber ist der Angreifer. Für die Token-Frage folgt daraus eine Klarstellung, die im weiteren Text nicht wiederholt wird: kein Token-Transportweg schützt vor dem Serverbetreiber selbst — der Server muss das Token sehen, um es zu prüfen. Der Unterschied zwischen den Optionen ist ausschließlich die Persistenz- und Streubreite des Geheimnisses: was landet in Logfiles, Log-Backups, Reverse-Proxy-Logs, APM/Tracing-Systemen und deren Subprozessoren — also in Artefakten, die den Handshake überleben und mehr Leute sehen als der Prozess, der das Token validiert.

Vertraulichkeit gegenüber dem Betreiber ist eine Aufgabe der E2EE-Schicht, nicht der Auth-Schicht. RFC 6455 sagt dazu selbst nichts vor: §10.5 —

„This protocol does not prescribe any particular way that servers can authenticate clients during a WebSocket handshake." (https://datatracker.ietf.org/doc/html/rfc6455#section-10.5)

Ein zweites, für das Projekt relevantes Faktum vorab: die Browser-API verschluckt alle Fehlerdetails. WHATWG WebSockets Standard, „Feedback from the protocol":

„User agents must not convey any failure information to scripts in a way that would allow a script to distinguish the following situations […] Allowing a script to distinguish these cases would allow a script to probe the user's local network in preparation for an attack." (https://websockets.spec.whatwg.org/#feedback-from-the-protocol)

Zu den nicht unterscheidbaren Fällen gehören DNS-Fehler, Connection-Refused, TLS-Fehler, Subprotocol-Mismatch und Abbruch nach erfolgreichem Handshake. Das Skript sieht in allen Fällen CloseEvent.code === 1006, wasClean === false; das error-Event ist ein generisches Event ohne Payload (https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event). Im WebView ist „Token abgelaufen" nicht von „Server down" unterscheidbar. Im Rust-Layer schon — tungstenite::Error::Http trägt die vollständige HTTP-Response inklusive Statuscode und Body (https://docs.rs/tungstenite/latest/tungstenite/error/enum.Error.html). Diese Asymmetrie zieht sich durch fast jede Teilfrage.


1. Token-Übergabe beim Handshake

1.1 Ausgangslage

Der Handshake ist ein normaler HTTP-Request. RFC 6455 §4.1:

„If the status code received from the server is not 101, the client handles the response per HTTP [RFC2616] procedures. In particular, the client might perform authentication if it receives a 401 status code" (https://datatracker.ietf.org/doc/html/rfc6455#section-4.1)

Ein Axum-Handler kann also regulär mit 401/403 samt JSON-Body ablehnen. Ob der Client das sieht, hängt daran, wo die Verbindung lebt (siehe Vorbemerkung).

1.2 Option A — Token im Query-String (wss://host/ws?token=…)

Normative Ablehnung. RFC 6750 §2.3:

„Because of the security weaknesses associated with the URI method (see Section 5), including the high likelihood that the URL containing the access token will be logged, it SHOULD NOT be used unless it is impossible to transport the access token in the 'Authorization' request header field or the HTTP request entity-body." (https://datatracker.ietf.org/doc/html/rfc6750#section-2.3)

RFC 6750 §5.3:

„Bearer tokens SHOULD NOT be passed in page URLs […] Browsers, web servers, and other software may not adequately secure URLs in the browser history, web server logs, and other data structures." (https://datatracker.ietf.org/doc/html/rfc6750#section-5.3)

OWASP REST Security Cheat Sheet: „Passwords, security tokens, and API keys should not appear in the URL, as this can be captured in web server logs" (https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html)

Das Log-Verhalten in Default-Konfigurationen — der eigentliche Beleg:

Proxy/Server Query-String im Default-Log? Header im Default-Log? Quelle
nginx (combined) Ja — $request ist die Request-Line inkl. Query Nein (nur Referer + User-Agent) https://nginx.org/en/docs/http/ngx_http_log_module.html
Apache httpd (common/combined) Ja — %r = „The request line from the client" Nein (außer explizit %{Header}i) https://httpd.apache.org/docs/2.4/logs.html
Caddy (JSON) Ja — request.uri = „Absolute path and query of the URI" Ja, alle Header — außer Cookie, Set-Cookie, Authorization, Proxy-Authorization (→ REDACTED) https://caddyserver.com/docs/logging

Caddy wörtlich:

„By default, access logs […] with headers that contain potentially sensitive information (Cookie, Set-Cookie, Authorization and Proxy-Authorization) will be logged as REDACTED."

Befund: In allen drei Default-Konfigurationen ist der Authorization-Header der leisere Kanal. Bei Caddy ist er sogar aktiv redigiert, request.uri dagegen nicht. Das ist der einzige belastbare technische Unterschied im Log-Kanal — und für dieses Projekt der entscheidende, weil der Betreiber-Angreifer genau über solche Artefakte an Tokens kommt, die er im Prozess nur flüchtig sieht.

Wer es trotzdem so macht — und wie ehrlich die Doku dazu ist. Microsoft SignalR (ms.date: 2026-07-29):

„In standard web APIs, bearer tokens are sent in an HTTP header. However, SignalR is unable to set these headers in browsers when some transports are used. When WebSockets and Server-Sent Events are used, the token is transmitted as a query string parameter."

und auf der Security-Seite:

„Many web servers log the URL for each request, including the query string. Logging the URLs may log the access token. ASP.NET Core logs the URL for each request by default […] you can write middleware to log the data that you require and filter out the access_token query string value (if present)." (https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz, https://learn.microsoft.com/en-us/aspnet/core/signalr/security)

Azure Web PubSub unterstützt beides parallel: /client/?hub={hub}&access_token={token} oder Authorization: Bearer {token} (https://learn.microsoft.com/en-us/azure/azure-web-pubsub/concept-client-protocols). AWS AppSync unterstützt drei Wege gleichzeitig: Query-Param, Subprotocol und echte Header (https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html).

python-websockets bewertet den Query-Param als „fully reliable but less secure", weil „URIs end up in logs, which leaks credentials" (https://websockets.readthedocs.io/en/stable/topics/authentication.html).

Zwei verbreitete Nachteils-Behauptungen, die sich NICHT belegen ließen — bitte nicht ins Entscheidungsticket übernehmen:

  • Referrer-Leak: RFC 6750 §5.3 nennt Referrer, spricht aber von „page URLs". Für new WebSocket(url) konnte keine Quelle gefunden werden. Der Handshake ist eine Subresource-Request; im Referrer landet die Seiten-URL, nicht die WS-URL.
  • Browser-History: new WebSocket(url) erzeugt keinen History-Eintrag. Kein Beleg gefunden.

Belegbar bleiben: Access-Logs, Log-Aggregation/APM, und deren Backups. Das reicht.

Pro Funktioniert in jeder WebSocket-Implementierung inklusive Browser-API; keine Sonderbehandlung nötig; als Query<T>-Extractor in Axum trivial.
Contra RFC 6750 sagt explizit „SHOULD NOT"; landet per Default in nginx-, Apache- und Caddy-Logs; verschärft sich linear mit erzwungenen Reconnects (siehe §2).

1.3 Option B — Sec-WebSocket-Protocol-Missbrauch

Ist ein JWT ein legaler Subprotocol-Token? RFC 6455 §4.1:

„The elements that comprise this value MUST be non-empty strings with characters in the range U+0021 to U+007E not including separator characters as defined in [RFC2616]."

RFC 2616 §2.2 definiert als Separatoren: ( ) < > @ , ; : \ " / [ ] ? = { }, SP, HT (https://datatracker.ietf.org/doc/html/rfc2616#section-2.2).

., - und _ sind keine Separatoren → ein kompakt serialisiertes JWT (base64url.base64url.base64url) ist ein gültiger Subprotocol-Token. Aber = ist ein Separator → Base64 mit Padding wäre illegal. Genau deshalb schreibt Kubernetes base64url-ohne-Padding vor.

Die zwingende Echo-Semantik ist der eigentliche Haken. RFC 6455 §4.2.2: Der Server wählt „one or none of the acceptable protocols". RFC 6455 §4.1: Gibt der Server ein Subprotokoll zurück, das der Client nicht angeboten hat, „the client MUST Fail the WebSocket Connection". Browser sind strenger als RFC 6455 — WHATWG:

„If protocols is not the empty list and extracting header list values given Sec-WebSocket-Protocol and response's header list results in null, failure, or the empty byte sequence, then fail the WebSocket connection." (https://websockets.spec.whatwg.org/#establish-a-websocket-connection)

Bietet der Browser-Client also Subprotokolle an, muss der Server eines zurückgeben — aber er darf natürlich nicht das Token echoen. Daraus folgt zwingend das Kubernetes-Muster: mindestens zwei Subprotokolle anbieten, eines ist das Token, eines das echte Protokoll.

Kubernetes API-Server, der kanonische Präzedenzfall. bearerProtocolPrefix = "base64url.bearer.authorization.k8s.io."

„ProtocolAuthenticator allows a websocket connection to provide a bearer token as a subprotocol in the format base64url.bearer.authorization.<base64url-without-padding(bearer-token)>" (https://github.com/kubernetes/apiserver/blob/master/pkg/authentication/request/websocket/protocol.go)

Der Handler verlangt mindestens ein weiteres Subprotocol, entfernt bei Erfolg das Token-Subprotocol und echot die verbleibenden zurück. Motivation aus dem ursprünglichen PR: „Browser clients do not have the ability to set an Authorization header programmatically on websocket requests" (https://github.com/kubernetes/kubernetes/pull/47740).

AWS AppSync macht dasselbe mit header-<base64url(JSON)> zusätzlich zu graphql-ws.

Log-Verhalten — hier kippt der Vorteil:

  • nginx / Apache: Sec-WebSocket-Protocol steht nicht im Default-Format.
  • Caddy: der Header wird per Default vollständig geloggt — request.headers ist eine komplette Map, und Sec-WebSocket-Protocol steht nicht auf der Redaction-Liste. Bei Caddy ist dieser Weg damit schlechter als Authorization: Bearer.

IANA: Subprotocol-Namen sind „First Come First Served" registriert (https://www.iana.org/assignments/websocket/websocket.xhtml); ein Token-Präfix wie das von Kubernetes ist nicht registriert. Registrierung ist optional, Kollisionsrisiko theoretisch.

Pro Funktioniert in der Browser-API ohne Custom-Header; landet nicht in nginx-/Apache-Default-Logs; von Kubernetes und AWS AppSync in Produktion belegt.
Contra Erzwingt das Zwei-Subprotokoll-Konstrukt, sonst schlägt der Browser-Handshake fehl (WHATWG, strenger als RFC); Base64url-Padding muss weg (= ist Separator); bei Caddy per Default im Log; Proxy-Header-Längenlimits gelten (nginx large_client_header_buffers, Default 4×8k — nicht verifiziert, vor einer Entscheidung prüfen).

Cookies fließen beim Handshake automatisch mit, normativ: WHATWG „establish a WebSocket connection" setzt den Request auf „mode is 'websocket', credentials mode is 'include'" (https://websockets.spec.whatwg.org/#establish-a-websocket-connection) — auch cross-origin. Genau daraus entsteht CSWSH (Cross-Site WebSocket Hijacking).

RFC 6455 §10.2 zur Origin-Prüfung, und wörtlich zur Grenze des Modells:

„when the WebSocket Protocol is used by a dedicated client directly […] the origin model is not useful, as the client can provide any arbitrary origin string." (https://datatracker.ietf.org/doc/html/rfc6455#section-10.2)

RFC 6455 §4.1: „This header field is sent by browser clients; for non-browser clients, this header field may be sent if it makes sense in the context of those clients."

OWASP WSTG-CLNT-10: „It is the server's responsibility to verify the Origin header in the initial HTTP WebSocket handshake." (https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/10-Testing_WebSockets)

Microsoft SignalR Security dagegen: „The protections provided by CORS don't apply to WebSockets." und „The Origin header is controlled by the client and, like the Referer header, can be faked. These headers should not be used as an authentication mechanism."

Gilt SameSite für WS-Handshakes? — Ableitung, nicht wörtlich belegt. draft-ietf-httpbis-rfc6265bis §5.6.7.1 lässt Lax nur gelten, „if and only if they are top-level navigations which use a 'safe' […] HTTP method" (https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis). Ein WS-Handshake ist keine Top-Level-Navigation → Lax und Strict blocken den cross-site Handshake. Das Wort „WebSocket" kommt in rfc6265bis nicht vor. Die Aussage stützt sich zusätzlich auf das OWASP WebSocket Security Cheat Sheet („Use SameSite cookies […] to strengthen CSWSH defenses", https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) — diese Seite trägt allerdings kein verifizierbares Datum.

Tauri-spezifische Komplikation — zwei getrennte Cookie-Stores:

Pro HttpOnly schützt gegen XSS-Auslesen; wird per Default nicht geloggt (Caddy redigiert explizit); im Browser vollautomatisch.
Contra CSWSH — CORS greift nicht, Origin-Prüfung ist die einzige Verteidigung und ist bei Nicht-Browser-Clients wertlos (RFC 6455 §10.2 sagt das selbst); SameSite-Wirksamkeit ist abgeleitet, nicht wörtlich belegt; in Tauri zwei inkonsistente Cookie-Stores; im Rust-Client-Layer gar kein natürliches Konstrukt; passt schlecht zum Multi-Server-Modell der Map (N Server = N Cookie-Domains).

1.5 Option D — Erstes Auth-Frame nach dem Handshake

Wer macht das, mit belegten Timeouts und Close-Codes:

System Ablauf Timeout Fehler-Code Quelle
graphql-ws ConnectionInit mit payload → ConnectionAck connectionInitWaitTimeout, @default 3_000 (3 s) 4408 Init-Timeout; 4401 Unauthorized, 4403 Forbidden (empfohlen) https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md
Centrifugo connect-Command mit {"token":"<JWT>"} client.stale_close_delay, Default "10s" 3502 "stale", 3500 "invalid token" (terminal) https://centrifugal.dev/docs/server/codes
Home Assistant Server: auth_required → Client: {"type":"auth","access_token":…} → auth_ok/auth_invalid nicht dokumentiert — https://developers.home-assistant.io/docs/api/websocket/
Discord Gateway Nach Hello (op 10) sendet der Client Identify (op 2) nicht dokumentiert 4003 Not authenticated (reconnect true), 4004 Authentication failed (reconnect false) https://docs.discord.com/developers/events/gateway
Pusher Channels pusher:signin über den offenen Socket impliziert 4009 (Bereich 4000–4099 = „SHOULD NOT be re-established unchanged") https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/
Socket.IO auth-Option → socket.handshake.auth.token, Middleware vor Connect — „the connection will be refused and the client will receive a connect_error event" https://socket.io/docs/v4/middlewares/

Korrektur zu einer verbreiteten Annahme: Phoenix Channels wird oft als Beispiel für dieses Muster genannt, ist aber keines — Phoenix übergibt den Token als Socket-Param, also im Query-String (new Socket("/socket", {authToken: window.userToken}), geprüft in connect/3 via Phoenix.Token.verify(…, max_age: 1209600), https://phoenix.hexdocs.pm/channels.html). Als Beleg für Option D nicht verwendbar.

python-websockets bewertet diesen Weg als „fully reliable and the most secure mechanism", merkt aber an, dass Auth damit auf der Anwendungsschicht stattfindet, nach abgeschlossenem Handshake (https://websockets.readthedocs.io/en/stable/topics/authentication.html).

Pro Token verlässt nie die HTTP-Schicht → landet in keinem Access-Log, egal welcher Proxy; funktioniert in Browser-API und Rust-Client identisch; erlaubt strukturierte Fehler statt 1006; derselbe Kanal trägt später den Re-Auth (§2.2).
Contra Unauthentifizierte Sockets belegen Ressourcen bis zum Timeout → DoS-Fläche (genau dagegen existieren graphql-ws' 3 s und Centrifugos 3502 "stale"); kein HTTP-401 mehr möglich, nur Close-Codes; Auth wandert in die Applikationsschicht und muss dort selbst gegen Missbrauch gehärtet werden (Centrifugo hat dafür eigene Codes 3013 too many requests, 3509 too many errors).

1.6 Option E — Rust-Client mit voller Header-Kontrolle (Authorization: Bearer)

Wenn die Verbindung im Tauri-Rust-Layer lebt (siehe Ticket Wo lebt die WS-Verbindung im Client), entfällt die Browser-Beschränkung vollständig.

tungstenite::client::IntoClientRequest ist implementiert für &str, String, Uri, Url, http::Request<()>, httparse::Request, ClientRequestBuilder (https://docs.rs/tungstenite/latest/tungstenite/client/trait.IntoClientRequest.html).

ClientRequestBuilder (https://docs.rs/tungstenite/latest/tungstenite/client/struct.ClientRequestBuilder.html):

„Builder for a custom IntoClientRequest with options to add custom additional headers and sub protocols." with_header(): „Adds (key, value) as an additional header to the handshake request" with_sub_protocol(): „Adds protocol to the handshake request subprotocols"

Praxis-Pattern:

let mut request = "wss://api.example.com/ws".into_client_request()?;
request.headers_mut().insert("Authorization", "Bearer token123".parse()?);
let (ws_stream, response) = connect_async(request).await?;

Fehlerdetails sind hier verfügbar — tungstenite::Error::Http(Box<Response<Option<Vec<u8>>>>) trägt Statuscode und Body (https://docs.rs/tungstenite/latest/tungstenite/error/enum.Error.html). Ein 401 mit strukturiertem JSON-Grund ist auswertbar; im WebView nicht (siehe Vorbemerkung).

Auch aus dem React-Frontend erreichbar, ohne eigenen Rust-Command: tauri-plugin-websocket — „Open a WebSocket connection using a Rust client in JavaScript" (https://v2.tauri.app/plugin/websocket/). Die ConnectionConfig enthält laut Referenz das Feld headers? neben maxMessageSize, maxFrameSize etc. (https://v2.tauri.app/reference/javascript/websocket/). Preis: es ist nicht die Browser-WebSocket-API — kein WebSocket-Objekt, keine wasClean-Semantik, IPC-Bridge im Datenpfad. (Der exakte Feldtyp und das Verhalten bei verbotenen Headern wurden nicht verifiziert.)

Proxy-Einschränkung, relevant für Firmenkunden: tokio-tungstenite bringt keinen eigenen HTTP-/SOCKS-Proxy-Support mit — connect_async ist nur mit „Connect to a given URL" dokumentiert (https://docs.rs/tokio-tungstenite/). Ein CONNECT-Tunnel muss selbst aufgebaut und der fertige Stream an client_async übergeben werden.

Pro Der einzige Weg, der von RFC 6750 §2.3 ausdrücklich als der richtige benannt wird; wird von nginx und Apache per Default nicht geloggt, von Caddy explizit redigiert; volle Fehlerdetails (401 mit Body) statt 1006; keine Zwei-Subprotokoll-Akrobatik.
Contra Setzt voraus, dass die Verbindung im Rust-Layer lebt (Kopplung an ein anderes Ticket); kein Proxy-Support in tokio-tungstenite → CONNECT-Tunnel selbst bauen, was bei Firmenkunden mit expliziter Proxy-Konfiguration Arbeit ist; über tauri-plugin-websocket verliert man die Browser-WebSocket-Semantik im Frontend.

1.7 Querschnitt-Option — kurzlebige Einweg-„Ticket"-Tokens

Nicht exklusiv zu A–E, sondern kombinierbar: ein Ticket per authentifiziertem REST-Call holen und dieses (statt des langlebigen JWT) an den Handshake hängen.

  • Azure Web PubSub „Client Access URL", Lebensdauer explizit konfigurierbar (expirationTimeInMinutes: 5):

    „The client negotiates with your application server. The application server contains the authorization middleware, which handles the client request and signs a JWT for the client to connect to the service. […] The client tries to connect to the Web PubSub service by using the URL and the JWT returned from the application server." (https://learn.microsoft.com/en-us/azure/azure-web-pubsub/howto-generate-client-access-url, ms.date: 2024-10-17)

  • Slack RTM: „The Websocket URLs provided by rtm.connect are single-use and are only valid for 30 seconds" (https://docs.slack.dev/legacy/legacy-rtm-api/) — Achtung, RTM ist als Legacy markiert.
  • Ably — Token-Request-Flow via authUrl/authCallback (https://ably.com/docs/auth/token).
  • python-websockets nennt Single-Use-Tokens ausdrücklich als Mitigation für den Query-Param-Weg.

Es gibt dafür keine RFC-/OWASP-Quelle — das ist Herstellerpraxis, kein Standard.

Pro Reduziert den Schaden eines Log-Leaks von „Sitzungsdauer" auf „Sekunden bis Minuten"; entkoppelt das langlebige JWT vom Handshake-Kanal; macht Option A dadurch deutlich weniger heikel.
Contra Zusätzlicher REST-Roundtrip vor jedem (Re-)Connect — verschärft den Thundering Herd (§4); zusätzlicher serverseitiger Zustand (Einmal-Verbrauch, Ablauf) — steht in Spannung zur „eine Binary + Postgres"-Regel der Map; kein Standard, jede Implementierung ist eigen.

1.8 Close-Codes für Auth-Fehler

IANA-Registry (https://www.iana.org/assignments/websocket/websocket.xhtml), Policy: 1000–2999 „Standards Action, Specification Required or IESG Review"; 3000–3999 „First Come First Served"; 4000–4999 „Reserved for Private Use". RFC 6455 §7.4.2 formuliert dasselbe.

Registriert und relevant: 1008 Policy Violation, 1011 Internal Error. Im 3000er-Bereich existieren 3000 Unauthorized, 3003 Forbidden, 3008 Timeout — alle ohne Reference-Spalte, also private First-Come-First-Served-Einträge ohne Spezifikation, keine Standardsemantik.

Etablierte Praxis im privaten Bereich:

System Codes
graphql-ws 4400 Bad Request, 4401 Unauthorized, 4403 Forbidden, 4408 Init-Timeout, 4429 Too many init requests
Discord 4003 Not authenticated (reconnect true), 4004 Authentication failed (reconnect false), 4005 Already authenticated
Pusher Bereichs-Semantik: 4000–4099 „SHOULD NOT be re-established unchanged"; 4100–4199 „SHOULD be re-established after backing off"; 4200–4299 „SHOULD be re-established immediately"
Centrifugo nicht-terminal: 3005 connection expired, 3011 force reconnect. Terminal: 3500 invalid token, 3502 stale, 3507 permission denied

Musterbefund: Alle vier Systeme kodieren nicht nur warum, sondern auch ob der Client reconnecten darf. Das ist die tragende Semantik, nicht die Fehlerbenennung — und sie ist die Voraussetzung dafür, dass §4.4 (Reconnect-Klassen) überhaupt funktioniert.


2. Token-Ablauf bei stundenlanger Verbindung

2.1 Die Grundsatzfrage: gilt Auth nur beim Handshake?

Die klarste Primärquelle ist Microsofts SignalR-Doku (https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz, updated_at: 2026-08-12):

„SignalR captures the authenticated user when a connection is established and caches it for the lifetime of the connection. […] SignalR doesn't automatically revalidate the user during the life of the connection, regardless of the authentication scheme. This behavior applies to all schemes, including cookie authentication and bearer token authentication." „Changes to a user's identity, roles, or claims that occur after the connection is established aren't reflected on an existing connection." „If a token expires during the lifetime of a connection, by default the connection continues to work."

Konkretes Szenario aus derselben Seite: Nutzer verliert die Editor-Rolle → „The user can keep calling Editor-only hub methods until the connection is closed." Die dort genannten Gegenmittel sind wörtlich:

„Close affected connections so that clients reconnect and reauthenticate. For bearer token authentication, the CloseOnAuthenticationExpiration option closes connections when the authentication token expires." „Perform authorization checks in hub methods against current data, such as the user's current roles or claims from a data store, instead of relying only on the cached principal."

OWASP WebSocket Security Cheat Sheet (kein verifizierbares Datum):

„Close WebSocket connections when sessions expire. Re-validate user sessions periodically (every 30 minutes is common)…" „Rotate tokens in long-lived connections to prevent hijacked sessions from persisting." „When users log out, close all their WebSocket connections immediately." „Don't assume WebSocket connection equals unlimited access. Check authorization for each action."

Negativer Befund: Auth0/Okta haben keine auffindbare WebSocket-spezifische Dokumentation zum Token-Ablauf. Alle Treffer waren Community-Inhalte. Nicht als Beleg verwenden.

2.2 Option A — Verbindungs-TTL / erzwungener Reconnect

AWS API Gateway WebSocket API, harte, nicht erhöhbare Quotas (https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-execution-service-websocket-limits-table.html):

Quota Wert Erhöhbar
Connection duration for WebSocket API 2 Stunden Nein
Idle Connection Timeout 10 Minuten Nein
WebSocket frame size 32 KB Nein
Message payload size 128 KB Nein

AWS AppSync Realtime: „AWS AppSync keeps sending these messages and servicing the registered subscriptions until it shuts down the connection automatically (after 24 hours)" (https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html).

Pro Einfachste denkbare Implementierung — ein Timer pro Verbindung; Auth-Zustand kann nie länger gültig sein als das Token; Revocation greift spätestens nach einer TTL, ohne dass der Server eine Liste führen muss.
Contra Multipliziert die Anzahl der Handshakes — bei 2 h TTL sind das 12 Handshakes pro Tag pro Client statt einem. In Kombination mit Option 1.2 (Query-String) heißt das 12 Log-Zeilen mit Token statt einer. Das ist die direkte Wechselwirkung zwischen §1 und §2: kurze TTL verschlimmert den Query-String-Nachteil linear. Zusätzlich: jeder erzwungene Reconnect ist ein Backfill-Zyklus (?since) und, wenn alle TTLs synchron ablaufen, ein selbstgemachter Thundering Herd (§4.3).

2.3 Option B — In-Band-Re-Auth über den offenen Socket

System Mechanismus Semantik Quelle
Centrifugo refresh-Command (und sub_refresh für Subscriptions): „Renew an expiring connection / subscription token." Client bekommt beim Connect ein ttl; „Centrifugo SDKs handle this ttl internally and automatically begin the refresh process." Nach Ablauf: Grace Period (Default 25 s), dann Disconnect 3005 connection expired Verbindung bleibt bestehen https://centrifugal.dev/docs/server/authentication
Ably authUrl/authCallback: „the SDK automatically: 1. Calls your auth endpoint when connecting 2. Requests a new token before the current token expires 3. Maintains the connection seamlessly during refresh". authorize() „maintains the existing connection while updating permissions". Ohne authUrl/authCallback: „if your token expires and an SDK has no way to get a new token […] the connection will enter the FAILED state" Verbindung bleibt bestehen https://ably.com/docs/auth/token
AWS AppSync Authorization wird pro Subscription-Registrierung erneut mitgesendet (start-Message hat extensions.authorization) Pro-Operation-Auth https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html
SignalR accessTokenFactory wird „before every HTTP request made by SignalR" aufgerufen — bei WebSockets faktisch nur beim (Re-)Connect, da es nur einen Request gibt de facto nur beim Reconnect https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz

Ablys Fehlercodes: 40140 = „token error (unspecified)", Bereich 40140 ≤ code < 40150 = Token-Fehler, 40142 = „token expired"; dokumentierte Client-Regel: einen automatischen Reissue-Versuch, dann Request wiederholen (https://github.com/ably/ably-common/blob/main/protocol/errors.json).

Pro Das neue Token wird im WS-Frame übertragen und landet damit in keinem Access-Log — der wesentliche Vorteil gegenüber reconnect-basiertem Refresh im gegebenen Bedrohungsmodell; keine Verbindungsabbrüche, kein Backfill-Zyklus, kein Reconnect-Sturm.
Contra Application-Layer-Auth statt HTTP-Statuscode; der Server muss den Refresh-Pfad selbst gegen Missbrauch härten (Rate-Limiting — Centrifugo braucht dafür eigene Codes 3013 too many requests, 3509 too many errors); zusätzliche Protokoll-Nachrichten im ansonsten als „Dumb Pipe" gesetzten Kanal (siehe Map: die Naht verläuft entlang persistiert vs. flüchtig — ein Re-Auth-Frame ist weder das eine noch das andere und braucht eine Einordnung).

2.4 Option C — Serverseitige Revocation-Liste

Firebase (https://firebase.google.com/docs/auth/admin/manage-sessions):

„Firebase ID tokens are short lived and last for an hour; the refresh token can be used to retrieve new ID tokens." „Because Firebase ID tokens are stateless JWTs, you can determine a token has been revoked only by requesting the token's status from the Firebase Authentication backend."

Revocation-Check ist dort opt-in (verifyIdToken(idToken, checkRevoked)); für die Realtime Database wird stattdessen ein Security-Rules-Muster dokumentiert (auth.token.auth_time > root.child('metadata').child(auth.uid).child('revokeTime').val()). Die Doku sagt nirgends, dass RTDB/Firestore bei Revocation eine bestehende Verbindung terminiert.

Pro Einziger Mechanismus, der auf „Logout auf allen Geräten" und Gerätediebstahl sofort reagiert, ohne die TTL abzuwarten; erlaubt lange Verbindungen ohne kurze Token-TTL.
Contra Im Bedrohungsmodell dieses Projekts per Definition wirkungslos gegen den Betreiber — er kontrolliert die Liste. Sie adressiert ausschließlich „gestohlenes Token bei ansonsten korrektem Server", also ein anderes Bedrohungsmodell, das im Entscheidungsticket getrennt geführt werden sollte. Praktisch: serverseitiger Zustand bei jedem Check (DB-Lookup pro Frame wäre teuer, Cache bringt Konsistenzfenster zurück).

2.5 Randnotiz: RFC 8441 (WebSockets über HTTP/2)

https://datatracker.ietf.org/doc/html/rfc8441 — Extended CONNECT mit :protocol = "websocket", ausgehandelt via SETTINGS_ENABLE_CONNECT_PROTOCOL. Für Auth ändert sich nichts:

„The Origin, Sec-WebSocket-Version, Sec-WebSocket-Protocol, and Sec-WebSocket-Extensions header fields are used in the CONNECT request." „Other HTTP request and response-header fields, such as those for manipulating cookies, may be included […] as usual." „The security considerations of [RFC6455], Section 10 continue to apply."

Sec-WebSocket-Key/Sec-WebSocket-Accept entfallen. Axum 0.8 unterstützt das (siehe §5.1); relevant vor allem, weil sich unter H2 die Logging-Formate ändern (:path statt Request-Line) — nicht separat verifiziert.


3. Heartbeat

3.1 Protokoll-Ebene (RFC 6455)

§5.5 Control Frames:

„All control frames MUST have a payload length of 125 bytes or less and MUST NOT be fragmented."

§5.5.2 Ping (Opcode 0x9):

„Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in response, unless it already received a Close frame. It SHOULD respond with Pong frame as soon as is practical." „An endpoint MAY send a Ping frame any time after the connection is established and before the connection is closed. A Ping frame may serve either as a keepalive or as a means to verify that the remote endpoint is still responsive."

§5.5.3 Pong (Opcode 0xA):

„A Pong frame sent in response to a Ping frame must have identical Application data as found in the message body of the Ping frame being replied to." „A Pong frame MAY be sent unsolicited. This serves as a unidirectional heartbeat."

(https://www.rfc-editor.org/rfc/rfc6455.txt)

Konsequenz: Der Ping-Payload (≤125 B) ist der einzige Korrelationsmechanismus auf Protokollebene — z. B. Sequenz-Counter oder Timestamp für RTT-Messung. Unsolicited Pong ist normkonform, erzeugt aber keine Antwort und taugt daher nur zum Idle-Timer-Reset, nicht zur Liveness-Prüfung.

3.2 Die Browser-Beschränkung — hart belegt

WHATWG WebSockets Standard, §5 „Ping and Pong frames" (https://websockets.spec.whatwg.org/):

„The WebSocket protocol defines Ping and Pong frames that can be used for keep-alive, heart-beats, network status probing, latency instrumentation, and so forth. These are not currently exposed in the API. User agents may send ping and unsolicited pong frames as desired […] User agents must not use pings or unsolicited pongs to aid the server; it is assumed that servers will solicit pongs whenever appropriate for the server's needs."

Die IDL kennt nur send() und close() — kein ping(), kein pong-Event; MDN listet exakt diese zwei Instance Methods und erwähnt Ping/Pong-Frames überhaupt nicht (https://developer.mozilla.org/en-US/docs/Web/API/WebSocket).

Browser antworten auf Server-Pings automatisch (RFC-konform), zeigen die Auto-Pongs aber nicht in den DevTools; Chrome sendet selbst keine Ping-Frames, sondern nutzt SO_KEEPALIVE auf TCP-Ebene (Issue-Tracker, keine normative Doku: https://issues.chromium.org/issues/41309915, https://bugzilla.mozilla.org/show_bug.cgi?id=920074).

Konsequenz für Tauri: Läuft die Verbindung im WebView (WebView2 / WKWebView / WebKitGTK, https://v2.tauri.app/reference/webview-versions/), ist ein Application-Level-Heartbeat (z. B. {"type":"ping"} als Text-Frame) der einzige Weg, clientseitig Liveness festzustellen. Genau deshalb bauen Socket.IO/Engine.IO eigene Ping-Packet-Typen statt RFC-6455-Control-Frames. Läuft sie im Rust-Layer, sind Protokoll-Pings verfügbar.

Negativer Befund: Weder https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API noch „Writing WebSocket client applications" enthalten heute einen Abschnitt „Proxy servers and firewalls" oder überhaupt eine Erwähnung von Proxies/Ping-Pong. Die früher oft zitierte MDN-Passage existiert nicht mehr und ist nicht zitierfähig.

3.3 Rust-Ebene — automatisches Pong

axum 0.8 Message-Doku (https://docs.rs/axum/0.8.9/axum/extract/ws/enum.Message.html), wörtlich:

Ping: „Ping messages will be automatically responded to by the server, so you do not have to worry about dealing with them yourself." Pong: „Pong messages will be automatically sent to the client if a ping message is received, so you do not have to worry about constructing them yourself unless you want to implement a unidirectional heartbeat."

tungstenite (https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocket.html):

„upon receiving ping messages tungstenite queues pong replies automatically. […] This means you should not respond to ping frames manually." „Note that if read returns a ping, you should flush before passing a custom pong to write, otherwise the automatic queued response to the ping will not be sent as it will be replaced by your custom pong message." (an read) „This will also queue responses to ping and close messages. These responses will be written and flushed on the next call to read, write or flush."

Drei praktische Fallstricke daraus: (a) Das Auto-Pong wird nur gequeued — ohne nachfolgendes read/write/flush geht es nicht raus (in einer dauerhaft gepollten Split-Stream-Schleife erfüllt); (b) der Handler sieht Ping/Pong trotzdem im Stream, muss aber nicht reagieren; (c) ein eigener Pong-Send kann das Auto-Pong verdrängen.

3.4 Warum TCP-Keepalive nicht reicht — vier belegte Gründe

man 7 tcp (https://man7.org/linux/man-pages/man7/tcp.7.html):

tcp_keepalive_time (integer; default: 7200) — „The number of seconds a connection needs to be idle before TCP begins sending out keep-alive probes. Keep-alives are sent only when the SO_KEEPALIVE socket option is enabled. […] Note that underlying connection tracking mechanisms and application timeouts may be much shorter."

  1. Per Default aus — greift erst nach explizitem SO_KEEPALIVE.
  2. 7200 s ist länger als jeder Proxy-Timeout aus der Tabelle in §3.5 (30 s … 600 s). Der erste Probe kommt, wenn die Verbindung längst tot ist.
  3. Keepalive-Probes tragen keine Nutzdaten und resetten L7-Idle-Timer nicht. AWS Classic ELB wörtlich: „TCP keep-alive probes do not prevent the load balancer from terminating the connection because they do not send data in the payload." (https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-idle-timeout.html)
  4. L7-Terminierung trennt die Verbindungen — nginx/ALB/AppGW halten zwei getrennte TCP-Sessions; ein Keepalive auf dem Client-Leg erreicht das Origin nie.

Gegenprobe: Bei L4-Load-Balancern (NLB, TCP-Listener) resetten TCP-Keepalives den Idle-Timer. HTTP/2-PING-Frames zählen beim ALB dagegen explizit nicht: „Application Load Balancers do not support HTTP/2 PING frames. These do not reset the connection idle timeout." (https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html)

3.5 Verhalten hinter Proxies und Corporate-Firewalls

Der härteste Beleg für „wss statt ws" — RFC 6455 §1.8:

„At the time of writing of this specification, it should be noted that connections on ports 80 and 443 have significantly different success rates, with connections on port 443 being significantly more likely to succeed, though this may change with time."

RFC 6455 §4.1 zum expliziten Proxy:

„If the client is configured to use a proxy when using the WebSocket Protocol to connect to host /host/ and port /port/, then the client SHOULD connect to that proxy and ask it to open a TCP connection" (Beispiel: CONNECT example.com:80 HTTP/1.1, optional mit Proxy-authorization) „Implementations […] are encouraged to use a SOCKS5 [RFC1928] proxy for WebSocket connections, if available, or failing that, to prefer the proxy configured for HTTPS connections over the proxy configured for HTTP connections."

RFC 6455 §10.3 erklärt, warum Client-Masking überhaupt existiert: damit Intermediaries WS-Frames nicht als HTTP-Requests fehlinterpretieren („a client MUST mask all frames that it sends to the server […] whether or not the WebSocket Protocol is running over TLS").

Idle-Timeouts der Infrastruktur — die Zahlen, die das Heartbeat-Intervall bestimmen:

Hop Default Konfigurierbar Quelle
Azure Application Gateway request timeout (gilt für die WS-Session!) 20 s 1–86400 s https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-websocket
GCP ALB backend service timeout 30 s 1 s–… https://docs.cloud.google.com/load-balancing/docs/https/request-distribution
nginx proxy_read_timeout 60 s ja https://nginx.org/en/docs/http/ngx_http_proxy_module.html
AWS ALB / Classic ELB idle 60 s 1–4000 s https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html
Azure Load Balancer TCP idle 4 min 4–100 min https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-tcp-idle-timeout
AWS NLB TCP/TLS 350 s 60–6000 s (TCP) AWS-Doku
AWS API Gateway WS idle 10 min nein s. §2.2
AWS API Gateway WS max. Dauer 2 h nein s. §2.2
GCP global ext. ALB, aktive WS 24 h (fix) nein GCP-Doku
Linux tcp_keepalive_time 7200 s ja man 7 tcp

nginx, Schlusssatz der WebSocket-Seite (https://nginx.org/en/docs/http/websocket.html), wörtlich:

„By default, the connection will be closed if the proxied server does not transmit any data within 60 seconds. This timeout can be increased with the proxy_read_timeout directive. Alternatively, the proxied server can be configured to periodically send WebSocket ping frames to reset the timeout and check if the connection is still alive."

Azure Application Gateway, wörtlich — der einzige Anbieter, der eine konkrete Faustregel für das Heartbeat-Intervall nennt:

„Ensure that your timeout value is greater than your server-defined ping/pong interval to avoid experiencing timeout errors before a ping is sent from the client. A typical value for a WebSocket is 20 seconds, so, for example, a timeout value of 40 seconds ensures that the gateway doesn't send a timeout error before the client sends a ping. Otherwise, this condition throws a 1006 error on the client side."

GCP, wichtige Unterscheidung:

Global external ALB: „Active websocket connections don't use the configured backend service timeout […] closed after 24 hours (86,400 seconds). This 24-hour limit is fixed […] Idle websocket connections are closed after the backend service times out." Classic ALB: „Websocket connections, whether idle or active, automatically close after the backend service times out." (Default 30 s!)

Cloudflare — Korrektur einer verbreiteten Zahl: Die kursierenden „100 Sekunden" stehen in keiner Cloudflare-Primärquelle. https://developers.cloudflare.com/network/websockets/ dokumentiert nur qualitativ:

„Cloudflare will close a WebSocket connection when no data is transmitted in either direction for a period of time. Enterprise customers can contact their account team to configure a custom idle timeout. To keep long-lived connections alive during periods of inactivity, implement a client-side heartbeat (ping/pong) mechanism." „When Cloudflare releases new code to its global network, we may restart servers, which terminates WebSockets connections."

HAProxy — Korrektur: Es gibt keine dokumentierten numerischen Defaults für timeout client/server/tunnel; die oft zitierten „50s" stammen aus Distro-Beispiel-Configs. Die Doku (https://docs.haproxy.org/3.0/configuration.html §4.2) sagt zu timeout tunnel:

„This timeout supersedes both the client and server timeouts once the connection becomes a tunnel. […] In HTTP, this timeout is used when a connection is upgraded (e.g. when switching to the WebSocket protocol…)" — Doku-Beispiel: timeout tunnel 1h

Tauri-spezifisch: Der System-WebView respektiert die OS-Proxy-Konfiguration; eine Tauri-API zum Setzen von Proxy-Einstellungen fehlte lange (https://github.com/tauri-apps/tauri/issues/4263, wry-Limitierung). Bei WebView2 lässt sich --proxy-server über AdditionalBrowserArguments setzen. Für den Rust-seitigen WS-Client gilt §1.6: CONNECT-Tunnel selbst bauen.

3.6 Übliche Intervalle etablierter Systeme

System Intervall Timeout Quelle
Socket.IO v4 pingInterval 25000 ms pingTimeout 20000 ms, connectTimeout 45000 ms https://socket.io/docs/v4/server-options/
Phoenix JS heartbeatIntervalMs 30000 ms DEFAULT_TIMEOUT 10000 ms https://github.com/phoenixframework/phoenix/blob/main/assets/js/phoenix/socket.js
Centrifugo client.ping_interval „25s" client.pong_timeout „8s", stale_close_delay „10s" https://centrifugal.dev/docs/server/configuration
Discord Gateway server-diktiert via heartbeat_interval (Doku-Beispiel 45000) kein ACK → Reconnect https://docs.discord.com/developers/events/gateway
Slack RTM (legacy) „every few seconds" — https://docs.slack.dev/legacy/legacy-rtm-api/
MQTT v5.0 Keep Alive (client-gewählt) 1,5 × Keep Alive https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html (07.03.2019)

Socket.IO, wörtlich — die klarste Formulierung der beidseitigen Erkennung:

„The server sends a ping packet every pingInterval ms, and if the client does not answer with a pong within pingTimeout ms, the server considers that the connection is closed. Similarly, if the client does not receive a ping packet from the server within pingInterval + pingTimeout ms, then the client also considers that the connection is closed." Warnung: „Using a small value like 1000 (one heartbeat per second) will incur some load on your server, which might become noticeable with a few thousands connected clients."

Discord, wörtlich — Jitter schon beim ersten Heartbeat:

„your app should wait heartbeat_interval * jitter where jitter is any random value between 0 and 1, then send its first Heartbeat." „If a client does not receive a heartbeat ACK between its attempts at sending heartbeats, this may be due to a failed or 'zombied' connection. The client should immediately terminate the connection with any close code besides 1000 or 1001, then reconnect and attempt to Resume."

MQTT v5.0 §3.1.2.10:

„If the Keep Alive value is non-zero and the Server does not receive an MQTT Control Packet from the Client within one and a half times the Keep Alive time period, it MUST close the Network Connection"

Slack RTM zeigt zusätzlich das Latenz-Muster: „a client could include a local timestamp in the ping message so it can calculate round-trip latency" (Server echot die Felder zurück) — das entspricht der RFC-6455-Payload-Korrelation aus §3.1.

Korrektur: Der oft genannte Discord-Wert 41250 ms ist nicht dokumentiert; die aktuelle Doku zeigt 45000 im Beispiel. Der Wert ist server-diktiert und nicht garantiert.

3.7 Optionen-Gegenüberstellung Heartbeat

Option Pro Contra
Protokoll-Ping/Pong (RFC 6455) Vom Standard vorgesehen; von axum/tungstenite serverseitig automatisch beantwortet, also kein Applikationscode für die Antwort; Payload-Korrelation für RTT; resettet L7-Idle-Timer, weil es echte Frames sind (im Gegensatz zu TCP-Keepalive) Im Browser-WebView nicht auslösbar und nicht beobachtbar (WHATWG: „not currently exposed in the API") → clientseitige Liveness-Erkennung unmöglich; unsolicited Pong erzeugt keine Antwort und beweist nichts
Application-Level-Heartbeat (JSON-Frame) Funktioniert in WebView und Rust-Layer identisch; beidseitige Liveness-Erkennung; kann Nutzlast tragen (Cursor, Server-Zeit, reconnect_after-Hinweis); von Socket.IO, Phoenix, Centrifugo, Discord durchgehend so gebaut Zusätzliche Protokoll-Nachrichten im als „Dumb Pipe" gesetzten Kanal; kostet Serverlast bei vielen Verbindungen (Socket.IO warnt explizit ab ~1 s Intervall bei „a few thousands connected clients"); doppelt sich mit dem Protokoll-Ping, wenn beides läuft
Nur TCP-Keepalive Kein Applikationscode Vier belegte Gründe dagegen (§3.4) — Default 7200 s, per Default aus, kein L7-Timer-Reset, greift nicht über L7-Terminierung hinweg

Intervall-Korridor aus den Zahlen: Die kleinsten dokumentierten Infrastruktur-Timeouts liegen bei 20 s (Azure AppGW) bzw. 30 s (GCP Classic ALB Default). Die etablierten Application-Heartbeats liegen bei 25–30 s. Azures eigene Faustregel („typical value for a WebSocket is 20 seconds […] timeout value of 40 seconds") beschreibt dasselbe Verhältnis von etwa 1:2 zwischen Heartbeat und Idle-Timeout.


4. Reconnect-Strategie

4.1 Exponentielles Backoff mit Jitter — die Referenz

Marc Brooker, „Exponential Backoff And Jitter", AWS Architecture Blog, 4. März 2015 (https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/), mit Autoren-Update: „Update (May 2023): After 8 years, this solution continues to serve as a pillar for how Amazon builds remote client libraries for resilient systems."

Verifikationshinweis: Die Formeln stehen im Artikel nicht als Text, sondern als PNGs mit leerem alt-Attribut. Die kursierenden Textvarianten (random(...), tmp = ...) sind falsch. Aus den Bildern ausgelesen:

# Capped Exponential Backoff ("No Jitter"), Figure 3
sleep = min(cap, base * 2 ** attempt)

# Full Jitter, Figure 6
sleep = random_between(0, min(cap, base * 2 ** attempt))

# Equal Jitter, Figure 10
temp  = min(cap, base * 2 ** attempt)
sleep = temp / 2 + random_between(0, temp / 2)

# Decorrelated Jitter, Figure 11
sleep = min(cap, random_between(base, sleep * 3))

Zweiter Verifikationshinweis: Der Artikel enthält keine Ergebnistabelle — nur 13 Grafiken und Fließtext. Die quantitativen Aussagen sind qualitativ formuliert:

„With N clients contending, the total amount of work done by the system increases with N²." „In the case with 100 contending clients, we've reduced our call count by more than half." „The no-jitter exponential backoff approach is the clear loser. It not only takes more work, but also takes more time than the jittered approaches." „Of the jittered approaches, 'Equal Jitter' is the loser. It does slightly more work than 'Full Jitter', and takes much longer. The decision between 'Decorrelated Jitter' and 'Full Jitter' is less clear. The 'Full Jitter' approach uses less work, but slightly more time." „It's worth noting that none of these approaches fundamentally change the N² nature of the work to be done, but do substantially reduce work at reasonable levels of contention. The return on implementation complexity of using jittered backoff is huge, and it should be considered a standard approach for remote clients."

Simulator-Code: https://github.com/awslabs/aws-arch-backoff-simulator

4.2 Weitere Anbieter-Formeln und -Regeln

Google Cloud, „truncated exponential backoff" (https://docs.cloud.google.com/memorystore/docs/redis/exponential-backoff, Stand 2026-07-29):

„The wait time is min(((2^n)+random_number_milliseconds), maximum_backoff), with n incremented by 1 for each iteration (request)." „random_number_milliseconds is a random number of milliseconds less than or equal to 1000. This helps to avoid cases where many clients get synchronized by some situation and all retry at once, sending requests in synchronized waves." maximum_backoff „is typically 32 or 64 seconds." „It's okay to continue retrying once you reach the maximum_backoff time."

Konkrete GCP-Client-Library-Defaults (https://docs.cloud.google.com/storage/docs/retry-strategy): Go 1 s / ×2.0 / max 30 s; Java 1 s / ×2.0 / max 32 s / total 50 s; Node.js 1 s / ×2 / max 64 s / total 600 s.

AWS SDK Retry Behavior (https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) — Achtung, die Seite beschreibt inzwischen das 2026er-Verhalten (Opt-in via AWS_NEW_RETRIES_2026=true): retry_mode = standard, max_attempts = 3. Formel wörtlich: delay = random(0, 1) × min(20,000 ms, base_delay × 2^retry) — „The SDK uses exponential backoff with full jitter." Base delay: transient 50 ms, throttling 1000 ms. Zusätzlich ein Retry-Quota-Token-Bucket (500 Tokens, 14 pro transientem Retry, 5 pro Throttling-Retry, +1 bei Erfolg ohne Retry) — d. h. Retries sind global gedeckelt, nicht nur pro Request.

Azure Transient-Fault-Guidance (https://learn.microsoft.com/en-us/azure/architecture/best-practices/transient-faults, ms.date: 2026-02-26) — die dichteste Sammlung harter Regeln:

„Add jitter, which is a small random delay, to each retry interval to prevent multiple clients from syncing their retries and creating load spikes on the target service." „Use an exponential back-off strategy with jitter for background operations, and use immediate or regular interval retry strategies for interactive operations." „Never implement an endless retry mechanism. This approach typically prevents the resource or service from recovering from overload situations." „Never do an immediate retry more than once." „Implement a retry budget to limit the total number of retries across all requests within a process or service… For example, you might allow a process to do no more than 60 retries per minute against a specified dependency." „When a response includes a Retry-After header, follow it and wait at least the specified duration before the next attempt. This server-provided signal reflects the service's recovery timeline and takes precedence over your client-side back-off calculation." „Most 4xx client errors, like 400, 401, 403, and 404, indicate problems that a retry doesn't resolve."

Korrektur: learn.microsoft.com/.../best-practices/retry-service-specific liefert HTTP 301 — die alten service-spezifischen Default-Tabellen sind nicht mehr zitierbar.

4.3 Konkrete Parameter dokumentierter WS-Clients

Socket.IO v4 (https://socket.io/docs/v4/client-options/):

Option Default Doku-Text
reconnectionAttempts Infinity —
reconnectionDelay 1000 „The initial delay before reconnection in milliseconds (affected by the randomizationFactor value)."
reconnectionDelayMax 5000 „The maximum delay between two reconnection attempts. Each attempt increases the reconnection delay by 2x."
randomizationFactor 0.5 „The randomization factor used when reconnecting (so that the clients do not reconnect at the exact same time after a server crash, for example)."
timeout 20000 „The timeout in milliseconds for each connection attempt."

Rechenbeispiel aus der Doku: 1. Versuch zwischen 500 und 1500 ms, 2. zwischen 1000 und 3000 ms, 3. zwischen 2000 und 5000 ms, danach 5000 ms. (Die Doku enthält an dieser Stelle einen Tippfehler — der Multiplikator ist uniform in [0.5, 1.5], nicht „-0.5 and 1.5". Beim 3. Versuch ist die Obergrenze 5000 statt 6000, weil reconnectionDelayMax die gejitterte Zahl deckelt.)

Phoenix JS (https://github.com/phoenixframework/phoenix/blob/main/assets/js/phoenix/socket.js):

this.reconnectAfterMs = (tries) => { … return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000 }
this.rejoinAfterMs    = (tries) => { … return [1000, 2000, 5000][tries - 1] || 10000 }

Korrektur zur Ticket-Formulierung: Es ist ein 9-elementiges Array mit Fallback 5000 ab Versuch 10 — nicht 10 Elemente. rejoinAfterMs-Fallback ist 10000. Kein maxReconnectAttempts, Phoenix reconnected unbegrenzt. Bemerkenswert: kein Jitter, dafür ein sehr aggressiver Start (10 ms) für den häufigsten Fall — den kurzen Netz-Glitch.

centrifuge-js (https://github.com/centrifugal/centrifuge-js):

minReconnectDelay: „When client disconnected from a server it will automatically try to reconnect using a backoff algorithm with jitter. […] Default is 500 milliseconds." maxReconnectDelay: „sets an upper reconnect delay value. Default is 20000 milliseconds" maxServerPingDelay: „sets the maximum delay of server pings after which connection is considered broken and client reconnects. […] Default is 10000."

Ably (https://ably.com/docs/connect/states):

„if the connection drops at any time it will attempt to re-establish it by making repeated connection attempts every 15 seconds for up to two minutes." suspended: „In the suspended state, an SDK will periodically attempt to open a new connection every 30 seconds." (disconnectedRetryTimeout 15000 ms, suspendedRetryTimeout 30000 ms.)

Spannbreite der dokumentierten Praxis: Startverzögerung 10 ms (Phoenix) bis 15 s (Ably); Obergrenze 5 s (Socket.IO) bis 30 s (Ably) bis 64 s (GCP). Jitter: Socket.IO ±50 %, GCP +≤1000 ms, AWS Full Jitter, centrifuge-js „mit Jitter" (Faktor nicht dokumentiert), Phoenix gar keiner.

4.4 Welcher Fehler rechtfertigt welchen Reconnect?

RFC 6455 §7.4.1, die für dieses Projekt relevanten Codes:

Code Bedeutung
1000 Normal Closure
1001 Going Away (Server-Shutdown oder Browser-Navigation)
1005 No Status Rcvd — nur intern, nie auf dem Draht
1006 Abnormal Closure — nur intern, kein Close-Frame empfangen
1008 Policy Violation
1009 Message Too Big
1011 Internal Error

1006 ist der Code, den man bei jedem stillen Proxy-Kill sieht — genau der Fall, den Azure AppGW dokumentiert („throws a 1006 error on the client side", §3.5). 1005/1006/1015 dürfen nie in einem Close-Frame gesendet werden.

Discord Gateway Close Event Codes — die einzige öffentlich vollständige reconnect: true/false-Tabelle (https://docs.discord.com/developers/topics/opcodes-and-status-codes):

Code Beschreibung Reconnect
4000 Unknown error true
4003 Not authenticated true
4004 Authentication failed false
4005 Already authenticated true
4008 Rate limited true
4009 Session timed out true
4010–4014 Invalid shard / Sharding required / Invalid API version / Invalid+Disallowed intents false

Discords Leitsatz: „In order to prevent broken reconnect loops, you should consider some close codes as a signal to stop reconnecting. This can be because your token expired, or your identification is invalid."

Das dokumentierte Muster ist eine 3-Klassen-Einteilung: (a) sofort/schnell — server-initiierte Umverteilung (Discord Opcode 7, 4009, 1001 Going Away), (b) Backoff — transiente Fehler, 1006, 1011, Rate-Limits, (c) gar nicht — Auth-/Konfigurationsfehler, analog zu Azures „Most 4xx client errors […] indicate problems that a retry doesn't resolve."

Pusher kodiert dieselbe Dreiteilung in Code-Bereichen (4000–4099 / 4100–4199 / 4200–4299, §1.8), Centrifugo in terminal/nicht-terminal.

4.5 Thundering Herd beim Server-Neustart

Discord, „You've Got (Too Much) Mail: Behind the Scenes of the 3/25/26 Voice Outage", publiziert 29. April 2026 (https://discord.com/blog/behind-the-scenes-of-the-3-25-26-voice-outage) — der aktuellste datierte Praxisbericht zum Thema:

  • Auslöser: „17% of sessions across Discord were ungracefully stopped." → „each gateway process for each affected user gets a :DOWN message, signaling to the client that their session is now invalid and requires a reconnection."
  • Verstärkung: „a selective receive on a supervisor with a ~100k mailbox queue adds ~1ms to process spawn time" — die Supervisor-Mailbox erreichte ~1 Mio. Einträge; „new connections are blocked because they cannot be opened within the connection timeout."
  • Mitigationen: Rate-Limiting („low syncer spawn limits"), Architektur (PartitionSupervisor statt einzelnem GenServer), Kapazität, und gestaffelte Wiederaufnahme statt Vollneustart.
  • Kernaussage: Der Recovery-Verkehr selbst war das Problem. Erholung durch Reduktion eingehender Arbeit und schrittweises Zurückholen der Systeme.

Slack, „Migrating Millions of Concurrent Websockets to Envoy", 15. März 2021 (Update 21. Juni 2021), Ariane van der Stedt & Radha Kumari (https://slack.engineering/migrating-millions-of-concurrent-websockets-to-envoy/) — relevant fürs Deployment-Design:

„We'd keep running the old process for many hours to allow long-lived websocket connections to drain and avoid frequent disconnections of users."

Einschränkung: Der Artikel behandelt Reconnect-Stürme und Client-Backoff nicht. Die verbreiteten Aussagen über Slacks „Flannel"- und „Governor"-Komponenten stammen aus Sekundär-Blogs und sind nicht zitierfähig.

Negativbefund: „How Discord Handles Two and Half Million Concurrent Voice Users using WebRTC" (12.09.2018, https://discord.com/blog/how-discord-handles-two-and-half-million-concurrent-voice-users-using-webrtc) behandelt WebRTC-Voice-Skalierung, nicht WebSocket-Reconnect-Stürme.

Server-gesteuerte Reconnects — dokumentierte Muster:

Muster Beleg
Server sagt „reconnect jetzt" — Discord Opcode 7 „Reconnect": „You should attempt to reconnect and resume immediately." Der Server verteilt Verbindungen gestaffelt selbst um, statt sie alle gleichzeitig zu droppen https://docs.discord.com/developers/topics/opcodes-and-status-codes
Server gibt die Ziel-URL vor — Discord resume_gateway_url im Ready-Event → Lastverteilung beim Reconnect ist serverseitig steuerbar ebd.
Retry-After beim 503 auf den Handshake — RFC 9110 §10.2.3: „The 'Retry-After' header field indicates how long a user agent ought to wait before making a follow-up request." ABNF: Retry-After = HTTP-date / delay-seconds. §15.6.4: „The server SHOULD generate a 'Retry-After' header field to indicate when the client may retry the request." Da der WS-Handshake ein normaler HTTP-Request ist, voll anwendbar — und laut Azure überstimmt er die Client-Backoff-Berechnung https://www.rfc-editor.org/rfc/rfc9110.txt
Verbindungs-Draining statt Hard-Cut — alten Prozess stundenlang weiterlaufen lassen Slack, 15.03.2021
Rate-Limiting des Recovery-Pfads Discord, 29.04.2026

Optionen-Gegenüberstellung Thundering Herd:

Option Pro Contra
Nur Client-Jitter Kein Serverzustand, kein Protokoll; von AWS als „standard approach for remote clients" bezeichnet Ändert laut Brooker die N²-Natur des Problems nicht, reduziert sie nur; hilft nicht, wenn der Server nach dem Neustart sofort wieder überlastet ist
Server-gesteuerter Reconnect-Hinweis (Close-Code + reconnect_after, oder Retry-After beim 503) Der Server kennt seinen Zustand; Azure sagt explizit, dass dieser Wert die Client-Berechnung überstimmt; erlaubt gestaffeltes Zurückholen wie bei Discord Braucht ein Protokollfeld bzw. einen 503-Pfad und Client-Logik, die ihn respektiert; nützt nichts, wenn der Server gar nicht antwortet (Neustart-Fenster)
Draining beim Deployment Vermeidet den Sturm ganz statt ihn abzufedern Zwei Prozessgenerationen parallel — kollidiert mit der Map-Regel „eine Binary + Postgres" und der In-Process-Fan-out-Entscheidung
Rate-Limiting der Accept-Rate Schützt den Server unabhängig vom Client-Verhalten; Discords wirksamste Maßnahme im Vorfall vom 25.03.2026 Verschiebt das Problem in die Client-Backoff-Schleife; braucht sinnvolle 503-/Close-Code-Antworten, sonst produziert es blindes Retry

4.6 Netzwerkwechsel-Erkennung

MDN Navigator.onLine (https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine), wörtlich:

„On Windows, the online status is determined by whether it can reach a Microsoft home server, which may be blocked by firewalls or VPNs, even if the computer has Internet access. Therefore, this property is inherently unreliable, and you should not disable features based on the online status, only provide hints when the user may seem offline."

Praktische Asymmetrie (MDN Window: offline_event): offline ist verlässlich, online nicht (Netz ja, Internet vielleicht). → online taugt als Trigger, den Backoff-Timer zurückzusetzen, nicht als Beweis für Konnektivität. Für Firmen-VPNs ist die Windows-Heuristik zusätzlich unzuverlässig — direkt relevant für die Zielgruppe.

Tauri v2 hat kein Core-API für Netzwerk-Status. Community-Plugins: tauri-plugin-network-information (iOS/Android, connection() + Change-Listener, https://github.com/wtto00/tauri-plugin-network-information), tauri-plugin-network (Desktop, https://github.com/HuakunShen/tauri-plugin-network). Auf Mobile im Foreground gilt zusätzlich: OS-seitige App-Suspension beendet die WS-Verbindung ohne Close-Frame → 1006 beim Wiederaufwachen.


5. Axum 0.8 konkret

5.0 Bestandsaufnahme rustification-server

Gelesen, nichts geändert. Relevante Dateien:

  • C:\Projekte\Privat\Rustification\rustification-server\Cargo.toml
  • C:\Projekte\Privat\Rustification\rustification-server\src\main.rs
  • C:\Projekte\Privat\Rustification\rustification-server\src\route.rs
  • C:\Projekte\Privat\Rustification\rustification-server\src\middleware.rs
  • C:\Projekte\Privat\Rustification\rustification-server\src\model.rs (Claims { sub: String, exp: usize })
edition = "2024"
axum = "0.8.9"                # KEIN features = ["ws"]
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.9", features = ["runtime-tokio","tls-rustls-ring-webpki","postgres","chrono","uuid"] }
jsonwebtoken = "9"

Nicht vorhanden und für WS nötig: axum-Feature ws; futures/futures-util (für StreamExt::split); tokio-util (für CancellationToken/TaskTracker); axum-extra (für TypedHeader).

  • AppState (main.rs:14-17): pub struct AppState { pub db: PgPool, pub jwt_secret: String } — nicht Clone, überall als Arc<AppState>; State-Typ ist also State<Arc<AppState>>.
  • Router (main.rs:46-48): Router::new().nest("/api/v1", route::router(state.clone())).with_state(state).
  • Auth-Middleware: axum::middleware::from_fn_with_state(state, require_auth) als .layer(...) auf dem protected-Subrouter (route.rs:104). require_auth validiert das Bearer-JWT und macht req.extensions_mut().insert(data.claims) (middleware.rs:31). Kein FromRequestParts-Extractor.
  • Auffällig: Extension<Claims> wird in keinem Handler gelesen — die eingefügte Extension ist derzeit ungenutzt.
  • Graceful Shutdown existiert nicht: main.rs:52 ist axum::serve(listener, app).await.unwrap(); Auch kein into_make_service_with_connect_info — ConnectInfo würde zur Laufzeit fehlschlagen.

5.1 WebSocket-Upgrade mit State

axum::extract::ws ist „available via the ws crate feature" (https://docs.rs/axum/0.8.9/axum/extract/ws/index.html).

Breaking Changes 0.7 → 0.8 (https://github.com/tokio-rs/axum/blob/main/axum/CHANGELOG.md):

breaking: axum::extract::ws::Message now uses Bytes in place of Vec<u8>, and a new Utf8Bytes type in place of String, for its variants (#3078) changed: Upgraded tokio-tungstenite to 0.26 (#3078) breaking: Remove WebSocket::close. Users should explicitly send close messages themselves. (#2974) added: Add support for WebSockets over HTTP/2. They can be enabled by changing get(ws_endpoint) handlers to any(ws_endpoint) (#2894) breaking: Make serve generic over the listener and IO types (#2941)

(Hinweis: Die Überschrift im Repo lautet # 0.8.0 (27. November, 2023) — ein Tippfehler im axum-Repo; das echte 0.8.0-Release ist https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0 vom 01.01.2025. Der Blogpost erwähnt WebSockets nicht; die WS-Änderungen stehen nur im CHANGELOG.)

Message-Varianten: Text(Utf8Bytes), Binary(Bytes), Ping(Bytes), Pong(Bytes), Close(Option<CloseFrame>).

Extractor-Reihenfolge — WebSocketUpgrade ist FromRequestParts. Quellcode (https://github.com/tokio-rs/axum/blob/main/axum/src/extract/ws.rs):

impl<S> FromRequestParts<S> for WebSocketUpgrade<DefaultOnFailedUpgrade> where S: Send + Sync {
    type Rejection = WebSocketUpgradeRejection;
    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        // HTTP/1.1: Methode MUSS GET sein, Connection: upgrade, Upgrade: websocket, Sec-WebSocket-Key
        // HTTP/2:   Methode MUSS CONNECT sein, hyper::ext::Protocol == "websocket"
        // Sec-WebSocket-Version MUSS "13" sein
        let on_upgrade = parts.extensions.remove::<hyper::upgrade::OnUpgrade>()
            .ok_or(ConnectionNotUpgradable)?;
        …
    }
}

Die Regel „Extractors always run in the order of the function parameters, from left to right" und „der letzte Extractor muss FromRequest implementieren" gilt in 0.8 unverändert (https://docs.rs/axum/0.8.9/axum/extract/index.html) — betrifft WS aber nicht, weil WebSocketUpgrade, State, Extension, Query, Path, TypedHeader, ConnectInfo und HeaderMap alle FromRequestParts sind. Die Reihenfolge ist frei:

async fn ws_handler(
    ws: WebSocketUpgrade,
    State(state): State<Arc<AppState>>,
    Extension(claims): Extension<Claims>,
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Response { ws.on_upgrade(move |socket| handle(socket, state, claims, addr)) }

Handshake-Konfiguration in 0.8.9 (https://docs.rs/axum/0.8.9/axum/extract/ws/struct.WebSocketUpgrade.html):

Methode Default / Semantik
max_message_size(usize) Default 64 MB
max_frame_size(usize) Default 16 MB
read_buffer_size(usize) 128 KiB
write_buffer_size(usize) / max_write_buffer_size(usize) 128 KiB / unlimited
accept_unmasked_frames(bool) false
protocols<I>(I) Subprotokoll-Negotiation
requested_protocols() / set_selected_protocol(HeaderValue) neu in 0.8.9 (#3597) — erlaubt eigene Auswahl-Logik, relevant für Option 1.3
selected_protocol() gewähltes Subprotokoll
on_failed_upgrade<C>(C) Callback, wenn das Upgrade in der Hintergrund-Task fehlschlägt

Struct-Doku: „For HTTP/1.1 requests, this extractor requires the request method to be GET; in later versions, CONNECT is used instead. To support both, it should be used with any."

Serverseitig das ausgehandelte Subprotokoll lesen: WebSocket::protocol() -> Option<&HeaderValue> — „Return the selected WebSocket subprotocol, if one has been chosen." (https://docs.rs/axum/0.8.9/axum/extract/ws/struct.WebSocket.html)

close_code-Konstanten (aus axum/src/extract/ws.rs) — der Name für 1001 ist in axum AWAY, nicht GOING_AWAY:

NORMAL = 1000, AWAY = 1001, PROTOCOL = 1002, UNSUPPORTED = 1003, STATUS = 1005,
ABNORMAL = 1006, INVALID = 1007, POLICY = 1008, SIZE = 1009, EXTENSION = 1010,
ERROR = 1011, RESTART = 1012 /* "the server is restarting" */, AGAIN = 1013 /* overloaded */

1012 RESTART und 1013 AGAIN sind für §4.5 (Thundering Herd) unmittelbar relevant.

Verbindungsdaten: ConnectInfo<T> — „Note this extractor requires you to use Router::into_make_service_with_connect_info to run your app otherwise it will fail at runtime" (https://docs.rs/axum/0.8.9/axum/extract/struct.ConnectInfo.html). Das fehlt im aktuellen main.rs. TypedHeader<T> lebt seit 0.7 in axum-extra (Feature typed-header).

Offizielle Beispiele (alle vorhanden):

5.2 Greift middleware::from_fn_with_state beim Upgrade-Request? — Ja.

Der Beleg steckt im Quellcode von on_upgrade (https://github.com/tokio-rs/axum/blob/main/axum/src/extract/ws.rs):

pub fn on_upgrade<C, Fut>(self, callback: C) -> Response {
    tokio::spawn(async move {
        let upgraded = match on_upgrade.await { … };          // hyper::upgrade::OnUpgrade
        let socket = WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, Some(config)).await;
        callback(WebSocket { inner: socket, protocol }).await;
    });

    let mut response = Response::builder()
        .status(StatusCode::SWITCHING_PROTOCOLS)
        .header(header::CONNECTION, UPGRADE)
        .header(header::UPGRADE, WEBSOCKET)
        .header(header::SEC_WEBSOCKET_ACCEPT, sign(sec_websocket_key.as_bytes()))
        .body(Body::empty()).unwrap();
    …
    response
}

Der Handler kehrt sofort mit einem gewöhnlichen Response (101) zurück. Der Upgrade-Request ist bis dahin ein normaler HTTP/1.1-GET (bzw. HTTP/2-CONNECT) und durchläuft die volle Tower-Kette wie jede REST-Route. Erst hyper::upgrade::OnUpgrade resolved in der separaten tokio::spawn-Task, nachdem hyper die 101 geschrieben und die IO gehijackt hat (https://docs.rs/hyper/latest/hyper/upgrade/index.html).

Kann Middleware mit 401 ablehnen? Ja. Die bestehende require_auth gibt Err((StatusCode::UNAUTHORIZED, …)) zurück, bevor next.run(req) läuft — der Handler wird nie aufgerufen, on_upgrade nie erreicht, hyper sendet keine 101. Standard-Tower-Pfad, nichts WS-Spezifisches. Was der Client davon sieht, hängt an der Vorbemerkung (WebView: nur 1006).

Extensions — zwei Fallstricke:

  1. Von der Middleware via extensions_mut().insert(claims) eingefügte Werte sind im Handler über Extension<Claims> sichtbar (Standardmechanik von from_fn).
  2. WebSocketUpgrade::from_request_parts macht parts.extensions.remove::<hyper::upgrade::OnUpgrade>(). Baut eine Middleware den Request neu, ohne extensions zu übernehmen, ist OnUpgrade weg → Rejection ConnectionNotUpgradable. Das ist der wichtigste Middleware-Fallstrick bei WS.
  3. Die on_upgrade-Closure ist 'static (C: FnOnce(WebSocket) -> Fut + Send + 'static). Request-Extensions überleben nicht — Parts wird nach dem Handler gedroppt. Alles, was die Task braucht (Claims, Arc<AppState>, SocketAddr), muss vorher extrahiert/geklont und per move hineingezogen werden.

Response-Middleware nach 101 / Timeouts: tower_http::timeout::TimeoutLayer misst bis zum Response — bei WS ist das sofort (101 mit Body::empty()). Die spätere WS-Task ist davon nicht betroffen; ResponseBodyTimeoutLayer greift auf einem leeren Body ebenfalls nicht (https://docs.rs/tower-http/latest/tower_http/timeout/index.html). ⚠️ Nicht belegbar: Es wurde kein Issue gefunden, das nachweist, dass TimeoutLayer eine bereits geupgradete WS-Verbindung abbricht. Gefundene, aber andere Probleme betreffende Issues: axum#1260 („server stucks when large of websocket connection closed"), axum Discussion #2597, axum#2964. Das realere Risiko ist Middleware, die den Request-Body puffert oder den Request rekonstruiert und dabei Extensions verliert.

Alternative: Auth im Handler per eigenem FromRequestParts-Extractor (https://docs.rs/axum/0.8.9/axum/extract/trait.FromRequestParts.html):

Middleware (from_fn_with_state) Eigener FromRequestParts-Extractor
Pro Ein Ort für die ganze Route-Gruppe; kann 401 senden, ohne den Handler zu bauen; ist der heutige Bestandscode Typsicher — kein Extension<Claims>-Panic bei vergessenem Layer; kann Query-/Subprotokoll-Token im selben Schritt lesen (relevant für Optionen 1.2/1.3); pro Handler komponierbar
Contra Extension<Claims> panikt zur Laufzeit, wenn der Layer fehlt; liest Token nur aus dem Header, es sei denn man baut das aus Muss an jeden Handler geschrieben werden; State-Zugriff braucht FromRef oder S = Arc<AppState>

Beide sind kombinierbar: Middleware für REST, zusätzlicher Extractor für WS mit anderem Token-Kanal.

5.3 Verbindungsverwaltung und Graceful Shutdown

Was axum::serve(...).with_graceful_shutdown(...) tut — Quellcode axum/src/serve/mod.rs (https://github.com/tokio-rs/axum/blob/main/axum/src/serve/mod.rs):

let (signal_tx, signal_rx) = watch::channel(());
executor.execute(async move { signal.await; drop(signal_rx); });
let (close_tx, close_rx) = watch::channel(());

loop {
    let (io, remote_addr) = match select(pin!(listener.accept()), pin!(signal_tx.closed())).await {
        Either::Left((conn, _)) => conn,
        Either::Right(_) => break,          // keine neuen Verbindungen mehr
    };
    handle_connection(&mut make_service, &signal_tx, &close_rx, io, remote_addr, &executor).await;
}
drop(close_rx); drop(listener);
close_tx.closed().await;                     // wartet auf die Connection-Tasks

Belege, dass upgegradete Verbindungen NICHT beendet und NICHT abgewartet werden:

  1. Der close_rx-Guard hängt nur an der hyper-Connection-Task. Die WS-Task ist ein eigenständiges tokio::spawn in on_upgrade ohne close_rx ⇒ close_tx.closed().await wartet nicht auf sie.
  2. hyper_util-UpgradeableConnection::graceful_shutdown (https://docs.rs/hyper-util/latest/hyper_util/server/conn/auto/struct.UpgradeableConnection.html):

    „This should only be called while the Connection future is still pending. If called after UpgradeableConnection::poll has resolved, this does nothing." Nach dem Hijack ist die Connection-Future resolved ⇒ der Aufruf ist ein No-op; das Socket gehört jetzt dem WebSocketStream.

  3. axum Issue #3003 — „Graceful Shutdown ignores Websocket handler task", eröffnet 21.10.2024, Status offen:

    „the task spawned in ws.on_upgrade is not connected to the graceful shutdown future at all" Und: selbst mit eigenem CancellationToken ist nicht garantiert, dass die Verbindung geschlossen ist, wenn die Shutdown-Future resolved.

  4. axum PR #3831 — „fix(serve): include WebSocket tasks in graceful shutdown", eingereicht 11.07.2026, letzte Aktivität 28.07.2026, Status offen:

    „WebSocket handlers escape all of it … the shutdown future then resolves while sockets are still live, so handler state outlives 'shutdown'" Der PR fügt eine GracefulPeer-Struktur mit Connection-Guards in die Request-Extensions ein und sendet beim Shutdown einen Close-Frame.

  5. Gegenbeispiel zur Abgrenzung: axum#2673 — SSE blockiert den Graceful Shutdown (kein Upgrade, hyper-Connection bleibt offen), WS nicht.

⇒ Faktenlage: Solange #3831 nicht gemerged ist, muss das Projekt WS-Verbindungen selbst tracken und beenden. WithGracefulShutdown-Doku (https://docs.rs/axum/0.8.9/axum/serve/struct.WithGracefulShutdown.html) sagt zu Upgrades nichts; axum::serve ist dokumentiert als „intentionally simple and doesn't support any configuration" (https://docs.rs/axum/0.8.9/axum/fn.serve.html).

Tracking-Bausteine, jeweils belegt:

Baustein Doku-Zitat Eignung
tokio_util::sync::CancellationToken (https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html) cancelled(): „Returns a Future that gets fulfilled when cancellation is requested. […] This method is cancel safe." · child_token(): „Creates a CancellationToken which will get cancelled whenever the current token gets cancelled." (Cancel eines Childs cancelt den Parent nicht) · cancelled_owned() für 'static-Closures Ein Token pro Verbindung, abgeleitet vom globalen Shutdown-Token
tokio_util::task::TaskTracker (https://docs.rs/tokio-util/latest/tokio_util/task/task_tracker/struct.TaskTracker.html) „A task tracker used for waiting until tasks exit." · wait: „Waits until this TaskTracker is both closed and empty" (cancel safe) · gegenüber JoinSet: „When tasks exit, a TaskTracker will allow the task to immediately free its memory"; „does not require mutable access to insert tasks"; „can be cloned to share it with many tasks" Warten auf N WS-Tasks beim Shutdown; die Doku enthält ein vollständiges Beispiel TaskTracker + CancellationToken genau dafür
tokio::sync::watch (https://docs.rs/tokio/latest/tokio/sync/watch/index.html) „Sender::is_closed and Sender::closed allow the producer to detect when all Receiver handles have been dropped." Genau das Muster, das axum::serve intern benutzt (signal_tx/close_tx)
JoinSet (https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html) braucht &mut, hält Rückgabewerte Laut TaskTracker-Doku für viele lang laufende unabhängige Tasks die schlechtere Wahl

Muster in der Verbindungsschleife (Ableitung aus den obigen Docs + axum-Beispiel):

let token = shutdown_token.child_token();
loop {
    tokio::select! {
        _ = token.cancelled() => {                       // cancel safe (Doku oben)
            let _ = sender.send(Message::Close(Some(CloseFrame {
                code: close_code::AWAY,                  // 1001 "going away"
                reason: "server shutting down".into(),
            }))).await;
            break;
        }
        msg = receiver.next() => { … }
        ev  = rx.recv()      => { … }                    // broadcast::Receiver::recv, cancel safe
    }
}

WebSocket::close existiert seit 0.8 nicht mehr (CHANGELOG #2974) → Close-Frame explizit über send. Für einen Neustart wäre alternativ close_code::RESTART (1012) protokollierbar (§5.1) — was direkt an §4.4 anschließt.

Cancel-Safety — tokio::select!-Doku (https://docs.rs/tokio/latest/tokio/macro.select.html):

„Cancellation safety describes what happens when a future is dropped before it completes." Grundregel: „If you have a future that has not yet completed, then it must be a no-op to drop that future and recreate it"

Als cancel-sicher gelistet: mpsc::Receiver::recv, broadcast::Receiver::recv, watch::Receiver::changed, AsyncReadExt::read, sowie pauschal „Stream operations via tokio_stream and futures crates". Nicht cancel-sicher: read_exact, read_to_end, write_all, Mutex::lock, RwLock::read/write, Semaphore::acquire, Notify::notified.

broadcast::Receiver::recv explizit (https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Receiver.html):

„Cancel safety: This method is cancel safe. If recv is used as a branch in tokio::select! and another branch completes first, it is guaranteed that no messages were received on this channel."

⚠️ SplitStream::next() ist nur indirekt belegt. https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html#method.next dokumentiert nur „Creates a future that resolves to the next item in the stream" — keine explizite Cancel-Safety-Aussage. Der einzige Beleg ist die pauschale Zeile in der select!-Doku. Das ist vermutlich der Grund, warum die axum-Beispiele (chat, websockets) durchweg split-in-zwei-Tasks verwenden statt select! über den Socket-Stream — dieser Ansatz umgeht die Frage.


6. tokio::sync::broadcast und langsame Empfänger

Doku: Modul https://docs.rs/tokio/latest/tokio/sync/broadcast/index.html · Sender https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Sender.html · Receiver https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Receiver.html · RecvError https://docs.rs/tokio/latest/tokio/sync/broadcast/error/enum.RecvError.html

6.1 Grundsemantik

„A multi-producer, multi-consumer broadcast queue. Each sent value is seen by all consumers." „When a value is sent, all Receiver handles are notified and will receive the value. The value is stored once inside the channel and cloned on demand for each receiver." „When a Receiver handle is dropped, any messages not read by the receiver will be marked as read."

Kapazität: „The provided capacity is rounded up to the next power of two." (3 → 4); capacity == 0 panikt.

Senden blockiert nie. Sender::send ist nicht async: „Attempts to send a value to all active Receiver handles, returning it back if it could not be sent." Bei vollem Ring gilt (Modul-Doku): „the oldest value currently held by the channel is overwritten." Rückgabe Ok(usize) = Anzahl aktiver Receiver; Err(SendError<T>) „when there is no active Receiver handle" — der Wert kommt zurück. Sender::receiver_count: „It is not guaranteed that a sent message will reach this number of receivers."

6.2 RecvError::Lagged(u64) — exakte Semantik

Lagged(u64): „The receiver lagged too far behind: one or more messages were overwritten in the ring buffer before this receiver could read them. The receiver remains subscribed. Its internal cursor has been advanced to the oldest message still retained by the channel; the next successful recv call returns that message (unless further sends overwrite it first). The u64 is the number of messages that were skipped."

Receiver::recv:

„If the Receiver handle falls behind, once the channel is full, newly sent values overwrite old values in the ring buffer. The next call to recv then returns Err(RecvError::Lagged(n)), where n is the number of overwritten messages the receiver missed." „The receiver stays subscribed; its internal cursor is advanced to the oldest value still held by the channel."

Antwort auf die drei Teilfragen des Tickets: (a) n = Anzahl übersprungener Nachrichten; (b) der Receiver bleibt brauchbar, Lagged ist kein Abbruch; (c) er springt auf die älteste noch vorhandene Nachricht — er verliert also genau n Nachrichten, nicht mehr und nicht weniger.

6.3 Weitere API, die für das „Resync"-Problem relevant ist

  • Receiver::len: „Returns the number of messages that were sent into the channel and that this Receiver has yet to receive." → direkt als Backlog-Metrik/Frühwarnung nutzbar, bevor Lagged eintritt.
  • Receiver::resubscribe: „This Receiver handle will receive a clone of all values sent after it has resubscribed." → verwirft explizit alles Alte; das ist der „Cursor auf jetzt zurücksetzen"-Reset.
  • Receiver::try_recv — nicht blockierend, TryRecvError::{Closed, Empty, Lagged(n)}.
  • Speicher: Der Wert wird einmal gespeichert und „cloned on demand for each receiver" ⇒ T sollte billig klonbar sein (Arc<Event>, bytes::Bytes). ⚠️ Ob der Ring bei channel(n) sofort vollständig alloziert wird, sagt die Doku nicht ausdrücklich — nur die Power-of-Two-Rundung und der Hinweis „Allocation failure may result in a panic".

6.4 „Lagged = Resync jetzt" — Optionen

⚠️ Wichtiger Negativbefund: Es wurde keine tokio-Issue, -Discussion oder Doku-Passage gefunden, die „Lagged als Resync-Trigger" als empfohlenes Muster benennt. Es ist gängige Community-Praxis, aber ohne Primärquelle. Belegbar sind nur die einzelnen Bausteine.

Ansatz Belegter Baustein Pro Contra
Kapazität erhöhen channel(n)-Doku (Power-of-Two-Rundung) Trivial; verschiebt die Schwelle Löst nichts, verschiebt nur; Speicher × Kapazität; ein dauerhaft langsamer Client laggt trotzdem
resubscribe() + REST-Backfill resubscribe-Doku (s. o.) + Map-Entscheidung „?since-Backfill, die DB ist die Queue" Passt exakt zur bereits gesetzten Zustellsemantik der Map; der Client hat mit ?since ohnehin den Mechanismus, um die Lücke zu schließen; Lagged(n) liefert sogar die Größe der Lücke Backfill-Last auf der DB genau dann, wenn der Server ohnehin unter Druck steht; braucht einen sauberen Cursor (siehe Council-Risiko „Cursor-Lücke")
Receiver::len() als Frühwarnung len-Doku (s. o.) Erlaubt Reaktion vor dem Datenverlust (z. B. Client drosseln oder trennen) Zusätzlicher Zustand pro Verbindung; Schwellenwert ist Tuning
Per-Verbindung-Puffer-Task (broadcast → mpsc::try_send) mpsc::Sender::try_send: „differs from send by returning immediately if the channel's buffer is full" (https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html) Drop-/Trenn-Entscheidung pro Client statt global; ein langsamer Client trifft nur sich selbst Ein Channel + Fan-out-Loop pro Verbindung; T wird pro Client geklont
Coalescing / latest-only watch (s. §6.5) Kein Lag-Fehler möglich Nur sinnvoll, wenn Zwischenwerte entbehrlich sind

Referenz-Implementierung im Repo von axum selbst: examples/chat/src/main.rs mit broadcast::channel(100).

6.5 Alternative Kanaltypen

Option Doku Semantik Trade-off
tokio::sync::watch https://docs.rs/tokio/latest/tokio/sync/watch/index.html „a multi-producer, multi-consumer channel that only retains the last sent value"; changed() ist cancel safe; borrow_and_update() „should be preferred […] if the receiver intends to await notifications from changed in a loop […] avoids a potential race where a new value is sent between changed being ready and the value being read" Kein Lag-Fehler möglich, aber Zwischenwerte gehen verloren ⇒ taugt als reines Resync-Signal / Versionszähler (z. B. „Cursor der Conversation X ist jetzt N, hol dir den Rest per REST"), ungeeignet als Event-Stream, in dem jede Nachricht zählt. Passt bemerkenswert gut zur Map-Entscheidung „Dumb Pipe {conversation, cursor}"
tokio::sync::mpsc + try_send pro Verbindung https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html send: „Sends a value, waiting until there is capacity."; try_send: sofortiges TrySendError::Full/Closed; reserve: „Waits for channel capacity. Once capacity […] is available, it is reserved for the caller." Backpressure- bzw. Drop-Entscheidung pro Client; erlaubt „langsamen Client trennen" statt globalem Lag. Kosten: ein Channel + Fan-out-Loop pro Verbindung
async_broadcast 0.7.2 https://docs.rs/async-broadcast/latest/async_broadcast/ broadcast: „If the channel is full, this method waits until there is space for a message" (echte Backpressure, async). set_overflow(true): „broadcasting […] will succeed even if the channel is full. It achieves that by removing the oldest message". InactiveReceiver hält den Kanal offen ohne zu konsumieren. Eigene Abgrenzung zu tokio in der Doku: „While this implementation does provide overflow mode, it is the default behavior and not opt-in. There is no equivalent of inactive receivers." Backpressure statt Drop — ein langsamer WS-Client kann den gesamten Producer bremsen (Head-of-Line-Blocking über alle Clients). Nur sinnvoll, wenn Verlust teurer ist als Latenz — was der Map-Entscheidung „Best-effort + ?since-Backfill" widerspricht
flume https://docs.rs/flume, https://crates.io/crates/flume „A blazingly fast multi-producer, multi-consumer channel" mit „MPMC support". Crates.io: „in casual maintenance mode … critical security and bug fixes, but heavy feature development has stopped" ⚠️ Kein Broadcast — MPMC heißt hier Work-Queue: jede Nachricht geht an genau einen Receiver. Für Fan-out an N WS-Clients ungeeignet, nur als Worker-Queue. („genau ein Receiver" konnte in der flume-Doku nicht wörtlich belegt werden.)
postage::broadcast https://docs.rs/postage/latest/postage/broadcast/ „Provides a lossless, MPMC channel. All receivers are guaranteed to recieve each message." Lossless impliziert Backpressure. ⚠️ Nicht belegbar: Die Modul-Doku sagt nicht explizit, ob der Sender bei langsamem Receiver wartet oder was sonst geschieht. Kleinere Community

6.6 PostgreSQL LISTEN/NOTIFY als zweite Fan-out-Implementierung

Die Map sieht LISTEN/NOTIFY als zweite Implementierung hinter dem Fan-out-Trait vor. Faktenlage:

sqlx::postgres::PgListener (https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgListener.html):

„A stream of asynchronous notifications from Postgres." „This listener will auto-reconnect. If the active connection being used ever dies, this listener will detect that event, create a new connection, will re-subscribe to all of the originally specified channels, and will resume operations as normal." recv(): „If the connection to PostgreSQL is lost, it is automatically reconnected on the next call to recv(), and should be entirely transparent (as long as it was just an intermittent network failure or long-lived connection reaper)." listen(): „The channel name is quoted here to ensure case sensitivity."

PostgreSQL NOTIFY (https://www.postgresql.org/docs/current/sql-notify.html):

„In the default configuration it must be shorter than 8000 bytes." „There is a queue that holds notifications that have been sent but not yet processed by all listening sessions. If this queue becomes full, transactions calling NOTIFY will fail at commit. The queue is quite large (8GB in a standard installation)."

Pro Multi-Instanz-fähiger Fan-out ohne zusätzlichen Broker — hält die Map-Regel „eine Binary + Postgres"; PgListener reconnected selbst; NOTIFY ist transaktional (feuert erst bei COMMIT), was die Konsistenz mit der DB als Queue erzwingt; der Dumb-Pipe-Payload {conversation, cursor} liegt weit unter 8 kB
Contra 8000-Byte-Payload-Grenze — nur IDs/Deltas, Volldaten per Query nachladen; die Notify-Queue kann Transaktionen beim Commit fehlschlagen lassen, wenn Listener hängen; jeder PgListener belegt eine dedizierte Postgres-Connection außerhalb des PgPool (aktuell max_connections = 10) — muss beim Sizing eingeplant werden; NOTIFY liefert keine Historie, ein verpasstes Signal ist weg (dieselbe Resync-Frage wie bei broadcast, nur eine Ebene tiefer)

Anhang: Was nicht belegt werden konnte

Diese Punkte sollten im Entscheidungsticket nicht als Fakt behandelt werden:

  1. Referrer-Leak und Browser-History für WS-URLs (§1.2) — RFC 6750 §5.3 spricht von „page URLs"; für new WebSocket(url) kein Beleg gefunden.
  2. APM/Tracing-Leak — plausibel (OpenTelemetry url.full), aber keine Semantic-Convention-Seite geprüft.
  3. OWASP WebSocket Security Cheat Sheet trägt kein verifizierbares Datum/keine Version.
  4. SameSite-Wirksamkeit für WS-Handshakes (§1.4) — aus rfc6265bis §5.6.7.1 abgeleitet, das Wort „WebSocket" kommt dort nicht vor.
  5. Auth0/Okta haben keine auffindbare WS-spezifische Doku zum Token-Ablauf.
  6. Header-Größenlimits von nginx/Caddy für lange JWTs im Sec-WebSocket-Protocol (§1.3).
  7. tauri-plugin-websocket headers-Feldtyp — nur der Feldname aus der JS-Referenz belegt.
  8. Cloudflare „100 s WS-Idle-Timeout" — in keiner Cloudflare-Primärquelle; sie dokumentieren bewusst keinen Zahlenwert.
  9. MDN „Proxy servers and firewalls" — dieser Abschnitt existiert in der aktuellen MDN-Fassung nicht mehr.
  10. HAProxy numerische Timeout-Defaults — upstream nicht dokumentiert; kursierende „50 s" stammen aus Distro-Beispiel-Configs.
  11. Slack-Reconnect-Sturm mit Datum — der Envoy-Artikel (15.03.2021) behandelt das Thema nicht; „Flannel/Governor"-Aussagen sind nur sekundärbelegt.
  12. Discord heartbeat_interval = 41250 ms — nicht dokumentiert; die Doku zeigt 45000 im Beispiel, der Wert ist server-diktiert.
  13. tower_http::timeout bricht geupgradete WS ab — kein Issue gefunden, das das nachweist.
  14. SplitStream::next() Cancel-Safety — nur indirekt über die pauschale select!-Doku-Zeile.
  15. „Lagged als Resync-Trigger" — kein tokio-Doku- oder Issue-Beleg als empfohlenes Muster.
  16. broadcast-Ring: Sofort-Allokation — Doku sagt dazu nichts.
  17. flume: „jede Nachricht an genau einen Receiver" — in der flume-Doku nicht wörtlich belegt.

Quellenverzeichnis

Spezifikationen

Rust-Bibliotheken

Infrastruktur / Proxies

Auth-/Realtime-Anbieter und -Frameworks

Praxisberichte (mit Datum)

Tauri