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

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

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

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

1493 lines
102 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Recherche: WS-Auth, Heartbeat und Reconnect
Ticket: [`../issues/03-research-ws-auth-reconnect.md`](../issues/03-research-ws-auth-reconnect.md)
Parent: [`../map.md`](../map.md)
Stand: 14.08.2026
> **Dieses Dokument gibt bewusst keine Empfehlung ab.** Es stellt pro Teilfrage die Optionen
> mit ihren belegten Vor- und Nachteilen gegenüber. Die Entscheidung fällt im Ticket
> *Verbindungsaufbau & Auth über WS*.
## Vorbemerkung zum Bedrohungsmodell
Die Map setzt fest: **der Serverbetreiber ist der Angreifer.** Für die Token-Frage folgt
daraus eine Klarstellung, die im weiteren Text nicht wiederholt wird: **kein
Token-Transportweg schützt vor dem Serverbetreiber selbst** — der Server muss das Token
sehen, um es zu prüfen. Der Unterschied zwischen den Optionen ist ausschließlich die
**Persistenz- und Streubreite** des Geheimnisses: was landet in Logfiles, Log-Backups,
Reverse-Proxy-Logs, APM/Tracing-Systemen und deren Subprozessoren — also in Artefakten, die
den Handshake überleben und mehr Leute sehen als der Prozess, der das Token validiert.
Vertraulichkeit *gegenüber* dem Betreiber ist eine Aufgabe der E2EE-Schicht, nicht der
Auth-Schicht. RFC 6455 sagt dazu selbst nichts vor: §10.5 —
> „This protocol does not prescribe any particular way that servers can authenticate clients
> during a WebSocket handshake."
(https://datatracker.ietf.org/doc/html/rfc6455#section-10.5)
Ein zweites, für das Projekt relevantes Faktum vorab: **die Browser-API verschluckt alle
Fehlerdetails.** WHATWG WebSockets Standard, „Feedback from the protocol":
> „User agents must not convey any failure information to scripts in a way that would allow a
> script to distinguish the following situations […] Allowing a script to distinguish these
> cases would allow a script to probe the user's local network in preparation for an attack."
(https://websockets.spec.whatwg.org/#feedback-from-the-protocol)
Zu den nicht unterscheidbaren Fällen gehören DNS-Fehler, Connection-Refused, TLS-Fehler,
**Subprotocol-Mismatch** und Abbruch nach erfolgreichem Handshake. Das Skript sieht in allen
Fällen `CloseEvent.code === 1006`, `wasClean === false`; das `error`-Event ist ein generisches
`Event` ohne Payload (https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event).
**Im WebView ist „Token abgelaufen" nicht von „Server down" unterscheidbar. Im Rust-Layer
schon** — `tungstenite::Error::Http` trägt die vollständige HTTP-Response inklusive
Statuscode und Body (https://docs.rs/tungstenite/latest/tungstenite/error/enum.Error.html).
Diese Asymmetrie zieht sich durch fast jede Teilfrage.
---
## 1. Token-Übergabe beim Handshake
### 1.1 Ausgangslage
Der Handshake ist ein normaler HTTP-Request. RFC 6455 §4.1:
> „If the status code received from the server is not 101, the client handles the response per
> HTTP [RFC2616] procedures. In particular, the client might perform authentication if it
> receives a 401 status code"
(https://datatracker.ietf.org/doc/html/rfc6455#section-4.1)
Ein Axum-Handler kann also regulär mit 401/403 samt JSON-Body ablehnen. Ob der Client das
*sieht*, hängt daran, wo die Verbindung lebt (siehe Vorbemerkung).
### 1.2 Option A — Token im Query-String (`wss://host/ws?token=…`)
**Normative Ablehnung.** RFC 6750 §2.3:
> „Because of the security weaknesses associated with the URI method (see Section 5), including
> the high likelihood that the URL containing the access token will be logged, it SHOULD NOT be
> used unless it is impossible to transport the access token in the 'Authorization' request
> header field or the HTTP request entity-body."
(https://datatracker.ietf.org/doc/html/rfc6750#section-2.3)
RFC 6750 §5.3:
> „Bearer tokens SHOULD NOT be passed in page URLs […] Browsers, web servers, and other
> software may not adequately secure URLs in the browser history, web server logs, and other
> data structures."
(https://datatracker.ietf.org/doc/html/rfc6750#section-5.3)
OWASP REST Security Cheat Sheet: „Passwords, security tokens, and API keys should not appear
in the URL, as this can be captured in web server logs"
(https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html)
**Das Log-Verhalten in Default-Konfigurationen — der eigentliche Beleg:**
| Proxy/Server | Query-String im Default-Log? | Header im Default-Log? | Quelle |
|---|---|---|---|
| nginx (`combined`) | **Ja** — `$request` ist die Request-Line inkl. Query | Nein (nur Referer + User-Agent) | https://nginx.org/en/docs/http/ngx_http_log_module.html |
| Apache httpd (`common`/`combined`) | **Ja** — `%r` = „The request line from the client" | Nein (außer explizit `%{Header}i`) | https://httpd.apache.org/docs/2.4/logs.html |
| Caddy (JSON) | **Ja** — `request.uri` = „Absolute path and query of the URI" | **Ja, alle Header** — außer `Cookie`, `Set-Cookie`, `Authorization`, `Proxy-Authorization` (→ `REDACTED`) | https://caddyserver.com/docs/logging |
Caddy wörtlich:
> „By default, access logs […] with headers that contain potentially sensitive information
> (`Cookie`, `Set-Cookie`, `Authorization` and `Proxy-Authorization`) will be logged as
> `REDACTED`."
**Befund:** In allen drei Default-Konfigurationen ist der `Authorization`-Header der leisere
Kanal. Bei Caddy ist er sogar aktiv redigiert, `request.uri` dagegen nicht. Das ist der einzige
belastbare technische Unterschied im Log-Kanal — und für dieses Projekt der entscheidende, weil
der Betreiber-Angreifer genau über solche Artefakte an Tokens kommt, die er im Prozess nur
flüchtig sieht.
**Wer es trotzdem so macht — und wie ehrlich die Doku dazu ist.** Microsoft SignalR
(`ms.date: 2026-07-29`):
> „In standard web APIs, bearer tokens are sent in an HTTP header. However, SignalR is unable to
> set these headers in browsers when some transports are used. When WebSockets and Server-Sent
> Events are used, the token is transmitted as a query string parameter."
und auf der Security-Seite:
> „Many web servers log the URL for each request, including the query string. Logging the URLs
> may log the access token. ASP.NET Core logs the URL for each request by default […] you can
> write middleware to log the data that you require and filter out the `access_token` query
> string value (if present)."
(https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz,
https://learn.microsoft.com/en-us/aspnet/core/signalr/security)
Azure Web PubSub unterstützt beides parallel: `/client/?hub={hub}&access_token={token}` **oder**
`Authorization: Bearer {token}`
(https://learn.microsoft.com/en-us/azure/azure-web-pubsub/concept-client-protocols).
AWS AppSync unterstützt drei Wege gleichzeitig: Query-Param, Subprotocol und echte Header
(https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html).
python-websockets bewertet den Query-Param als „fully reliable but less secure", weil
„URIs end up in logs, which leaks credentials"
(https://websockets.readthedocs.io/en/stable/topics/authentication.html).
**Zwei verbreitete Nachteils-Behauptungen, die sich NICHT belegen ließen** — bitte nicht ins
Entscheidungsticket übernehmen:
- **Referrer-Leak:** RFC 6750 §5.3 nennt Referrer, spricht aber von „page URLs". Für
`new WebSocket(url)` konnte keine Quelle gefunden werden. Der Handshake ist eine
Subresource-Request; im Referrer landet die *Seiten*-URL, nicht die WS-URL.
- **Browser-History:** `new WebSocket(url)` erzeugt keinen History-Eintrag. Kein Beleg gefunden.
Belegbar bleiben: **Access-Logs, Log-Aggregation/APM, und deren Backups.** Das reicht.
| | |
|---|---|
| **Pro** | Funktioniert in jeder WebSocket-Implementierung inklusive Browser-API; keine Sonderbehandlung nötig; als `Query<T>`-Extractor in Axum trivial. |
| **Contra** | RFC 6750 sagt explizit „SHOULD NOT"; landet per Default in nginx-, Apache- und Caddy-Logs; verschärft sich linear mit erzwungenen Reconnects (siehe §2). |
### 1.3 Option B — `Sec-WebSocket-Protocol`-Missbrauch
**Ist ein JWT ein legaler Subprotocol-Token?** RFC 6455 §4.1:
> „The elements that comprise this value MUST be non-empty strings with characters in the range
> U+0021 to U+007E not including separator characters as defined in [RFC2616]."
RFC 2616 §2.2 definiert als Separatoren: `( ) < > @ , ; : \ " / [ ] ? = { }`, SP, HT
(https://datatracker.ietf.org/doc/html/rfc2616#section-2.2).
`.`, `-` und `_` sind **keine** Separatoren → ein kompakt serialisiertes JWT
(`base64url.base64url.base64url`) ist ein **gültiger** Subprotocol-Token. **Aber `=` ist ein
Separator** → Base64 *mit* Padding wäre illegal. Genau deshalb schreibt Kubernetes
base64url-ohne-Padding vor.
**Die zwingende Echo-Semantik ist der eigentliche Haken.** RFC 6455 §4.2.2: Der Server wählt
„one or none of the acceptable protocols". RFC 6455 §4.1: Gibt der Server ein Subprotokoll
zurück, das der Client nicht angeboten hat, „the client MUST _Fail the WebSocket Connection_".
Browser sind **strenger** als RFC 6455 — WHATWG:
> „If protocols is not the empty list and extracting header list values given
> `Sec-WebSocket-Protocol` and response's header list results in null, failure, or the empty
> byte sequence, then fail the WebSocket connection."
(https://websockets.spec.whatwg.org/#establish-a-websocket-connection)
Bietet der Browser-Client also Subprotokolle an, **muss** der Server eines zurückgeben —
aber er darf natürlich nicht das Token echoen. Daraus folgt zwingend das Kubernetes-Muster:
**mindestens zwei Subprotokolle anbieten**, eines ist das Token, eines das echte Protokoll.
**Kubernetes API-Server, der kanonische Präzedenzfall.**
`bearerProtocolPrefix = "base64url.bearer.authorization.k8s.io."`
> „ProtocolAuthenticator allows a websocket connection to provide a bearer token as a
> subprotocol in the format
> `base64url.bearer.authorization.<base64url-without-padding(bearer-token)>`"
(https://github.com/kubernetes/apiserver/blob/master/pkg/authentication/request/websocket/protocol.go)
Der Handler verlangt mindestens ein weiteres Subprotocol, entfernt bei Erfolg das
Token-Subprotocol und echot die verbleibenden zurück. Motivation aus dem ursprünglichen PR:
„Browser clients do not have the ability to set an Authorization header programmatically on
websocket requests" (https://github.com/kubernetes/kubernetes/pull/47740).
AWS AppSync macht dasselbe mit `header-<base64url(JSON)>` zusätzlich zu `graphql-ws`.
**Log-Verhalten — hier kippt der Vorteil:**
- nginx / Apache: `Sec-WebSocket-Protocol` steht **nicht** im Default-Format.
- **Caddy: der Header wird per Default vollständig geloggt** — `request.headers` ist eine
komplette Map, und `Sec-WebSocket-Protocol` steht **nicht** auf der Redaction-Liste. Bei Caddy
ist dieser Weg damit **schlechter** als `Authorization: Bearer`.
**IANA:** Subprotocol-Namen sind „First Come First Served" registriert
(https://www.iana.org/assignments/websocket/websocket.xhtml); ein Token-Präfix wie das von
Kubernetes ist **nicht** registriert. Registrierung ist optional, Kollisionsrisiko theoretisch.
| | |
|---|---|
| **Pro** | Funktioniert in der Browser-API ohne Custom-Header; landet nicht in nginx-/Apache-Default-Logs; von Kubernetes und AWS AppSync in Produktion belegt. |
| **Contra** | Erzwingt das Zwei-Subprotokoll-Konstrukt, sonst schlägt der Browser-Handshake fehl (WHATWG, strenger als RFC); Base64url-Padding muss weg (`=` ist Separator); **bei Caddy per Default im Log**; Proxy-Header-Längenlimits gelten (nginx `large_client_header_buffers`, Default 4×8k — **nicht verifiziert**, vor einer Entscheidung prüfen). |
### 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-http` nutzt 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 Windows
`http://tauri.localhost`) — Origin-Allowlisting muss das kennen:
https://github.com/tauri-apps/tauri/discussions/4912
- `tokio-tungstenite` hat **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 `IntoClientRequest` with options to add custom additional headers and
> sub protocols."
> `with_header()`: „Adds (`key`, `value`) as an additional header to the handshake request"
> `with_sub_protocol()`: „Adds `protocol` to the handshake request subprotocols"
Praxis-Pattern:
```rust
let mut request = "wss://api.example.com/ws".into_client_request()?;
request.headers_mut().insert("Authorization", "Bearer token123".parse()?);
let (ws_stream, response) = connect_async(request).await?;
```
**Fehlerdetails sind hier verfügbar** — `tungstenite::Error::Http(Box<Response<Option<Vec<u8>>>>)`
trägt Statuscode und Body
(https://docs.rs/tungstenite/latest/tungstenite/error/enum.Error.html). Ein 401 mit
strukturiertem JSON-Grund ist auswertbar; im WebView nicht (siehe Vorbemerkung).
**Auch aus dem React-Frontend erreichbar, ohne eigenen Rust-Command:**
`tauri-plugin-websocket` — „Open a WebSocket connection using a Rust client in JavaScript"
(https://v2.tauri.app/plugin/websocket/). Die `ConnectionConfig` enthält laut Referenz das Feld
**`headers?`** neben `maxMessageSize`, `maxFrameSize` etc.
(https://v2.tauri.app/reference/javascript/websocket/). Preis: es ist nicht die
Browser-`WebSocket`-API — kein `WebSocket`-Objekt, keine `wasClean`-Semantik, IPC-Bridge im
Datenpfad. *(Der exakte Feldtyp und das Verhalten bei verbotenen Headern wurden nicht verifiziert.)*
**Proxy-Einschränkung, relevant für Firmenkunden:** tokio-tungstenite bringt **keinen** eigenen
HTTP-/SOCKS-Proxy-Support mit — `connect_async` ist nur mit „Connect to a given URL"
dokumentiert (https://docs.rs/tokio-tungstenite/). Ein CONNECT-Tunnel muss selbst aufgebaut und
der fertige Stream an `client_async` übergeben werden.
| | |
|---|---|
| **Pro** | Der einzige Weg, der von RFC 6750 §2.3 ausdrücklich als der richtige benannt wird; **wird von nginx und Apache per Default nicht geloggt, von Caddy explizit redigiert**; volle Fehlerdetails (401 mit Body) statt `1006`; keine Zwei-Subprotokoll-Akrobatik. |
| **Contra** | Setzt voraus, dass die Verbindung im Rust-Layer lebt (Kopplung an ein anderes Ticket); kein Proxy-Support in tokio-tungstenite → CONNECT-Tunnel selbst bauen, was bei Firmenkunden mit expliziter Proxy-Konfiguration Arbeit ist; über `tauri-plugin-websocket` verliert man die Browser-`WebSocket`-Semantik im Frontend. |
### 1.7 Querschnitt-Option — kurzlebige Einweg-„Ticket"-Tokens
Nicht exklusiv zu A–E, sondern **kombinierbar**: ein Ticket per authentifiziertem REST-Call
holen und dieses (statt des langlebigen JWT) an den Handshake hängen.
- **Azure Web PubSub „Client Access URL"**, Lebensdauer explizit konfigurierbar
(`expirationTimeInMinutes: 5`):
> „The client negotiates with your application server. The application server contains the
> authorization middleware, which handles the client request and signs a JWT for the client to
> connect to the service. […] The client tries to connect to the Web PubSub service by using
> the URL and the JWT returned from the application server."
(https://learn.microsoft.com/en-us/azure/azure-web-pubsub/howto-generate-client-access-url,
`ms.date: 2024-10-17`)
- **Slack RTM:** „The Websocket URLs provided by `rtm.connect` are single-use and are only valid
for **30 seconds**" (https://docs.slack.dev/legacy/legacy-rtm-api/) — Achtung, RTM ist als
Legacy markiert.
- **Ably** — Token-Request-Flow via `authUrl`/`authCallback` (https://ably.com/docs/auth/token).
- python-websockets nennt Single-Use-Tokens ausdrücklich als Mitigation für den Query-Param-Weg.
**Es gibt dafür keine RFC-/OWASP-Quelle** — das ist Herstellerpraxis, kein Standard.
| | |
|---|---|
| **Pro** | Reduziert den Schaden eines Log-Leaks von „Sitzungsdauer" auf „Sekunden bis Minuten"; entkoppelt das langlebige JWT vom Handshake-Kanal; macht Option A dadurch deutlich weniger heikel. |
| **Contra** | Zusätzlicher REST-Roundtrip vor jedem (Re-)Connect — verschärft den Thundering Herd (§4); zusätzlicher serverseitiger Zustand (Einmal-Verbrauch, Ablauf) — steht in Spannung zur „eine Binary + Postgres"-Regel der Map; kein Standard, jede Implementierung ist eigen. |
### 1.8 Close-Codes für Auth-Fehler
IANA-Registry (https://www.iana.org/assignments/websocket/websocket.xhtml), Policy:
1000–2999 „Standards Action, Specification Required or IESG Review"; **3000–3999 „First Come
First Served"; 4000–4999 „Reserved for Private Use"**. RFC 6455 §7.4.2 formuliert dasselbe.
Registriert und relevant: `1008 Policy Violation`, `1011 Internal Error`. Im 3000er-Bereich
existieren `3000 Unauthorized`, `3003 Forbidden`, `3008 Timeout` — **alle ohne Reference-Spalte**,
also private First-Come-First-Served-Einträge ohne Spezifikation, keine Standardsemantik.
**Etablierte Praxis im privaten Bereich:**
| System | Codes |
|---|---|
| graphql-ws | `4400` Bad Request, `4401` Unauthorized, `4403` Forbidden, `4408` Init-Timeout, `4429` Too many init requests |
| Discord | `4003` Not authenticated (reconnect true), **`4004` Authentication failed (reconnect false)**, `4005` Already authenticated |
| Pusher | Bereichs-Semantik: **4000–4099** „SHOULD NOT be re-established unchanged"; **4100–4199** „SHOULD be re-established after backing off"; **4200–4299** „SHOULD be re-established immediately" |
| Centrifugo | nicht-terminal: `3005` connection expired, `3011` force reconnect. Terminal: `3500` invalid token, `3502` stale, `3507` permission denied |
**Musterbefund:** Alle vier Systeme kodieren nicht nur *warum*, sondern auch **ob der Client
reconnecten darf**. Das ist die tragende Semantik, nicht die Fehlerbenennung — und sie ist die
Voraussetzung dafür, dass §4.4 (Reconnect-Klassen) überhaupt funktioniert.
---
## 2. Token-Ablauf bei stundenlanger Verbindung
### 2.1 Die Grundsatzfrage: gilt Auth nur beim Handshake?
Die klarste Primärquelle ist Microsofts SignalR-Doku
(https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz, `updated_at: 2026-08-12`):
> „SignalR captures the authenticated user when a connection is established and caches it for the
> lifetime of the connection. […] **SignalR doesn't automatically revalidate the user during the
> life of the connection, regardless of the authentication scheme.** This behavior applies to all
> schemes, including cookie authentication and bearer token authentication."
> „Changes to a user's identity, roles, or claims that occur after the connection is established
> aren't reflected on an existing connection."
> „If a token expires during the lifetime of a connection, by default the connection continues to
> work."
Konkretes Szenario aus derselben Seite: Nutzer verliert die `Editor`-Rolle → „The user can keep
calling `Editor`-only hub methods until the connection is closed." Die dort genannten
Gegenmittel sind wörtlich:
> „Close affected connections so that clients reconnect and reauthenticate. For bearer token
> authentication, the `CloseOnAuthenticationExpiration` option closes connections when the
> authentication token expires."
> „Perform authorization checks in hub methods against current data, such as the user's current
> roles or claims from a data store, instead of relying only on the cached principal."
OWASP WebSocket Security Cheat Sheet (kein verifizierbares Datum):
> „Close WebSocket connections when sessions expire. Re-validate user sessions periodically
> (every 30 minutes is common)…"
> „Rotate tokens in long-lived connections to prevent hijacked sessions from persisting."
> „When users log out, close all their WebSocket connections immediately."
> „Don't assume WebSocket connection equals unlimited access. Check authorization for each action."
**Negativer Befund:** Auth0/Okta haben **keine** auffindbare WebSocket-spezifische Dokumentation
zum Token-Ablauf. Alle Treffer waren Community-Inhalte. Nicht als Beleg verwenden.
### 2.2 Option A — Verbindungs-TTL / erzwungener Reconnect
**AWS API Gateway WebSocket API**, harte, **nicht erhöhbare** Quotas
(https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-execution-service-websocket-limits-table.html):
| Quota | Wert | Erhöhbar |
|---|---|---|
| Connection duration for WebSocket API | **2 Stunden** | Nein |
| Idle Connection Timeout | **10 Minuten** | Nein |
| WebSocket frame size | 32 KB | Nein |
| Message payload size | 128 KB | Nein |
**AWS AppSync Realtime:** „AWS AppSync keeps sending these messages and servicing the registered
subscriptions until it shuts down the connection automatically (**after 24 hours**)"
(https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html).
| | |
|---|---|
| **Pro** | Einfachste denkbare Implementierung — ein Timer pro Verbindung; Auth-Zustand kann nie länger gültig sein als das Token; Revocation greift spätestens nach einer TTL, ohne dass der Server eine Liste führen muss. |
| **Contra** | **Multipliziert die Anzahl der Handshakes** — bei 2 h TTL sind das 12 Handshakes pro Tag pro Client statt einem. In Kombination mit Option 1.2 (Query-String) heißt das 12 Log-Zeilen mit Token statt einer. Das ist die direkte Wechselwirkung zwischen §1 und §2: **kurze TTL verschlimmert den Query-String-Nachteil linear.** Zusätzlich: jeder erzwungene Reconnect ist ein Backfill-Zyklus (`?since`) und, wenn alle TTLs synchron ablaufen, ein selbstgemachter Thundering Herd (§4.3). |
### 2.3 Option B — In-Band-Re-Auth über den offenen Socket
| System | Mechanismus | Semantik | Quelle |
|---|---|---|---|
| **Centrifugo** | `refresh`-Command (und `sub_refresh` für Subscriptions): „Renew an expiring connection / subscription token." Client bekommt beim Connect ein `ttl`; „Centrifugo SDKs handle this `ttl` internally and automatically begin the refresh process." Nach Ablauf: Grace Period (Default 25 s), dann Disconnect `3005 connection expired` | Verbindung bleibt bestehen | https://centrifugal.dev/docs/server/authentication |
| **Ably** | `authUrl`/`authCallback`: „the SDK automatically: 1. Calls your auth endpoint when connecting 2. Requests a new token **before** the current token expires 3. Maintains the connection seamlessly during refresh". `authorize()` „maintains the existing connection while updating permissions". Ohne `authUrl`/`authCallback`: „if your token expires and an SDK has no way to get a new token […] the connection will enter the `FAILED` state" | Verbindung bleibt bestehen | https://ably.com/docs/auth/token |
| **AWS AppSync** | Authorization wird **pro Subscription-Registrierung** erneut mitgesendet (`start`-Message hat `extensions.authorization`) | Pro-Operation-Auth | https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html |
| **SignalR** | `accessTokenFactory` wird „before **every** HTTP request made by SignalR" aufgerufen — bei WebSockets faktisch nur beim (Re-)Connect, da es nur einen Request gibt | de facto nur beim Reconnect | https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz |
Ablys Fehlercodes: `40140` = „token error (unspecified)", Bereich `40140 ≤ code < 40150` =
Token-Fehler, `40142` = „token expired"; dokumentierte Client-Regel: **einen** automatischen
Reissue-Versuch, dann Request wiederholen
(https://github.com/ably/ably-common/blob/main/protocol/errors.json).
| | |
|---|---|
| **Pro** | Das neue Token wird **im WS-Frame** übertragen und landet damit in **keinem** Access-Log — der wesentliche Vorteil gegenüber reconnect-basiertem Refresh im gegebenen Bedrohungsmodell; keine Verbindungsabbrüche, kein Backfill-Zyklus, kein Reconnect-Sturm. |
| **Contra** | Application-Layer-Auth statt HTTP-Statuscode; der Server muss den Refresh-Pfad selbst gegen Missbrauch härten (Rate-Limiting — Centrifugo braucht dafür eigene Codes `3013 too many requests`, `3509 too many errors`); zusätzliche Protokoll-Nachrichten im ansonsten als „Dumb Pipe" gesetzten Kanal (siehe Map: die Naht verläuft entlang *persistiert vs. flüchtig* — ein Re-Auth-Frame ist weder das eine noch das andere und braucht eine Einordnung). |
### 2.4 Option C — Serverseitige Revocation-Liste
Firebase (https://firebase.google.com/docs/auth/admin/manage-sessions):
> „Firebase ID tokens are short lived and last for an hour; the refresh token can be used to
> retrieve new ID tokens."
> „Because Firebase ID tokens are stateless JWTs, you can determine a token has been revoked only
> by requesting the token's status from the Firebase Authentication backend."
Revocation-Check ist dort **opt-in** (`verifyIdToken(idToken, checkRevoked)`); für die Realtime
Database wird stattdessen ein Security-Rules-Muster dokumentiert
(`auth.token.auth_time > root.child('metadata').child(auth.uid).child('revokeTime').val()`).
**Die Doku sagt nirgends, dass RTDB/Firestore bei Revocation eine bestehende Verbindung
terminiert.**
| | |
|---|---|
| **Pro** | Einziger Mechanismus, der auf „Logout auf allen Geräten" und Gerätediebstahl *sofort* reagiert, ohne die TTL abzuwarten; erlaubt lange Verbindungen ohne kurze Token-TTL. |
| **Contra** | **Im Bedrohungsmodell dieses Projekts per Definition wirkungslos gegen den Betreiber** — er kontrolliert die Liste. Sie adressiert ausschließlich „gestohlenes Token bei ansonsten korrektem Server", also ein anderes Bedrohungsmodell, das im Entscheidungsticket getrennt geführt werden sollte. Praktisch: serverseitiger Zustand bei jedem Check (DB-Lookup pro Frame wäre teuer, Cache bringt Konsistenzfenster zurück). |
### 2.5 Randnotiz: RFC 8441 (WebSockets über HTTP/2)
https://datatracker.ietf.org/doc/html/rfc8441 — Extended CONNECT mit `:protocol = "websocket"`,
ausgehandelt via `SETTINGS_ENABLE_CONNECT_PROTOCOL`. Für Auth ändert sich **nichts**:
> „The Origin, Sec-WebSocket-Version, Sec-WebSocket-Protocol, and Sec-WebSocket-Extensions header
> fields are used in the CONNECT request."
> „Other HTTP request and response-header fields, such as those for manipulating cookies, may be
> included […] as usual."
> „The security considerations of [RFC6455], Section 10 continue to apply."
`Sec-WebSocket-Key`/`Sec-WebSocket-Accept` entfallen. Axum 0.8 unterstützt das (siehe §5.1);
relevant vor allem, weil sich unter H2 die Logging-Formate ändern (`:path` statt Request-Line)
— **nicht separat verifiziert**.
---
## 3. Heartbeat
### 3.1 Protokoll-Ebene (RFC 6455)
**§5.5 Control Frames:**
> „All control frames MUST have a payload length of 125 bytes or less and MUST NOT be fragmented."
**§5.5.2 Ping** (Opcode `0x9`):
> „Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in response, unless it already
> received a Close frame. It SHOULD respond with Pong frame as soon as is practical."
> „An endpoint MAY send a Ping frame any time after the connection is established and before the
> connection is closed. A Ping frame may serve either as a keepalive or as a means to verify that
> the remote endpoint is still responsive."
**§5.5.3 Pong** (Opcode `0xA`):
> „A Pong frame sent in response to a Ping frame must have identical Application data as found in
> the message body of the Ping frame being replied to."
> „A Pong frame MAY be sent unsolicited. This serves as a unidirectional heartbeat."
(https://www.rfc-editor.org/rfc/rfc6455.txt)
Konsequenz: Der Ping-Payload (≤125 B) ist der einzige Korrelationsmechanismus auf Protokollebene
— z. B. Sequenz-Counter oder Timestamp für RTT-Messung. Unsolicited Pong ist normkonform,
erzeugt aber **keine Antwort** und taugt daher nur zum Idle-Timer-Reset, nicht zur
Liveness-Prüfung.
### 3.2 Die Browser-Beschränkung — hart belegt
WHATWG WebSockets Standard, §5 „Ping and Pong frames" (https://websockets.spec.whatwg.org/):
> „The WebSocket protocol defines Ping and Pong frames that can be used for keep-alive,
> heart-beats, network status probing, latency instrumentation, and so forth. **These are not
> currently exposed in the API.**
> User agents may send ping and unsolicited pong frames as desired […] User agents must not use
> pings or unsolicited pongs to aid the server; it is assumed that servers will solicit pongs
> whenever appropriate for the server's needs."
Die IDL kennt nur `send()` und `close()` — kein `ping()`, kein `pong`-Event; MDN listet exakt
diese zwei Instance Methods und erwähnt Ping/Pong-Frames überhaupt nicht
(https://developer.mozilla.org/en-US/docs/Web/API/WebSocket).
Browser *antworten* auf Server-Pings automatisch (RFC-konform), zeigen die Auto-Pongs aber nicht
in den DevTools; Chrome sendet selbst keine Ping-Frames, sondern nutzt `SO_KEEPALIVE` auf
TCP-Ebene (Issue-Tracker, keine normative Doku:
https://issues.chromium.org/issues/41309915, https://bugzilla.mozilla.org/show_bug.cgi?id=920074).
**Konsequenz für Tauri:** Läuft die Verbindung im WebView (WebView2 / WKWebView / WebKitGTK,
https://v2.tauri.app/reference/webview-versions/), ist ein **Application-Level-Heartbeat** (z. B.
`{"type":"ping"}` als Text-Frame) der **einzige** Weg, clientseitig Liveness festzustellen.
Genau deshalb bauen Socket.IO/Engine.IO eigene Ping-Packet-Typen statt RFC-6455-Control-Frames.
Läuft sie im Rust-Layer, sind Protokoll-Pings verfügbar.
**Negativer Befund:** Weder https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API noch
„Writing WebSocket client applications" enthalten heute einen Abschnitt „Proxy servers and
firewalls" oder überhaupt eine Erwähnung von Proxies/Ping-Pong. Die früher oft zitierte
MDN-Passage existiert nicht mehr und ist nicht zitierfähig.
### 3.3 Rust-Ebene — automatisches Pong
axum 0.8 `Message`-Doku
(https://docs.rs/axum/0.8.9/axum/extract/ws/enum.Message.html), wörtlich:
> `Ping`: „Ping messages will be automatically responded to by the server, so you do not have to
> worry about dealing with them yourself."
> `Pong`: „Pong messages will be automatically sent to the client if a ping message is received,
> so you do not have to worry about constructing them yourself **unless you want to implement a
> unidirectional heartbeat**."
tungstenite (https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocket.html):
> „upon receiving ping messages tungstenite queues pong replies automatically. […] This means you
> should not respond to ping frames manually."
> „Note that if `read` returns a ping, you should `flush` before passing a custom pong to `write`,
> otherwise the automatic queued response to the ping will not be sent as it will be replaced by
> your custom pong message."
> (an `read`) „This will also queue responses to ping and close messages. These responses will be
> written and flushed on the next call to `read`, `write` or `flush`."
Drei praktische Fallstricke daraus: (a) Das Auto-Pong wird nur **gequeued** — ohne
nachfolgendes `read`/`write`/`flush` geht es nicht raus (in einer dauerhaft gepollten
Split-Stream-Schleife erfüllt); (b) der Handler *sieht* `Ping`/`Pong` trotzdem im Stream, muss
aber nicht reagieren; (c) ein eigener Pong-Send kann das Auto-Pong verdrängen.
### 3.4 Warum TCP-Keepalive nicht reicht — vier belegte Gründe
`man 7 tcp` (https://man7.org/linux/man-pages/man7/tcp.7.html):
> `tcp_keepalive_time (integer; default: 7200)` — „The number of seconds a connection needs to be
> idle before TCP begins sending out keep-alive probes. **Keep-alives are sent only when the
> SO_KEEPALIVE socket option is enabled.** […] **Note that underlying connection tracking
> mechanisms and application timeouts may be much shorter.**"
1. **Per Default aus** — greift erst nach explizitem `SO_KEEPALIVE`.
2. **7200 s ist länger als jeder Proxy-Timeout** aus der Tabelle in §3.5 (30 s … 600 s). Der
erste Probe kommt, wenn die Verbindung längst tot ist.
3. **Keepalive-Probes tragen keine Nutzdaten** und resetten L7-Idle-Timer nicht. AWS Classic ELB
wörtlich: „TCP keep-alive probes do not prevent the load balancer from terminating the
connection because they do not send data in the payload."
(https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-idle-timeout.html)
4. **L7-Terminierung trennt die Verbindungen** — nginx/ALB/AppGW halten zwei getrennte
TCP-Sessions; ein Keepalive auf dem Client-Leg erreicht das Origin nie.
Gegenprobe: Bei L4-Load-Balancern (NLB, TCP-Listener) *resetten* TCP-Keepalives den Idle-Timer.
HTTP/2-PING-Frames zählen beim ALB dagegen explizit **nicht**: „Application Load Balancers do not
support HTTP/2 PING frames. These do not reset the connection idle timeout."
(https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html)
### 3.5 Verhalten hinter Proxies und Corporate-Firewalls
**Der härteste Beleg für „wss statt ws" — RFC 6455 §1.8:**
> „At the time of writing of this specification, it should be noted that **connections on ports
> 80 and 443 have significantly different success rates, with connections on port 443 being
> significantly more likely to succeed**, though this may change with time."
**RFC 6455 §4.1 zum expliziten Proxy:**
> „If the client is configured to use a proxy when using the WebSocket Protocol to connect to host
> /host/ and port /port/, then the client SHOULD connect to that proxy and ask it to open a TCP
> connection" (Beispiel: `CONNECT example.com:80 HTTP/1.1`, optional mit `Proxy-authorization`)
> „Implementations […] are encouraged to use a SOCKS5 [RFC1928] proxy for WebSocket connections,
> if available, or failing that, **to prefer the proxy configured for HTTPS connections over the
> proxy configured for HTTP connections**."
**RFC 6455 §10.3** erklärt, warum Client-Masking überhaupt existiert: damit Intermediaries
WS-Frames nicht als HTTP-Requests fehlinterpretieren („a client MUST mask all frames that it
sends to the server […] whether or not the WebSocket Protocol is running over TLS").
**Idle-Timeouts der Infrastruktur — die Zahlen, die das Heartbeat-Intervall bestimmen:**
| Hop | Default | Konfigurierbar | Quelle |
|---|---|---|---|
| **Azure Application Gateway request timeout** (gilt für die WS-Session!) | **20 s** | 1–86400 s | https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-websocket |
| **GCP ALB backend service timeout** | **30 s** | 1 s–… | https://docs.cloud.google.com/load-balancing/docs/https/request-distribution |
| **nginx `proxy_read_timeout`** | **60 s** | ja | https://nginx.org/en/docs/http/ngx_http_proxy_module.html |
| **AWS ALB / Classic ELB idle** | **60 s** | 1–4000 s | https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html |
| **Azure Load Balancer TCP idle** | **4 min** | 4–100 min | https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-tcp-idle-timeout |
| **AWS NLB TCP/TLS** | **350 s** | 60–6000 s (TCP) | AWS-Doku |
| **AWS API Gateway WS idle** | **10 min** | **nein** | s. §2.2 |
| **AWS API Gateway WS max. Dauer** | **2 h** | **nein** | s. §2.2 |
| **GCP global ext. ALB, aktive WS** | **24 h** (fix) | nein | GCP-Doku |
| **Linux `tcp_keepalive_time`** | **7200 s** | ja | `man 7 tcp` |
nginx, Schlusssatz der WebSocket-Seite (https://nginx.org/en/docs/http/websocket.html), wörtlich:
> „By default, the connection will be closed if the proxied server does not transmit any data
> within 60 seconds. This timeout can be increased with the `proxy_read_timeout` directive.
> Alternatively, **the proxied server can be configured to periodically send WebSocket ping frames
> to reset the timeout and check if the connection is still alive.**"
Azure Application Gateway, wörtlich — der einzige Anbieter, der eine konkrete Faustregel für das
Heartbeat-Intervall nennt:
> „Ensure that your timeout value is greater than your server-defined ping/pong interval to avoid
> experiencing timeout errors before a ping is sent from the client. **A typical value for a
> WebSocket is 20 seconds, so, for example, a timeout value of 40 seconds** ensures that the
> gateway doesn't send a timeout error before the client sends a ping. Otherwise, this condition
> throws a **1006 error** on the client side."
GCP, wichtige Unterscheidung:
> Global external ALB: „Active websocket connections don't use the configured backend service
> timeout […] closed after 24 hours (86,400 seconds). This 24-hour limit is fixed […] **Idle
> websocket connections are closed after the backend service times out.**"
> Classic ALB: „Websocket connections, **whether idle or active**, automatically close after the
> backend service times out." (Default 30 s!)
**Cloudflare — Korrektur einer verbreiteten Zahl:** Die kursierenden „100 Sekunden" stehen in
**keiner** Cloudflare-Primärquelle. https://developers.cloudflare.com/network/websockets/
dokumentiert nur qualitativ:
> „Cloudflare will close a WebSocket connection when no data is transmitted in either direction
> for a period of time. Enterprise customers can contact their account team to configure a custom
> idle timeout. **To keep long-lived connections alive during periods of inactivity, implement a
> client-side heartbeat (ping/pong) mechanism.**"
> „When Cloudflare releases new code to its global network, we may restart servers, **which
> terminates WebSockets connections.**"
**HAProxy — Korrektur:** Es gibt **keine** dokumentierten numerischen Defaults für
`timeout client`/`server`/`tunnel`; die oft zitierten „50s" stammen aus Distro-Beispiel-Configs.
Die Doku (https://docs.haproxy.org/3.0/configuration.html §4.2) sagt zu `timeout tunnel`:
> „This timeout **supersedes both the client and server timeouts** once the connection becomes a
> tunnel. […] In HTTP, this timeout is used when a connection is upgraded (e.g. when switching to
> the WebSocket protocol…)" — Doku-Beispiel: `timeout tunnel 1h`
**Tauri-spezifisch:** Der System-WebView respektiert die OS-Proxy-Konfiguration; eine Tauri-API
zum Setzen von Proxy-Einstellungen fehlte lange (https://github.com/tauri-apps/tauri/issues/4263,
wry-Limitierung). Bei WebView2 lässt sich `--proxy-server` über `AdditionalBrowserArguments`
setzen. Für den Rust-seitigen WS-Client gilt §1.6: CONNECT-Tunnel selbst bauen.
### 3.6 Übliche Intervalle etablierter Systeme
| System | Intervall | Timeout | Quelle |
|---|---|---|---|
| **Socket.IO v4** | `pingInterval` **25000 ms** | `pingTimeout` **20000 ms**, `connectTimeout` 45000 ms | https://socket.io/docs/v4/server-options/ |
| **Phoenix JS** | `heartbeatIntervalMs` **30000 ms** | `DEFAULT_TIMEOUT` 10000 ms | https://github.com/phoenixframework/phoenix/blob/main/assets/js/phoenix/socket.js |
| **Centrifugo** | `client.ping_interval` **„25s"** | `client.pong_timeout` **„8s"**, `stale_close_delay` „10s" | https://centrifugal.dev/docs/server/configuration |
| **Discord Gateway** | server-diktiert via `heartbeat_interval` (Doku-Beispiel `45000`) | kein ACK → Reconnect | https://docs.discord.com/developers/events/gateway |
| **Slack RTM (legacy)** | „every few seconds" | — | https://docs.slack.dev/legacy/legacy-rtm-api/ |
| **MQTT v5.0** | `Keep Alive` (client-gewählt) | **1,5 × Keep Alive** | https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html (07.03.2019) |
Socket.IO, wörtlich — die klarste Formulierung der beidseitigen Erkennung:
> „The server sends a ping packet every `pingInterval` ms, and if the client does not answer with
> a pong within `pingTimeout` ms, the server considers that the connection is closed. Similarly,
> if the client does not receive a ping packet from the server within
> `pingInterval + pingTimeout` ms, then the client also considers that the connection is closed."
> Warnung: „Using a small value like `1000` (one heartbeat per second) will incur some load on
> your server, which might become noticeable with a few thousands connected clients."
Discord, wörtlich — **Jitter schon beim ersten Heartbeat**:
> „your app should wait `heartbeat_interval * jitter` where `jitter` is any random value between
> 0 and 1, then send its first Heartbeat."
> „If a client does not receive a heartbeat ACK between its attempts at sending heartbeats, this
> may be due to a failed or 'zombied' connection. The client should **immediately terminate the
> connection with any close code besides `1000` or `1001`**, then reconnect and attempt to Resume."
MQTT v5.0 §3.1.2.10:
> „If the Keep Alive value is non-zero and the Server does not receive an MQTT Control Packet from
> the Client within one and a half times the Keep Alive time period, it MUST close the Network
> Connection"
Slack RTM zeigt zusätzlich das Latenz-Muster: „a client could include a local timestamp in the
ping message so it can calculate round-trip latency" (Server echot die Felder zurück) — das
entspricht der RFC-6455-Payload-Korrelation aus §3.1.
**Korrektur:** Der oft genannte Discord-Wert **41250 ms** ist nicht dokumentiert; die aktuelle
Doku zeigt `45000` im Beispiel. Der Wert ist server-diktiert und nicht garantiert.
### 3.7 Optionen-Gegenüberstellung Heartbeat
| Option | Pro | Contra |
|---|---|---|
| **Protokoll-Ping/Pong (RFC 6455)** | Vom Standard vorgesehen; von axum/tungstenite serverseitig **automatisch** beantwortet, also kein Applikationscode für die Antwort; Payload-Korrelation für RTT; resettet L7-Idle-Timer, weil es echte Frames sind (im Gegensatz zu TCP-Keepalive) | **Im Browser-WebView nicht auslösbar und nicht beobachtbar** (WHATWG: „not currently exposed in the API") → clientseitige Liveness-Erkennung unmöglich; unsolicited Pong erzeugt keine Antwort und beweist nichts |
| **Application-Level-Heartbeat (JSON-Frame)** | Funktioniert in WebView und Rust-Layer identisch; beidseitige Liveness-Erkennung; kann Nutzlast tragen (Cursor, Server-Zeit, `reconnect_after`-Hinweis); von Socket.IO, Phoenix, Centrifugo, Discord durchgehend so gebaut | Zusätzliche Protokoll-Nachrichten im als „Dumb Pipe" gesetzten Kanal; kostet Serverlast bei vielen Verbindungen (Socket.IO warnt explizit ab ~1 s Intervall bei „a few thousands connected clients"); doppelt sich mit dem Protokoll-Ping, wenn beides läuft |
| **Nur TCP-Keepalive** | Kein Applikationscode | Vier belegte Gründe dagegen (§3.4) — Default 7200 s, per Default aus, kein L7-Timer-Reset, greift nicht über L7-Terminierung hinweg |
**Intervall-Korridor aus den Zahlen:** Die kleinsten dokumentierten Infrastruktur-Timeouts liegen
bei 20 s (Azure AppGW) bzw. 30 s (GCP Classic ALB Default). Die etablierten
Application-Heartbeats liegen bei 25–30 s. Azures eigene Faustregel („typical value for a
WebSocket is 20 seconds […] timeout value of 40 seconds") beschreibt dasselbe Verhältnis von
etwa 1:2 zwischen Heartbeat und Idle-Timeout.
---
## 4. Reconnect-Strategie
### 4.1 Exponentielles Backoff mit Jitter — die Referenz
**Marc Brooker, „Exponential Backoff And Jitter", AWS Architecture Blog, 4. März 2015**
(https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/), mit
Autoren-Update: *„Update (May 2023): After 8 years, this solution continues to serve as a pillar
for how Amazon builds remote client libraries for resilient systems."*
**Verifikationshinweis:** Die Formeln stehen im Artikel nicht als Text, sondern als PNGs mit
leerem `alt`-Attribut. Die kursierenden Textvarianten (`random(...)`, `tmp = ...`) sind falsch.
Aus den Bildern ausgelesen:
```
# Capped Exponential Backoff ("No Jitter"), Figure 3
sleep = min(cap, base * 2 ** attempt)
# Full Jitter, Figure 6
sleep = random_between(0, min(cap, base * 2 ** attempt))
# Equal Jitter, Figure 10
temp = min(cap, base * 2 ** attempt)
sleep = temp / 2 + random_between(0, temp / 2)
# Decorrelated Jitter, Figure 11
sleep = min(cap, random_between(base, sleep * 3))
```
**Zweiter Verifikationshinweis:** Der Artikel enthält **keine Ergebnistabelle** — nur 13 Grafiken
und Fließtext. Die quantitativen Aussagen sind qualitativ formuliert:
> „With N clients contending, the total amount of work done by the system increases with N²."
> „In the case with 100 contending clients, we've reduced our call count by more than half."
> „**The no-jitter exponential backoff approach is the clear loser.** It not only takes more work,
> but also takes more time than the jittered approaches."
> „Of the jittered approaches, '**Equal Jitter' is the loser.** It does slightly more work than
> 'Full Jitter', and takes much longer. The decision between 'Decorrelated Jitter' and 'Full
> Jitter' is less clear. **The 'Full Jitter' approach uses less work, but slightly more time.**"
> „It's worth noting that none of these approaches fundamentally change the N² nature of the work
> to be done, but do substantially reduce work at reasonable levels of contention. **The return on
> implementation complexity of using jittered backoff is huge, and it should be considered a
> standard approach for remote clients.**"
Simulator-Code: https://github.com/awslabs/aws-arch-backoff-simulator
### 4.2 Weitere Anbieter-Formeln und -Regeln
**Google Cloud, „truncated exponential backoff"**
(https://docs.cloud.google.com/memorystore/docs/redis/exponential-backoff, Stand 2026-07-29):
> „The wait time is `min(((2^n)+random_number_milliseconds), maximum_backoff)`, with `n`
> incremented by 1 for each iteration (request)."
> „`random_number_milliseconds` is a random number of milliseconds less than or equal to 1000.
> **This helps to avoid cases where many clients get synchronized by some situation and all retry
> at once, sending requests in synchronized waves.**"
> `maximum_backoff` „is typically **32 or 64 seconds**."
> „It's okay to continue retrying once you reach the `maximum_backoff` time."
Konkrete GCP-Client-Library-Defaults
(https://docs.cloud.google.com/storage/docs/retry-strategy): Go 1 s / ×2.0 / max 30 s;
Java 1 s / ×2.0 / max 32 s / total 50 s; Node.js 1 s / ×2 / max 64 s / total 600 s.
**AWS SDK Retry Behavior**
(https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) — Achtung, die
Seite beschreibt inzwischen das 2026er-Verhalten (Opt-in via `AWS_NEW_RETRIES_2026=true`):
`retry_mode` = **standard**, `max_attempts` = **3**. Formel wörtlich:
`delay = random(0, 1) × min(20,000 ms, base_delay × 2^retry)` — „The SDK uses **exponential
backoff with full jitter**." Base delay: transient **50 ms**, throttling **1000 ms**. Zusätzlich
ein **Retry-Quota-Token-Bucket** (500 Tokens, 14 pro transientem Retry, 5 pro Throttling-Retry,
+1 bei Erfolg ohne Retry) — d. h. Retries sind global gedeckelt, nicht nur pro Request.
**Azure Transient-Fault-Guidance**
(https://learn.microsoft.com/en-us/azure/architecture/best-practices/transient-faults,
`ms.date: 2026-02-26`) — die dichteste Sammlung harter Regeln:
> „**Add jitter, which is a small random delay, to each retry interval to prevent multiple clients
> from syncing their retries and creating load spikes on the target service.**"
> „**Use an exponential back-off strategy with jitter for background operations, and use immediate
> or regular interval retry strategies for interactive operations.**"
> „**Never implement an endless retry mechanism.** This approach typically prevents the resource or
> service from recovering from overload situations."
> „**Never do an immediate retry more than once.**"
> „Implement a **retry budget** to limit the total number of retries across all requests within a
> process or service… For example, you might allow a process to do no more than **60 retries per
> minute** against a specified dependency."
> „When a response includes a `Retry-After` header, follow it and wait at least the specified
> duration before the next attempt. This server-provided signal reflects the service's recovery
> timeline and **takes precedence over your client-side back-off calculation**."
> „Most 4xx client errors, like 400, 401, 403, and 404, indicate problems that a retry doesn't
> resolve."
**Korrektur:** `learn.microsoft.com/.../best-practices/retry-service-specific` liefert HTTP 301 —
die alten service-spezifischen Default-Tabellen sind nicht mehr zitierbar.
### 4.3 Konkrete Parameter dokumentierter WS-Clients
**Socket.IO v4** (https://socket.io/docs/v4/client-options/):
| Option | Default | Doku-Text |
|---|---|---|
| `reconnectionAttempts` | `Infinity` | — |
| `reconnectionDelay` | `1000` | „The initial delay before reconnection in milliseconds (affected by the randomizationFactor value)." |
| `reconnectionDelayMax` | `5000` | „The maximum delay between two reconnection attempts. **Each attempt increases the reconnection delay by 2x.**" |
| `randomizationFactor` | `0.5` | „The randomization factor used when reconnecting (**so that the clients do not reconnect at the exact same time after a server crash**, for example)." |
| `timeout` | `20000` | „The timeout in milliseconds for each connection attempt." |
Rechenbeispiel aus der Doku: 1. Versuch zwischen 500 und 1500 ms, 2. zwischen 1000 und 3000 ms,
3. zwischen 2000 und 5000 ms, danach 5000 ms. *(Die Doku enthält an dieser Stelle einen
Tippfehler — der Multiplikator ist uniform in [0.5, 1.5], nicht „-0.5 and 1.5". Beim 3. Versuch
ist die Obergrenze 5000 statt 6000, weil `reconnectionDelayMax` die **gejitterte** Zahl deckelt.)*
**Phoenix JS**
(https://github.com/phoenixframework/phoenix/blob/main/assets/js/phoenix/socket.js):
```javascript
this.reconnectAfterMs = (tries) => { … return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000 }
this.rejoinAfterMs = (tries) => { … return [1000, 2000, 5000][tries - 1] || 10000 }
```
**Korrektur zur Ticket-Formulierung:** Es ist ein **9-elementiges** Array mit Fallback 5000 ab
Versuch 10 — nicht 10 Elemente. `rejoinAfterMs`-Fallback ist 10000. Kein `maxReconnectAttempts`,
Phoenix reconnected unbegrenzt. Bemerkenswert: **kein Jitter**, dafür ein sehr aggressiver Start
(10 ms) für den häufigsten Fall — den kurzen Netz-Glitch.
**centrifuge-js** (https://github.com/centrifugal/centrifuge-js):
> `minReconnectDelay`: „When client disconnected from a server it will automatically try to
> reconnect using a **backoff algorithm with jitter**. […] Default is `500` milliseconds."
> `maxReconnectDelay`: „sets an upper reconnect delay value. Default is `20000` milliseconds"
> `maxServerPingDelay`: „sets the maximum delay of server pings after which connection is
> considered broken and client reconnects. […] Default is `10000`."
**Ably** (https://ably.com/docs/connect/states):
> „if the connection drops at any time it will attempt to re-establish it by making repeated
> connection attempts **every 15 seconds for up to two minutes**."
> `suspended`: „In the suspended state, an SDK will periodically attempt to open a new connection
> **every 30 seconds**."
(`disconnectedRetryTimeout` 15000 ms, `suspendedRetryTimeout` 30000 ms.)
**Spannbreite der dokumentierten Praxis:** Startverzögerung 10 ms (Phoenix) bis 15 s (Ably);
Obergrenze 5 s (Socket.IO) bis 30 s (Ably) bis 64 s (GCP). Jitter: Socket.IO ±50 %, GCP +≤1000 ms,
AWS Full Jitter, centrifuge-js „mit Jitter" (Faktor nicht dokumentiert), Phoenix gar keiner.
### 4.4 Welcher Fehler rechtfertigt welchen Reconnect?
**RFC 6455 §7.4.1**, die für dieses Projekt relevanten Codes:
| Code | Bedeutung |
|---|---|
| 1000 | Normal Closure |
| 1001 | Going Away (Server-Shutdown oder Browser-Navigation) |
| 1005 | No Status Rcvd — **nur intern, nie auf dem Draht** |
| 1006 | Abnormal Closure — **nur intern, kein Close-Frame empfangen** |
| 1008 | Policy Violation |
| 1009 | Message Too Big |
| 1011 | Internal Error |
**1006 ist der Code, den man bei jedem stillen Proxy-Kill sieht** — genau der Fall, den Azure
AppGW dokumentiert („throws a 1006 error on the client side", §3.5). 1005/1006/1015 dürfen nie in
einem Close-Frame gesendet werden.
**Discord Gateway Close Event Codes** — die einzige öffentlich vollständige
`reconnect: true/false`-Tabelle (https://docs.discord.com/developers/topics/opcodes-and-status-codes):
| Code | Beschreibung | Reconnect |
|---|---|---|
| 4000 | Unknown error | true |
| 4003 | Not authenticated | true |
| **4004** | **Authentication failed** | **false** |
| 4005 | Already authenticated | true |
| 4008 | Rate limited | true |
| 4009 | Session timed out | true |
| 4010–4014 | Invalid shard / Sharding required / Invalid API version / Invalid+Disallowed intents | **false** |
Discords Leitsatz: „In order to prevent broken reconnect loops, you should consider some close
codes as a signal to stop reconnecting. This can be because your token expired, or your
identification is invalid."
**Das dokumentierte Muster ist eine 3-Klassen-Einteilung:**
(a) **sofort/schnell** — server-initiierte Umverteilung (Discord Opcode 7, 4009, 1001 Going Away),
(b) **Backoff** — transiente Fehler, 1006, 1011, Rate-Limits,
(c) **gar nicht** — Auth-/Konfigurationsfehler, analog zu Azures „Most 4xx client errors […]
indicate problems that a retry doesn't resolve."
Pusher kodiert dieselbe Dreiteilung in Code-**Bereichen** (4000–4099 / 4100–4199 / 4200–4299,
§1.8), Centrifugo in terminal/nicht-terminal.
### 4.5 Thundering Herd beim Server-Neustart
**Discord, „You've Got (Too Much) Mail: Behind the Scenes of the 3/25/26 Voice Outage", publiziert
29. April 2026** (https://discord.com/blog/behind-the-scenes-of-the-3-25-26-voice-outage) — der
aktuellste datierte Praxisbericht zum Thema:
- Auslöser: „17% of sessions across Discord were ungracefully stopped." → „each gateway process
for each affected user gets a `:DOWN` message, signaling to the client that their session is now
invalid and requires a reconnection."
- Verstärkung: „a selective receive on a supervisor with a ~100k mailbox queue adds ~1ms to
process spawn time" — die Supervisor-Mailbox erreichte ~1 Mio. Einträge; „new connections are
blocked because they cannot be opened within the connection timeout."
- Mitigationen: **Rate-Limiting** („low syncer spawn limits"), Architektur (`PartitionSupervisor`
statt einzelnem GenServer), Kapazität, und **gestaffelte Wiederaufnahme statt Vollneustart**.
- Kernaussage: **Der Recovery-Verkehr selbst war das Problem.** Erholung durch Reduktion
eingehender Arbeit und schrittweises Zurückholen der Systeme.
**Slack, „Migrating Millions of Concurrent Websockets to Envoy", 15. März 2021** (Update
21. Juni 2021), Ariane van der Stedt & Radha Kumari
(https://slack.engineering/migrating-millions-of-concurrent-websockets-to-envoy/) — relevant fürs
Deployment-Design:
> „We'd keep running the old process for many hours to allow long-lived websocket connections to
> drain and avoid frequent disconnections of users."
*Einschränkung: Der Artikel behandelt Reconnect-Stürme und Client-Backoff nicht. Die verbreiteten
Aussagen über Slacks „Flannel"- und „Governor"-Komponenten stammen aus Sekundär-Blogs und sind
nicht zitierfähig.*
*Negativbefund: „How Discord Handles Two and Half Million Concurrent Voice Users using WebRTC"
(12.09.2018, https://discord.com/blog/how-discord-handles-two-and-half-million-concurrent-voice-users-using-webrtc)
behandelt WebRTC-Voice-Skalierung, **nicht** WebSocket-Reconnect-Stürme.*
**Server-gesteuerte Reconnects — dokumentierte Muster:**
| Muster | Beleg |
|---|---|
| **Server sagt „reconnect jetzt"** — Discord Opcode 7 „Reconnect": „You should attempt to reconnect and resume immediately." Der Server verteilt Verbindungen gestaffelt selbst um, statt sie alle gleichzeitig zu droppen | https://docs.discord.com/developers/topics/opcodes-and-status-codes |
| **Server gibt die Ziel-URL vor** — Discord `resume_gateway_url` im Ready-Event → Lastverteilung beim Reconnect ist serverseitig steuerbar | ebd. |
| **`Retry-After` beim 503 auf den Handshake** — RFC 9110 §10.2.3: „The 'Retry-After' header field indicates how long a user agent ought to wait before making a follow-up request." ABNF: `Retry-After = HTTP-date / delay-seconds`. §15.6.4: „The server SHOULD generate a 'Retry-After' header field to indicate when the client may retry the request." Da der WS-Handshake ein normaler HTTP-Request ist, voll anwendbar — und laut Azure überstimmt er die Client-Backoff-Berechnung | https://www.rfc-editor.org/rfc/rfc9110.txt |
| **Verbindungs-Draining statt Hard-Cut** — alten Prozess stundenlang weiterlaufen lassen | Slack, 15.03.2021 |
| **Rate-Limiting des Recovery-Pfads** | Discord, 29.04.2026 |
**Optionen-Gegenüberstellung Thundering Herd:**
| Option | Pro | Contra |
|---|---|---|
| **Nur Client-Jitter** | Kein Serverzustand, kein Protokoll; von AWS als „standard approach for remote clients" bezeichnet | Ändert laut Brooker die N²-Natur des Problems **nicht**, reduziert sie nur; hilft nicht, wenn der Server nach dem Neustart sofort wieder überlastet ist |
| **Server-gesteuerter Reconnect-Hinweis** (Close-Code + `reconnect_after`, oder `Retry-After` beim 503) | Der Server kennt seinen Zustand; Azure sagt explizit, dass dieser Wert die Client-Berechnung überstimmt; erlaubt gestaffeltes Zurückholen wie bei Discord | Braucht ein Protokollfeld bzw. einen 503-Pfad und Client-Logik, die ihn respektiert; nützt nichts, wenn der Server gar nicht antwortet (Neustart-Fenster) |
| **Draining beim Deployment** | Vermeidet den Sturm ganz statt ihn abzufedern | Zwei Prozessgenerationen parallel — kollidiert mit der Map-Regel „eine Binary + Postgres" und der In-Process-Fan-out-Entscheidung |
| **Rate-Limiting der Accept-Rate** | Schützt den Server unabhängig vom Client-Verhalten; Discords wirksamste Maßnahme im Vorfall vom 25.03.2026 | Verschiebt das Problem in die Client-Backoff-Schleife; braucht sinnvolle 503-/Close-Code-Antworten, sonst produziert es blindes Retry |
### 4.6 Netzwerkwechsel-Erkennung
MDN `Navigator.onLine`
(https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine), wörtlich:
> „On Windows, the online status is determined by whether it can reach a Microsoft home server,
> **which may be blocked by firewalls or VPNs**, even if the computer has Internet access.
> Therefore, this property is inherently unreliable, and you should not disable features based on
> the online status, only provide hints when the user may seem offline."
Praktische Asymmetrie (MDN `Window: offline_event`): `offline` ist verlässlich, `online` nicht
(Netz ja, Internet vielleicht). → `online` taugt als **Trigger, den Backoff-Timer
zurückzusetzen**, nicht als Beweis für Konnektivität. Für Firmen-VPNs ist die Windows-Heuristik
zusätzlich unzuverlässig — direkt relevant für die Zielgruppe.
**Tauri v2** hat kein Core-API für Netzwerk-Status. Community-Plugins:
`tauri-plugin-network-information` (iOS/Android, `connection()` + Change-Listener,
https://github.com/wtto00/tauri-plugin-network-information),
`tauri-plugin-network` (Desktop, https://github.com/HuakunShen/tauri-plugin-network).
Auf Mobile im Foreground gilt zusätzlich: OS-seitige App-Suspension beendet die WS-Verbindung
**ohne Close-Frame** → `1006` beim Wiederaufwachen.
---
## 5. Axum 0.8 konkret
### 5.0 Bestandsaufnahme rustification-server
Gelesen, nichts geändert. Relevante Dateien:
- `C:\Projekte\Privat\Rustification\rustification-server\Cargo.toml`
- `C:\Projekte\Privat\Rustification\rustification-server\src\main.rs`
- `C:\Projekte\Privat\Rustification\rustification-server\src\route.rs`
- `C:\Projekte\Privat\Rustification\rustification-server\src\middleware.rs`
- `C:\Projekte\Privat\Rustification\rustification-server\src\model.rs` (`Claims { sub: String, exp: usize }`)
```toml
edition = "2024"
axum = "0.8.9" # KEIN features = ["ws"]
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.9", features = ["runtime-tokio","tls-rustls-ring-webpki","postgres","chrono","uuid"] }
jsonwebtoken = "9"
```
Nicht vorhanden und für WS nötig: `axum`-Feature `ws`; `futures`/`futures-util` (für
`StreamExt::split`); `tokio-util` (für `CancellationToken`/`TaskTracker`); `axum-extra` (für
`TypedHeader`).
- **AppState** (`main.rs:14-17`): `pub struct AppState { pub db: PgPool, pub jwt_secret: String }`
— **nicht** `Clone`, überall als `Arc<AppState>`; State-Typ ist also `State<Arc<AppState>>`.
- **Router** (`main.rs:46-48`): `Router::new().nest("/api/v1", route::router(state.clone())).with_state(state)`.
- **Auth-Middleware**: `axum::middleware::from_fn_with_state(state, require_auth)` als `.layer(...)`
auf dem `protected`-Subrouter (`route.rs:104`). `require_auth` validiert das Bearer-JWT und macht
`req.extensions_mut().insert(data.claims)` (`middleware.rs:31`). **Kein**
`FromRequestParts`-Extractor.
- **Auffällig:** `Extension<Claims>` wird in **keinem** Handler gelesen — die eingefügte Extension
ist derzeit ungenutzt.
- **Graceful Shutdown existiert nicht:** `main.rs:52` ist `axum::serve(listener, app).await.unwrap();`
Auch kein `into_make_service_with_connect_info` — `ConnectInfo` würde zur Laufzeit fehlschlagen.
### 5.1 WebSocket-Upgrade mit State
`axum::extract::ws` ist „available via the `ws` crate feature"
(https://docs.rs/axum/0.8.9/axum/extract/ws/index.html).
**Breaking Changes 0.7 → 0.8** (https://github.com/tokio-rs/axum/blob/main/axum/CHANGELOG.md):
> **breaking:** `axum::extract::ws::Message` now uses `Bytes` in place of `Vec<u8>`, and a new
> `Utf8Bytes` type in place of `String`, for its variants (#3078)
> **changed:** Upgraded `tokio-tungstenite` to 0.26 (#3078)
> **breaking:** Remove `WebSocket::close`. Users should explicitly send close messages themselves. (#2974)
> **added:** Add support for WebSockets over HTTP/2. They can be enabled by changing
> `get(ws_endpoint)` handlers to `any(ws_endpoint)` (#2894)
> **breaking:** Make `serve` generic over the listener and IO types (#2941)
*(Hinweis: Die Überschrift im Repo lautet `# 0.8.0 (27. November, 2023)` — ein Tippfehler im
axum-Repo; das echte 0.8.0-Release ist https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0
vom 01.01.2025. Der Blogpost erwähnt WebSockets nicht; die WS-Änderungen stehen nur im CHANGELOG.)*
**`Message`-Varianten:** `Text(Utf8Bytes)`, `Binary(Bytes)`, `Ping(Bytes)`, `Pong(Bytes)`,
`Close(Option<CloseFrame>)`.
**Extractor-Reihenfolge — `WebSocketUpgrade` ist `FromRequestParts`.** Quellcode
(https://github.com/tokio-rs/axum/blob/main/axum/src/extract/ws.rs):
```rust
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:
```rust
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`:
```rust
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::Ping` als
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 ist `any(ws_handler)`; setzt
`server.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` — **nutzt `tokio::sync::broadcast`**:
`struct AppState { user_set: Mutex<HashSet<String>>, tx: broadcast::Sender<String> }`,
`broadcast::channel(100)`, `split()` + `tokio::select!` mit `abort()`
(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):
```rust
pub fn on_upgrade<C, Fut>(self, callback: C) -> Response {
tokio::spawn(async move {
let upgraded = match on_upgrade.await { … }; // hyper::upgrade::OnUpgrade
let socket = WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, Some(config)).await;
callback(WebSocket { inner: socket, protocol }).await;
});
let mut response = Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.header(header::CONNECTION, UPGRADE)
.header(header::UPGRADE, WEBSOCKET)
.header(header::SEC_WEBSOCKET_ACCEPT, sign(sec_websocket_key.as_bytes()))
.body(Body::empty()).unwrap();
…
response
}
```
**Der Handler kehrt sofort mit einem gewöhnlichen `Response` (101) zurück.** Der Upgrade-Request
ist bis dahin ein normaler HTTP/1.1-`GET` (bzw. HTTP/2-`CONNECT`) und durchläuft die volle
Tower-Kette wie jede REST-Route. Erst `hyper::upgrade::OnUpgrade` resolved in der separaten
`tokio::spawn`-Task, nachdem hyper die 101 geschrieben und die IO gehijackt hat
(https://docs.rs/hyper/latest/hyper/upgrade/index.html).
**Kann Middleware mit 401 ablehnen? Ja.** Die bestehende `require_auth` gibt
`Err((StatusCode::UNAUTHORIZED, …))` zurück, bevor `next.run(req)` läuft — der Handler wird nie
aufgerufen, `on_upgrade` nie erreicht, hyper sendet keine 101. Standard-Tower-Pfad, nichts
WS-Spezifisches. Was der Client davon sieht, hängt an der Vorbemerkung (WebView: nur `1006`).
**Extensions — zwei Fallstricke:**
1. Von der Middleware via `extensions_mut().insert(claims)` eingefügte Werte sind im Handler über
`Extension<Claims>` sichtbar (Standardmechanik von `from_fn`).
2. **`WebSocketUpgrade::from_request_parts` macht `parts.extensions.remove::<hyper::upgrade::OnUpgrade>()`.**
Baut eine Middleware den Request neu, ohne `extensions` zu übernehmen, ist `OnUpgrade` weg →
Rejection `ConnectionNotUpgradable`. **Das ist der wichtigste Middleware-Fallstrick bei WS.**
3. **Die `on_upgrade`-Closure ist `'static`** (`C: FnOnce(WebSocket) -> Fut + Send + 'static`).
Request-Extensions überleben **nicht** — `Parts` wird nach dem Handler gedroppt. Alles, was die
Task braucht (Claims, `Arc<AppState>`, `SocketAddr`), muss vorher extrahiert/geklont und per
`move` hineingezogen werden.
**Response-Middleware nach 101 / Timeouts:** `tower_http::timeout::TimeoutLayer` misst bis zum
Response — bei WS ist das **sofort** (101 mit `Body::empty()`). Die spätere WS-Task ist davon
nicht betroffen; `ResponseBodyTimeoutLayer` greift auf einem leeren Body ebenfalls nicht
(https://docs.rs/tower-http/latest/tower_http/timeout/index.html).
⚠️ **Nicht belegbar:** Es wurde **kein** Issue gefunden, das nachweist, dass `TimeoutLayer` eine
bereits geupgradete WS-Verbindung abbricht. Gefundene, aber andere Probleme betreffende Issues:
[axum#1260](https://github.com/tokio-rs/axum/issues/1260) („server stucks when large of websocket
connection closed"), [axum Discussion #2597](https://github.com/tokio-rs/axum/discussions/2597),
[axum#2964](https://github.com/tokio-rs/axum/issues/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):
```rust
let (signal_tx, signal_rx) = watch::channel(());
executor.execute(async move { signal.await; drop(signal_rx); });
let (close_tx, close_rx) = watch::channel(());
loop {
let (io, remote_addr) = match select(pin!(listener.accept()), pin!(signal_tx.closed())).await {
Either::Left((conn, _)) => conn,
Either::Right(_) => break, // keine neuen Verbindungen mehr
};
handle_connection(&mut make_service, &signal_tx, &close_rx, io, remote_addr, &executor).await;
}
drop(close_rx); drop(listener);
close_tx.closed().await; // wartet auf die Connection-Tasks
```
**Belege, dass upgegradete Verbindungen NICHT beendet und NICHT abgewartet werden:**
1. Der `close_rx`-Guard hängt **nur** an der hyper-Connection-Task. Die WS-Task ist ein
eigenständiges `tokio::spawn` in `on_upgrade` **ohne** `close_rx` ⇒ `close_tx.closed().await`
wartet nicht auf sie.
2. `hyper_util`-`UpgradeableConnection::graceful_shutdown`
(https://docs.rs/hyper-util/latest/hyper_util/server/conn/auto/struct.UpgradeableConnection.html):
> „This should only be called while the `Connection` future is still pending. **If called after
> `UpgradeableConnection::poll` has resolved, this does nothing.**"
Nach dem Hijack ist die Connection-Future resolved ⇒ der Aufruf ist ein No-op; das Socket
gehört jetzt dem `WebSocketStream`.
3. **[axum Issue #3003 — „Graceful Shutdown ignores Websocket handler task"](https://github.com/tokio-rs/axum/issues/3003)**,
eröffnet **21.10.2024**, **Status offen**:
> „the task spawned in `ws.on_upgrade` is not connected to the graceful shutdown future at all"
Und: selbst mit eigenem `CancellationToken` ist nicht garantiert, dass die Verbindung
geschlossen ist, wenn die Shutdown-Future resolved.
4. **[axum PR #3831 — „fix(serve): include WebSocket tasks in graceful shutdown"](https://github.com/tokio-rs/axum/pull/3831)**,
eingereicht **11.07.2026**, letzte Aktivität **28.07.2026**, **Status offen**:
> „WebSocket handlers escape all of it … the shutdown future then resolves while sockets are
> still live, so handler state outlives 'shutdown'"
Der PR fügt eine `GracefulPeer`-Struktur mit Connection-Guards in die Request-Extensions ein
und sendet beim Shutdown einen Close-Frame.
5. Gegenbeispiel zur Abgrenzung: [axum#2673](https://github.com/tokio-rs/axum/issues/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):
```rust
let token = shutdown_token.child_token();
loop {
tokio::select! {
_ = token.cancelled() => { // cancel safe (Doku oben)
let _ = sender.send(Message::Close(Some(CloseFrame {
code: close_code::AWAY, // 1001 "going away"
reason: "server shutting down".into(),
}))).await;
break;
}
msg = receiver.next() => { … }
ev = rx.recv() => { … } // broadcast::Receiver::recv, cancel safe
}
}
```
`WebSocket::close` existiert seit 0.8 **nicht mehr** (CHANGELOG #2974) → Close-Frame explizit
über `send`. Für einen Neustart wäre alternativ `close_code::RESTART` (1012) protokollierbar
(§5.1) — was direkt an §4.4 anschließt.
**Cancel-Safety** — `tokio::select!`-Doku
(https://docs.rs/tokio/latest/tokio/macro.select.html):
> „Cancellation safety describes what happens when a future is dropped before it completes."
> Grundregel: „If you have a future that has not yet completed, then it must be a no-op to drop
> that future and recreate it"
Als cancel-**sicher** gelistet: `mpsc::Receiver::recv`, **`broadcast::Receiver::recv`**,
`watch::Receiver::changed`, `AsyncReadExt::read`, sowie pauschal „Stream operations via
`tokio_stream` and `futures` crates". **Nicht** cancel-sicher: `read_exact`, `read_to_end`,
`write_all`, `Mutex::lock`, `RwLock::read`/`write`, `Semaphore::acquire`, `Notify::notified`.
`broadcast::Receiver::recv` explizit
(https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Receiver.html):
> „**Cancel safety:** This method is cancel safe. If `recv` is used as a branch in `tokio::select!`
> and another branch completes first, it is guaranteed that no messages were received on this
> channel."
⚠️ **`SplitStream::next()` ist nur indirekt belegt.**
https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html#method.next dokumentiert nur
„Creates a future that resolves to the next item in the stream" — **keine explizite
Cancel-Safety-Aussage**. Der einzige Beleg ist die pauschale Zeile in der `select!`-Doku. Das ist
vermutlich der Grund, warum die axum-Beispiele (`chat`, `websockets`) durchweg **split-in-zwei-Tasks**
verwenden statt `select!` über den Socket-Stream — dieser Ansatz umgeht die Frage.
---
## 6. `tokio::sync::broadcast` und langsame Empfänger
Doku: Modul https://docs.rs/tokio/latest/tokio/sync/broadcast/index.html ·
`Sender` https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Sender.html ·
`Receiver` https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Receiver.html ·
`RecvError` https://docs.rs/tokio/latest/tokio/sync/broadcast/error/enum.RecvError.html
### 6.1 Grundsemantik
> „A multi-producer, multi-consumer broadcast queue. Each sent value is seen by all consumers."
> „When a value is sent, **all** `Receiver` handles are notified and will receive the value. The
> value is stored once inside the channel and **cloned on demand for each receiver**."
> „When a `Receiver` handle is dropped, any messages not read by the receiver will be marked as read."
Kapazität: „The provided `capacity` is rounded **up** to the next power of two." (3 → 4);
`capacity == 0` panikt.
**Senden blockiert nie.** `Sender::send` ist nicht `async`: „Attempts to send a value to all active
Receiver handles, returning it back if it could not be sent." Bei vollem Ring gilt (Modul-Doku):
**„the oldest value currently held by the channel is overwritten."** Rückgabe `Ok(usize)` = Anzahl
aktiver Receiver; `Err(SendError<T>)` „when there is no active `Receiver` handle" — der Wert kommt
zurück. `Sender::receiver_count`: „It is not guaranteed that a sent message will reach this number
of receivers."
### 6.2 `RecvError::Lagged(u64)` — exakte Semantik
> `Lagged(u64)`: „The receiver lagged too far behind: one or more messages were overwritten in the
> ring buffer before this receiver could read them. **The receiver remains subscribed. Its internal
> cursor has been advanced to the oldest message still retained by the channel;** the next
> successful `recv` call returns that message (unless further sends overwrite it first). The `u64`
> is the number of messages that were skipped."
`Receiver::recv`:
> „If the `Receiver` handle falls behind, once the channel is full, newly sent values overwrite old
> values in the ring buffer. The next call to `recv` then returns `Err(RecvError::Lagged(n))`,
> where `n` is the number of overwritten messages the receiver missed."
> „The receiver stays subscribed; its internal cursor is advanced to the oldest value still held by
> the channel."
**Antwort auf die drei Teilfragen des Tickets:** (a) `n` = Anzahl übersprungener Nachrichten;
(b) der Receiver bleibt **brauchbar**, `Lagged` ist kein Abbruch; (c) er springt auf die **älteste
noch vorhandene** Nachricht — er verliert also genau `n` Nachrichten, nicht mehr und nicht weniger.
### 6.3 Weitere API, die für das „Resync"-Problem relevant ist
- **`Receiver::len`:** „Returns the number of messages that were sent into the channel and that this
`Receiver` has yet to receive." → **direkt als Backlog-Metrik/Frühwarnung nutzbar, bevor `Lagged`
eintritt.**
- **`Receiver::resubscribe`:** „This `Receiver` handle will receive a clone of all values sent
**after** it has resubscribed." → verwirft explizit alles Alte; das ist der „Cursor auf jetzt
zurücksetzen"-Reset.
- **`Receiver::try_recv`** — nicht blockierend, `TryRecvError::{Closed, Empty, Lagged(n)}`.
- **Speicher:** Der Wert wird **einmal** gespeichert und „cloned on demand for each receiver" ⇒ `T`
sollte billig klonbar sein (`Arc<Event>`, `bytes::Bytes`). ⚠️ Ob der Ring bei `channel(n)` sofort
vollständig alloziert wird, sagt die Doku **nicht** ausdrücklich — nur die
Power-of-Two-Rundung und der Hinweis „Allocation failure may result in a panic".
### 6.4 „Lagged = Resync jetzt" — Optionen
⚠️ **Wichtiger Negativbefund:** Es wurde **keine** tokio-Issue, -Discussion oder Doku-Passage
gefunden, die „Lagged als Resync-Trigger" als *empfohlenes* Muster benennt. Es ist gängige
Community-Praxis, aber ohne Primärquelle. Belegbar sind nur die einzelnen Bausteine.
| Ansatz | Belegter Baustein | Pro | Contra |
|---|---|---|---|
| **Kapazität erhöhen** | `channel(n)`-Doku (Power-of-Two-Rundung) | Trivial; verschiebt die Schwelle | Löst nichts, verschiebt nur; Speicher × Kapazität; ein dauerhaft langsamer Client laggt trotzdem |
| **`resubscribe()` + REST-Backfill** | `resubscribe`-Doku (s. o.) + Map-Entscheidung „`?since`-Backfill, die DB *ist* die Queue" | Passt exakt zur bereits gesetzten Zustellsemantik der Map; der Client hat mit `?since` ohnehin den Mechanismus, um die Lücke zu schließen; `Lagged(n)` liefert sogar die Größe der Lücke | Backfill-Last auf der DB genau dann, wenn der Server ohnehin unter Druck steht; braucht einen sauberen Cursor (siehe Council-Risiko „Cursor-Lücke") |
| **`Receiver::len()` als Frühwarnung** | `len`-Doku (s. o.) | Erlaubt Reaktion *vor* dem Datenverlust (z. B. Client drosseln oder trennen) | Zusätzlicher Zustand pro Verbindung; Schwellenwert ist Tuning |
| **Per-Verbindung-Puffer-Task** (broadcast → `mpsc::try_send`) | `mpsc::Sender::try_send`: „differs from `send` by returning immediately if the channel's buffer is full" (https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html) | **Drop-/Trenn-Entscheidung pro Client** statt global; ein langsamer Client trifft nur sich selbst | Ein Channel + Fan-out-Loop pro Verbindung; `T` wird pro Client geklont |
| **Coalescing / latest-only** | `watch` (s. §6.5) | Kein Lag-Fehler möglich | Nur sinnvoll, wenn Zwischenwerte entbehrlich sind |
Referenz-Implementierung im Repo von axum selbst: `examples/chat/src/main.rs` mit
`broadcast::channel(100)`.
### 6.5 Alternative Kanaltypen
| Option | Doku | Semantik | Trade-off |
|---|---|---|---|
| **`tokio::sync::watch`** | https://docs.rs/tokio/latest/tokio/sync/watch/index.html | „a multi-producer, multi-consumer channel that **only retains the last sent value**"; `changed()` ist cancel safe; `borrow_and_update()` „should be preferred […] if the receiver intends to await notifications from `changed` in a loop […] avoids a potential race where a new value is sent between `changed` being ready and the value being read" | **Kein Lag-Fehler möglich**, aber Zwischenwerte gehen verloren ⇒ taugt als reines **Resync-Signal / Versionszähler** (z. B. „Cursor der Conversation X ist jetzt N, hol dir den Rest per REST"), ungeeignet als Event-Stream, in dem jede Nachricht zählt. Passt bemerkenswert gut zur Map-Entscheidung „Dumb Pipe `{conversation, cursor}`" |
| **`tokio::sync::mpsc` + `try_send` pro Verbindung** | https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html | `send`: „Sends a value, waiting until there is capacity."; `try_send`: sofortiges `TrySendError::Full`/`Closed`; `reserve`: „Waits for channel capacity. Once capacity […] is available, it is reserved for the caller." | Backpressure- bzw. Drop-Entscheidung **pro Client**; erlaubt „langsamen Client trennen" statt globalem Lag. Kosten: ein Channel + Fan-out-Loop pro Verbindung |
| **`async_broadcast` 0.7.2** | https://docs.rs/async-broadcast/latest/async_broadcast/ | `broadcast`: „If the channel is full, this method waits until there is space for a message" (echte Backpressure, `async`). `set_overflow(true)`: „broadcasting […] will succeed even if the channel is full. It achieves that by removing the oldest message". `InactiveReceiver` hält den Kanal offen ohne zu konsumieren. Eigene Abgrenzung zu tokio in der Doku: „While this implementation does provide overflow mode, it is the default behavior and not opt-in. There is no equivalent of inactive receivers." | **Backpressure statt Drop** — ein langsamer WS-Client kann den **gesamten Producer** bremsen (Head-of-Line-Blocking über alle Clients). Nur sinnvoll, wenn Verlust teurer ist als Latenz — was der Map-Entscheidung „Best-effort + `?since`-Backfill" widerspricht |
| **`flume`** | https://docs.rs/flume, https://crates.io/crates/flume | „A blazingly fast multi-producer, multi-consumer channel" mit „MPMC support". Crates.io: „in casual maintenance mode … critical security and bug fixes, but heavy feature development has stopped" | ⚠️ **Kein Broadcast** — MPMC heißt hier Work-Queue: jede Nachricht geht an genau **einen** Receiver. Für Fan-out an N WS-Clients ungeeignet, nur als Worker-Queue. *(„genau ein Receiver" konnte in der flume-Doku nicht wörtlich belegt werden.)* |
| **`postage::broadcast`** | https://docs.rs/postage/latest/postage/broadcast/ | „Provides a **lossless**, MPMC channel. All receivers are guaranteed to recieve each message." | Lossless impliziert Backpressure. ⚠️ **Nicht belegbar:** Die Modul-Doku sagt **nicht** explizit, ob der Sender bei langsamem Receiver wartet oder was sonst geschieht. Kleinere Community |
### 6.6 PostgreSQL `LISTEN/NOTIFY` als zweite Fan-out-Implementierung
Die Map sieht `LISTEN/NOTIFY` als zweite Implementierung hinter dem Fan-out-Trait vor. Faktenlage:
`sqlx::postgres::PgListener` (https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgListener.html):
> „A stream of asynchronous notifications from Postgres."
> „This listener will **auto-reconnect**. If the active connection being used ever dies, this
> listener will detect that event, create a new connection, will re-subscribe to all of the
> originally specified channels, and will resume operations as normal."
> `recv()`: „If the connection to PostgreSQL is lost, it is automatically reconnected on the next
> call to `recv()`, and should be entirely transparent (as long as it was just an intermittent
> network failure or long-lived connection reaper)."
> `listen()`: „The channel name is quoted here to ensure case sensitivity."
PostgreSQL `NOTIFY` (https://www.postgresql.org/docs/current/sql-notify.html):
> „In the default configuration it must be shorter than **8000 bytes**."
> „There is a queue that holds notifications that have been sent but not yet processed by all
> listening sessions. **If this queue becomes full, transactions calling `NOTIFY` will fail at
> commit.** The queue is quite large (8GB in a standard installation)."
| | |
|---|---|
| **Pro** | Multi-Instanz-fähiger Fan-out ohne zusätzlichen Broker — hält die Map-Regel „eine Binary + Postgres"; `PgListener` reconnected selbst; `NOTIFY` ist transaktional (feuert erst bei COMMIT), was die Konsistenz mit der DB als Queue erzwingt; der Dumb-Pipe-Payload `{conversation, cursor}` liegt weit unter 8 kB |
| **Contra** | 8000-Byte-Payload-Grenze — nur IDs/Deltas, Volldaten per Query nachladen; die Notify-Queue kann Transaktionen **beim Commit fehlschlagen** lassen, wenn Listener hängen; jeder `PgListener` belegt eine **dedizierte** Postgres-Connection außerhalb des `PgPool` (aktuell `max_connections = 10`) — muss beim Sizing eingeplant werden; `NOTIFY` liefert keine Historie, ein verpasstes Signal ist weg (dieselbe Resync-Frage wie bei `broadcast`, nur eine Ebene tiefer) |
---
## Anhang: Was nicht belegt werden konnte
Diese Punkte sollten im Entscheidungsticket **nicht** als Fakt behandelt werden:
1. **Referrer-Leak und Browser-History für WS-URLs** (§1.2) — RFC 6750 §5.3 spricht von „page
URLs"; für `new WebSocket(url)` kein Beleg gefunden.
2. **APM/Tracing-Leak** — plausibel (OpenTelemetry `url.full`), aber keine Semantic-Convention-Seite
geprüft.
3. **OWASP WebSocket Security Cheat Sheet** trägt kein verifizierbares Datum/keine Version.
4. **SameSite-Wirksamkeit für WS-Handshakes** (§1.4) — aus rfc6265bis §5.6.7.1 abgeleitet, das Wort
„WebSocket" kommt dort nicht vor.
5. **Auth0/Okta** haben keine auffindbare WS-spezifische Doku zum Token-Ablauf.
6. **Header-Größenlimits** von nginx/Caddy für lange JWTs im `Sec-WebSocket-Protocol` (§1.3).
7. **`tauri-plugin-websocket` `headers`-Feldtyp** — nur der Feldname aus der JS-Referenz belegt.
8. **Cloudflare „100 s WS-Idle-Timeout"** — in keiner Cloudflare-Primärquelle; sie dokumentieren
bewusst keinen Zahlenwert.
9. **MDN „Proxy servers and firewalls"** — dieser Abschnitt existiert in der aktuellen MDN-Fassung
nicht mehr.
10. **HAProxy numerische Timeout-Defaults** — upstream nicht dokumentiert; kursierende „50 s"
stammen aus Distro-Beispiel-Configs.
11. **Slack-Reconnect-Sturm mit Datum** — der Envoy-Artikel (15.03.2021) behandelt das Thema nicht;
„Flannel/Governor"-Aussagen sind nur sekundärbelegt.
12. **Discord `heartbeat_interval` = 41250 ms** — nicht dokumentiert; die Doku zeigt `45000` im
Beispiel, der Wert ist server-diktiert.
13. **`tower_http::timeout` bricht geupgradete WS ab** — kein Issue gefunden, das das nachweist.
14. **`SplitStream::next()` Cancel-Safety** — nur indirekt über die pauschale `select!`-Doku-Zeile.
15. **„Lagged als Resync-Trigger"** — kein tokio-Doku- oder Issue-Beleg als empfohlenes Muster.
16. **broadcast-Ring: Sofort-Allokation** — Doku sagt dazu nichts.
17. **flume: „jede Nachricht an genau einen Receiver"** — in der flume-Doku nicht wörtlich belegt.
---
## Quellenverzeichnis
**Spezifikationen**
- 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-util `UpgradeableConnection` — 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