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.
102 KiB
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,AuthorizationandProxy-Authorization) will be logged asREDACTED."
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_tokenquery 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-Protocoland 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-Protocolsteht nicht im Default-Format. - Caddy: der Header wird per Default vollständig geloggt —
request.headersist eine komplette Map, undSec-WebSocket-Protocolsteht nicht auf der Redaction-Liste. Bei Caddy ist dieser Weg damit schlechter alsAuthorization: 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). |
1.4 Option C — Cookie
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:
- WebView hat eine eigene Cookie-Jar; Tauri v2.4.0 führte
Webview::cookies()/cookies_for_url()ein (https://v2.tauri.app/release/tauri/v2.4.0/). tauri-plugin-httpnutzt den reqwest-Store — dokumentiertes offenes Problem, dass beide nicht synchron sind: https://github.com/tauri-apps/tauri/issues/13045- Origin im Tauri-WebView ist ein Custom-Scheme (
tauri://localhost, unter Windowshttp://tauri.localhost) — Origin-Allowlisting muss das kennen: https://github.com/tauri-apps/tauri/discussions/4912 tokio-tungstenitehat keine Cookie-Jar; Cookies müssten manuell als Header gesetzt werden.
| 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
IntoClientRequestwith 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(): „Addsprotocolto 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.connectare 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
CloseOnAuthenticationExpirationoption 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
readreturns a ping, you shouldflushbefore passing a custom pong towrite, otherwise the automatic queued response to the ping will not be sent as it will be replaced by your custom pong message." (anread) „This will also queue responses to ping and close messages. These responses will be written and flushed on the next call toread,writeorflush."
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."
- Per Default aus — greift erst nach explizitem
SO_KEEPALIVE. - 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.
- 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)
- 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 mitProxy-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_timeoutdirective. 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
pingIntervalms, and if the client does not answer with a pong withinpingTimeoutms, the server considers that the connection is closed. Similarly, if the client does not receive a ping packet from the server withinpingInterval + pingTimeoutms, then the client also considers that the connection is closed." Warnung: „Using a small value like1000(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 * jitterwherejitteris 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 besides1000or1001, 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), withnincremented by 1 for each iteration (request)." „random_number_millisecondsis 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 themaximum_backofftime."
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-Afterheader, 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 is500milliseconds."maxReconnectDelay: „sets an upper reconnect delay value. Default is20000milliseconds"maxServerPingDelay: „sets the maximum delay of server pings after which connection is considered broken and client reconnects. […] Default is10000."
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." (disconnectedRetryTimeout15000 ms,suspendedRetryTimeout30000 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
:DOWNmessage, 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 (
PartitionSupervisorstatt 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.tomlC:\Projekte\Privat\Rustification\rustification-server\src\main.rsC:\Projekte\Privat\Rustification\rustification-server\src\route.rsC:\Projekte\Privat\Rustification\rustification-server\src\middleware.rsC:\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 }— nichtClone, überall alsArc<AppState>; State-Typ ist alsoState<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 demprotected-Subrouter (route.rs:104).require_authvalidiert das Bearer-JWT und machtreq.extensions_mut().insert(data.claims)(middleware.rs:31). KeinFromRequestParts-Extractor. - Auffällig:
Extension<Claims>wird in keinem Handler gelesen — die eingefügte Extension ist derzeit ungenutzt. - Graceful Shutdown existiert nicht:
main.rs:52istaxum::serve(listener, app).await.unwrap();Auch keininto_make_service_with_connect_info—ConnectInfowü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::Messagenow usesBytesin place ofVec<u8>, and a newUtf8Bytestype in place ofString, for its variants (#3078) changed: Upgradedtokio-tungsteniteto 0.26 (#3078) breaking: RemoveWebSocket::close. Users should explicitly send close messages themselves. (#2974) added: Add support for WebSockets over HTTP/2. They can be enabled by changingget(ws_endpoint)handlers toany(ws_endpoint)(#2894) breaking: Makeservegeneric 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):
examples/websockets/src/main.rs—WebSocketUpgrade+TypedHeader<UserAgent>+ConnectInfo,into_make_service_with_connect_info::<SocketAddr>(),Message::Pingals Probe,socket.split()+ zwei Tasks +tokio::select!(https://github.com/tokio-rs/axum/blob/main/examples/websockets/src/main.rs)examples/websockets-http2/src/main.rs— Route istany(ws_handler); setztserver.http_builder().http2().enable_connect_protocol();(https://github.com/tokio-rs/axum/blob/main/examples/websockets-http2/src/main.rs)examples/chat/src/main.rs— nutzttokio::sync::broadcast:struct AppState { user_set: Mutex<HashSet<String>>, tx: broadcast::Sender<String> },broadcast::channel(100),split()+tokio::select!mitabort()(https://github.com/tokio-rs/axum/blob/main/examples/chat/src/main.rs)- Weiter relevant:
examples/testing-websockets,examples/graceful-shutdown.
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:
- Von der Middleware via
extensions_mut().insert(claims)eingefügte Werte sind im Handler überExtension<Claims>sichtbar (Standardmechanik vonfrom_fn). WebSocketUpgrade::from_request_partsmachtparts.extensions.remove::<hyper::upgrade::OnUpgrade>(). Baut eine Middleware den Request neu, ohneextensionszu übernehmen, istOnUpgradeweg → RejectionConnectionNotUpgradable. Das ist der wichtigste Middleware-Fallstrick bei WS.- Die
on_upgrade-Closure ist'static(C: FnOnce(WebSocket) -> Fut + Send + 'static). Request-Extensions überleben nicht —Partswird nach dem Handler gedroppt. Alles, was die Task braucht (Claims,Arc<AppState>,SocketAddr), muss vorher extrahiert/geklont und permovehineingezogen 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:
- Der
close_rx-Guard hängt nur an der hyper-Connection-Task. Die WS-Task ist ein eigenständigestokio::spawninon_upgradeohneclose_rx⇒close_tx.closed().awaitwartet nicht auf sie. 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
Connectionfuture is still pending. If called afterUpgradeableConnection::pollhas resolved, this does nothing." Nach dem Hijack ist die Connection-Future resolved ⇒ der Aufruf ist ein No-op; das Socket gehört jetzt demWebSocketStream.- axum Issue #3003 — „Graceful Shutdown ignores Websocket handler task",
eröffnet 21.10.2024, Status offen:
„the task spawned in
ws.on_upgradeis not connected to the graceful shutdown future at all" Und: selbst mit eigenemCancellationTokenist nicht garantiert, dass die Verbindung geschlossen ist, wenn die Shutdown-Future resolved. - 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. - 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
recvis used as a branch intokio::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
Receiverhandles are notified and will receive the value. The value is stored once inside the channel and cloned on demand for each receiver." „When aReceiverhandle 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 successfulrecvcall returns that message (unless further sends overwrite it first). Theu64is the number of messages that were skipped."
Receiver::recv:
„If the
Receiverhandle falls behind, once the channel is full, newly sent values overwrite old values in the ring buffer. The next call torecvthen returnsErr(RecvError::Lagged(n)), wherenis 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 thisReceiverhas yet to receive." → direkt als Backlog-Metrik/Frühwarnung nutzbar, bevorLaggedeintritt.Receiver::resubscribe: „ThisReceiverhandle 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" ⇒
Tsollte billig klonbar sein (Arc<Event>,bytes::Bytes). ⚠️ Ob der Ring beichannel(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 torecv(), 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
NOTIFYwill 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:
- 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. - APM/Tracing-Leak — plausibel (OpenTelemetry
url.full), aber keine Semantic-Convention-Seite geprüft. - OWASP WebSocket Security Cheat Sheet trägt kein verifizierbares Datum/keine Version.
- SameSite-Wirksamkeit für WS-Handshakes (§1.4) — aus rfc6265bis §5.6.7.1 abgeleitet, das Wort „WebSocket" kommt dort nicht vor.
- Auth0/Okta haben keine auffindbare WS-spezifische Doku zum Token-Ablauf.
- Header-Größenlimits von nginx/Caddy für lange JWTs im
Sec-WebSocket-Protocol(§1.3). tauri-plugin-websocketheaders-Feldtyp — nur der Feldname aus der JS-Referenz belegt.- Cloudflare „100 s WS-Idle-Timeout" — in keiner Cloudflare-Primärquelle; sie dokumentieren bewusst keinen Zahlenwert.
- MDN „Proxy servers and firewalls" — dieser Abschnitt existiert in der aktuellen MDN-Fassung nicht mehr.
- HAProxy numerische Timeout-Defaults — upstream nicht dokumentiert; kursierende „50 s" stammen aus Distro-Beispiel-Configs.
- Slack-Reconnect-Sturm mit Datum — der Envoy-Artikel (15.03.2021) behandelt das Thema nicht; „Flannel/Governor"-Aussagen sind nur sekundärbelegt.
- Discord
heartbeat_interval= 41250 ms — nicht dokumentiert; die Doku zeigt45000im Beispiel, der Wert ist server-diktiert. tower_http::timeoutbricht geupgradete WS ab — kein Issue gefunden, das das nachweist.SplitStream::next()Cancel-Safety — nur indirekt über die pauschaleselect!-Doku-Zeile.- „Lagged als Resync-Trigger" — kein tokio-Doku- oder Issue-Beleg als empfohlenes Muster.
- broadcast-Ring: Sofort-Allokation — Doku sagt dazu nichts.
- flume: „jede Nachricht an genau einen Receiver" — in der flume-Doku nicht wörtlich belegt.
Quellenverzeichnis
Spezifikationen
- RFC 6455 (The WebSocket Protocol) — https://www.rfc-editor.org/rfc/rfc6455.txt · §1.8, §4.1, §4.2.2, §5.5, §5.5.1–3, §7.4.1–2, §10.2, §10.3, §10.5
- RFC 6750 (Bearer Token Usage) — https://datatracker.ietf.org/doc/html/rfc6750 · §2.3, §5.3
- RFC 8441 (Bootstrapping WebSockets with HTTP/2) — https://datatracker.ietf.org/doc/html/rfc8441
- RFC 9110 (HTTP Semantics) — https://www.rfc-editor.org/rfc/rfc9110.txt · §10.2.3, §15.6.4
- RFC 2616 §2.2 (Separatoren) — https://datatracker.ietf.org/doc/html/rfc2616#section-2.2
- draft-ietf-httpbis-rfc6265bis — https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis
- WHATWG WebSockets Standard — https://websockets.spec.whatwg.org/
- IANA WebSocket Registries — https://www.iana.org/assignments/websocket/websocket.xhtml
- MQTT v5.0 (OASIS, 07.03.2019) — https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html
- MDN WebSocket / error_event / Navigator.onLine — https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
man 7 tcp— https://man7.org/linux/man-pages/man7/tcp.7.html- PostgreSQL
NOTIFY— https://www.postgresql.org/docs/current/sql-notify.html
Rust-Bibliotheken
- axum 0.8.9
extract::ws— https://docs.rs/axum/0.8.9/axum/extract/ws/index.html - axum CHANGELOG — https://github.com/tokio-rs/axum/blob/main/axum/CHANGELOG.md
- axum 0.8.0 Announcement (01.01.2025) — https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0
- axum Quellcode
extract/ws.rs,serve/mod.rs— https://github.com/tokio-rs/axum/blob/main/axum/src/ - axum Beispiele
websockets,websockets-http2,chat— https://github.com/tokio-rs/axum/tree/main/examples - axum Issue #3003 (21.10.2024, offen) — https://github.com/tokio-rs/axum/issues/3003
- axum PR #3831 (11.07.2026, offen) — https://github.com/tokio-rs/axum/pull/3831
- axum Issue #2673 (SSE-Gegenbeispiel) — https://github.com/tokio-rs/axum/issues/2673
- tokio
sync::broadcast/watch/mpsc/select!— https://docs.rs/tokio/latest/tokio/sync/ - tokio-util
CancellationToken/TaskTracker— https://docs.rs/tokio-util/latest/tokio_util/ - tungstenite / tokio-tungstenite — https://docs.rs/tungstenite/, https://docs.rs/tokio-tungstenite/
- hyper
upgrade, hyper-utilUpgradeableConnection— https://docs.rs/hyper/latest/hyper/upgrade/ - tower-http
timeout— https://docs.rs/tower-http/latest/tower_http/timeout/ - sqlx
PgListener— https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgListener.html - async-broadcast — https://docs.rs/async-broadcast/latest/async_broadcast/
- postage — https://docs.rs/postage/latest/postage/broadcast/
Infrastruktur / Proxies
- nginx WebSocket + Log-Modul + Proxy-Modul — https://nginx.org/en/docs/http/websocket.html
- Apache httpd Logs — https://httpd.apache.org/docs/2.4/logs.html
- Caddy Logging — https://caddyserver.com/docs/logging
- HAProxy 3.0 Configuration §4.2 — https://docs.haproxy.org/3.0/configuration.html
- AWS ALB / Classic ELB Idle Timeout — https://docs.aws.amazon.com/elasticloadbalancing/
- AWS API Gateway WebSocket Limits — https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-execution-service-websocket-limits-table.html
- Azure Load Balancer / Application Gateway WebSocket — https://learn.microsoft.com/en-us/azure/
- GCP Load Balancing Request Distribution — https://docs.cloud.google.com/load-balancing/docs/https/request-distribution
- Cloudflare WebSockets — https://developers.cloudflare.com/network/websockets/
Auth-/Realtime-Anbieter und -Frameworks
- Microsoft SignalR Auth + Security — https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz
- Azure Web PubSub — https://learn.microsoft.com/en-us/azure/azure-web-pubsub/
- AWS AppSync Realtime WebSocket Client — https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html
- AWS SDK Retry Behavior — https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html
- Kubernetes apiserver WebSocket-Protocol-Authenticator — https://github.com/kubernetes/apiserver/blob/master/pkg/authentication/request/websocket/protocol.go
- graphql-ws PROTOCOL.md — https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md
- Centrifugo (Auth, Codes, Configuration) — https://centrifugal.dev/docs/
- centrifuge-js — https://github.com/centrifugal/centrifuge-js
- Discord Gateway + Opcodes — https://docs.discord.com/developers/events/gateway
- Pusher Channels Protocol — https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/
- Socket.IO Server/Client Options + Middlewares — https://socket.io/docs/v4/
- Phoenix Channels +
socket.js— https://phoenix.hexdocs.pm/channels.html - Ably Auth + Connection States — https://ably.com/docs/auth/token
- Firebase Manage Sessions — https://firebase.google.com/docs/auth/admin/manage-sessions
- Home Assistant WebSocket API — https://developers.home-assistant.io/docs/api/websocket/
- Slack RTM (Legacy) — https://docs.slack.dev/legacy/legacy-rtm-api/
- python-websockets Authentication — https://websockets.readthedocs.io/en/stable/topics/authentication.html
- OWASP: WSTG-CLNT-10, REST Security Cheat Sheet, WebSocket Security Cheat Sheet
Praxisberichte (mit Datum)
- Marc Brooker, „Exponential Backoff And Jitter", AWS Architecture Blog, 04.03.2015 (Update Mai 2023) — https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- Simulator-Code — https://github.com/awslabs/aws-arch-backoff-simulator
- Discord, „Behind the Scenes of the 3/25/26 Voice Outage", 29.04.2026 — https://discord.com/blog/behind-the-scenes-of-the-3-25-26-voice-outage
- Slack, „Migrating Millions of Concurrent Websockets to Envoy", 15.03.2021 — https://slack.engineering/migrating-millions-of-concurrent-websockets-to-envoy/
- Azure, „Transient fault handling",
ms.date: 26.02.2026— https://learn.microsoft.com/en-us/azure/architecture/best-practices/transient-faults - Google Cloud, „Exponential backoff", Stand 29.07.2026 — https://docs.cloud.google.com/memorystore/docs/redis/exponential-backoff
Tauri
- Tauri v2 WebSocket-Plugin — https://v2.tauri.app/plugin/websocket/
- Tauri v2 JS-Referenz WebSocket — https://v2.tauri.app/reference/javascript/websocket/
- Tauri WebView-Versionen — https://v2.tauri.app/reference/webview-versions/
- Tauri v2.4.0 Release (Cookie-API) — https://v2.tauri.app/release/tauri/v2.4.0/
- Tauri Issue #13045 (Cookie-Store-Divergenz) — https://github.com/tauri-apps/tauri/issues/13045
- Tauri Issue #4263 (Proxy-Konfiguration) — https://github.com/tauri-apps/tauri/issues/4263