Root cause found — CONFIRMED by direct local reproduction, three
variants: src/ws_client.py::listen()'s connect + initial-handshake
phase (async with websockets.connect(...) as ws: and the immediately
following ws.recv() for Pusher's connection_established frame) had
no exception handling at all — the one connection phase in this
module that v0.5/v0.7/v0.9 never hardened, even though every other I/O
site (send, recv-loop, subscribe) already has a bounded, graceful-failure
contract. Reproduced with a local websockets.serve() mock Pusher server
standing in for the real one:
- Peer accepts the connection then closes without sending anything →
unhandled
websockets.ConnectionClosedOK.
- Peer accepts the connection but never sends the first frame → unhandled
TimeoutError (bounded by PUSHER_CONNECT_TIMEOUT_S, but not caught).
- Endpoint unreachable/refused (DNS failure, network blip, Pusher-side
outage) → unhandled
OSError.
All three crash the entire Actor run with an uncaught exception
instead of the graceful outcome the code clearly intends:
stays False, main.py's existing REQ-13 check fires, and the
run exits 1 with "Could not establish a live chat subscription" — a clear,
billed-but-honest failure message instead of a raw traceback. Since
Actor.charge("actor-start", ...) fires before any of this runs, every
one of these was already a customer billed for nothing before the crash.
Honest scope — what this does and does not explain: an uncaught
exception inside async with Actor: produces a fast FAILED exit, not
the TIMED-OUT (platform force-kill after exceeding the run's wall-clock
budget) that dominates this Actor's current 30-day stats (21 of 22
failures). This fix is CONFIRMED to close a real, previously-uncaught
crash class with three concretely reproduced trigger conditions, and
HYPOTHESIS (not proven) that it explains any share of the TIMED-OUT
majority specifically — plausible if the Apify SDK's own exception-path
shutdown inside async with Actor: doesn't itself return quickly, but
that mechanism was not directly observed this session. websockets'
connect-side WS traffic also carries no browser-like fingerprint (default
User-Agent: Python/websockets), unlike every other network surface in
this Actor (api_client.py rotates curl-cffi browser impersonation) and
the fleet's anti-blocking stack — a plausible, not confirmed, reason a
datacenter-IP handshake to Pusher gets rejected/dropped more often than
a browser's would.
What was tried and did not explain the TIMED-OUT majority: re-audited
every wall-clock cap in main.py/ws_client.py
(RESOLVE_PHASE_MAX_S, RECV_CHUNK_S, SEND_TIMEOUT_S,
PUSHER_CONNECT_TIMEOUT_S, DURATION_MAX_S,
DEADLINE_SAFETY_MARGIN_S) and the 0.10 Actor.configuration.timeout_at
plumbing directly against the installed apify==3.4.0 SDK source
(_configuration.py) — the env-var aliases (ACTOR_TIMEOUT_AT/
APIFY_TIMEOUT_AT) are real and resolve correctly when set, so the 0.10
fix is not a silent no-op. No local reproduction attempt (see below)
triggered an actual local hang past its expected bound.
What was tried and could not be reproduced locally: live Kick.com
traffic (real network calls against kick.com/Pusher, not mocked) —
xqc, kaicenat, adinross, ninja, trainwreckstv, nadia,
stableronaldo, buddha, n3on, iceposeidon, fousey, agent00,
duke_dennis, sketch, mikelong, elonmusk, amouranth,
asmongold, ibai, loltyler1, plus 404s (some-definitely-fake-slug,
sneakobrown, plaqueboymax, yeezuz) to exercise REQ-1 slug-miss
handling. None were live at request time (2026-08-17 ~11:00 UTC), so the
"huge chat volume" and "non-English chat" scenarios in this session's
brief could not be driven with real traffic; Kick's public API surface
offers no reliable "currently live, high-viewer-count channel" listing
endpoint to target one deterministically. A real apify run against
tests/fixtures/input.qa.json (xqc, offline) confirms the happy/quiet
path is unaffected by this fix: resolved <1s, connected, subscribed, ran
the full 30s window, exited 0 with the expected message.
Fix: src/ws_client.py — new CONNECTION_ERRORS tuple
(OSError, TimeoutError, websockets.WebSocketException) wraps the
entire async with websockets.connect(...) block in listen(); the
initial-handshake logic is extracted to _establish() so listen()
stays under the 40-line function ceiling. Any connection-establishment
failure is logged and swallowed exactly like a same-effect "never
connected" outcome, leaving stats.connected=False for the caller's
existing fail-loud gate.
New regression tests (tests/test_ws_client.py, 3 new, all verified to
fail against the pre-fix source with the exact three exception types
above, and pass after): test_listen_survives_peer_closing_before_any_frame,
test_listen_survives_peer_never_sending_first_frame,
test_listen_survives_connection_refused — each spins up a real local
websockets.serve() mock Pusher server (not a fake object) so the
regression exercises the actual websockets connect/handshake code path,
not just a mocked substitute.
Local gates: ruff check src/ tests/ clean. pyright (via uvx, not
added as a project dependency) — 1 pre-existing error only
(tests/test_models.py:63, the documented Pydantic-Field-default false
positive since 2026-07-10). pytest tests/: 57/58 green (54 pre-existing
- 3 new); the 1 failure (
test_pay_per_event_declared) is the same
pre-existing, unrelated $0.05 fleet-pricing-standard gap flagged in
every prior session on this Actor — not fixed here (repricing has its own
14-day-notice constraint).
Twitch sibling check (actors/twitch-vod-chat-archive, not modified):
does not share this flaw. It has no WebSocket/live-connection code at
all — it archives already-recorded VOD chat via paginated Twitch GQL HTTP
POSTs (src/client.py::gql_post), and that retry loop already wraps
every network call in try/except (RequestsError, OSError) with
backoff, returning (None, reason) instead of raising. The two Actors
share a name pattern ("chat archive") but not an architecture — Kick
has no chat-history API, so this Actor is necessarily live-only over a
persistent WebSocket; Twitch's VOD replay is a fundamentally different,
already-hardened, one-shot-HTTP-per-page design. No action needed on
Twitch from this session's finding.
Budget note: no cloud run was used this session (mid-session
instruction withdrew the previously-allowed single small cloud run,
account at $3.79/$5.00). This fix is local-reproduction-verified only;
it has not been cloud-QA'd. The specific thing a cloud run would need to
prove that local reproduction cannot: whether the TIMED-OUT majority
itself clears once this build is live, since local runs never experience
an actual Apify platform force-kill.