Refuse websocket upgrades with HTTP statuses, not close codes

A close() before accept() carries the HTTP status of the refused
upgrade, not a WebSocket close code: RSGI (granian) uses the value
verbatim as the response status, so 4401 panicked on the invalid status
(InvalidStatusCode) and — granian being built with panic = "abort" —
aborted the worker and took the whole app down. ASGI servers answer 403
regardless, and browsers report a refused handshake as an abnormal close
(1006), so the 4401/4403/4404 codes never reached clients anyway.

Reject 401/404/403 to mirror GET /api/games/{id}, and classify a failed
first connection in the game page through the REST API so players still
see "session expired" / "game no longer available" instead of a retry
loop. Add a regression test asserting every pre-accept rejection uses a
status in the HTTP range.
This commit is contained in:
2026-09-29 22:31:41 +08:00
parent 21911caf9e
commit 2910fc4600
7 changed files with 162 additions and 35 deletions
+26 -9
View File
@@ -87,16 +87,33 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string())
}
#[allow(dead_code)]
pub async fn game_state(game_id: &str) -> Result<GameView, String> {
let resp = Request::get(&format!("/api/games/{game_id}"))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
/// Why a game connection failed before the first snapshot arrived.
///
/// A refused WebSocket handshake is invisible to the browser: the close
/// event reports an abnormal closure (1006) with no HTTP status, so the
/// game page asks the REST API to tell a deliberate rejection from a
/// transient network problem.
pub enum GameEntry {
/// Not logged in (`401` from `GET /api/games/{id}`).
Unauthenticated,
/// Unknown game (`404`) or the user is not seated (`403`).
Unavailable,
/// The API could not be asked (or answered unexpectedly): treat the
/// connection loss as transient and retry.
Transient,
}
/// Classify a failed game connection via `GET /api/games/{game_id}`,
/// mirroring the statuses used to refuse the websocket upgrade.
pub async fn probe_game(game_id: &str) -> GameEntry {
match Request::get(&format!("/api/games/{game_id}")).send().await {
Ok(resp) => match resp.status() {
401 => GameEntry::Unauthenticated,
403 | 404 => GameEntry::Unavailable,
_ => GameEntry::Transient,
},
Err(_) => GameEntry::Transient,
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
+30 -13
View File
@@ -4,7 +4,9 @@ use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::card::{card_back, card_img};
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
@@ -135,23 +137,38 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let on_close = {
let id = id.clone();
let alive = alive.clone();
move |code: Option<u16>| {
move |_code: Option<u16>| {
if !alive.get() {
return;
}
ctx.closed.set(true);
match code {
Some(4401) => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
Some(4403) | Some(4404) => {
ctx.fatal.set(true);
ctx.error
.set(Some("This game is no longer available.".to_string()));
}
_ => schedule_retry(id.clone(), ctx, alive.clone()),
if ctx.game.get_clone().is_none() && ctx.attempts.get() == 0 {
// The connection ended before any snapshot arrived. A
// refused handshake looks exactly like a network drop to
// the browser (close code 1006, no HTTP status), so ask
// the API whether the server deliberately rejected us —
// in which case retrying is pointless — or the loss was
// transient. Only on the first failure: later attempts
// are governed by the retry backoff.
let id = id.clone();
let alive = alive.clone();
spawn_local(async move {
match api::probe_game(&id).await {
api::GameEntry::Unauthenticated => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
api::GameEntry::Unavailable => {
ctx.fatal.set(true);
ctx.error
.set(Some("This game is no longer available.".to_string()));
}
api::GameEntry::Transient => schedule_retry(id, ctx, alive),
}
});
} else {
schedule_retry(id.clone(), ctx, alive.clone());
}
}
};
+5 -2
View File
@@ -55,8 +55,11 @@ impl GameSocket {
/// could not be created.
///
/// `on_close` fires exactly once when the connection ends; it receives the
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
/// server close code when one was sent (e.g. 1000 for a normal closure) or
/// `None` for an abnormal loss. A *refused* handshake is not distinguishable
/// here: browsers report it as an abnormal closure (1006) and never expose
/// the HTTP status the server answered with, so callers must classify the
/// failure out of band.
pub fn connect(
game_id: &str,
on_message: impl Fn(ServerMessage) + 'static,