{"record":{"id":"44b263175ad98f94","repo":"zeroclaw-labs/zeroclaw","slug":"wecom-ws-command-ack-timeout-after-s-req-id","errorCode":null,"errorMessage":"WeCom WS {command} ack timeout after {}s (req_id={req_id})","messagePattern":"WeCom WS (.+?) ack timeout after (.+?)s \\(req_id=(.+?)\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/wecom_ws.rs","lineNumber":415,"sourceCode":"        let (tx, rx) = tokio::sync::oneshot::channel();\n        self.pending_responses\n            .lock()\n            .await\n            .insert(req_id.to_string(), tx);\n\n        if let Err(err) = self.ws_send_frame(frame).await {\n            self.pending_responses.lock().await.remove(req_id);\n            return Err(err);\n        }\n\n        match tokio::time::timeout(Duration::from_secs(WECOM_COMMAND_TIMEOUT_SECS), rx).await {\n            Ok(Ok(result)) => result,\n            Ok(Err(_)) => anyhow::bail!(\n                \"WeCom WS {command} response channel closed before ack (req_id={req_id})\"\n            ),\n            Err(_) => {\n                self.pending_responses.lock().await.remove(req_id);\n                anyhow::bail!(\n                    \"WeCom WS {command} ack timeout after {}s (req_id={req_id})\",\n                    WECOM_COMMAND_TIMEOUT_SECS\n                );\n            }\n        }\n    }\n\n    async fn maybe_handle_command_response(&self, frame: &Value) -> bool {\n        let Some(req_id) = frame\n            .get(\"headers\")\n            .and_then(|headers| headers.get(\"req_id\"))\n            .and_then(Value::as_str)\n        else {\n            return false;\n        };\n\n        let Some(errcode) = frame.get(\"errcode\").and_then(Value::as_i64) else {\n            return false;","sourceCodeStart":397,"sourceCodeEnd":433,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/wecom_ws.rs#L397-L433","documentation":"Every WeCom WS command waits at most WECOM_COMMAND_TIMEOUT_SECS = 10 seconds for its ack, matched by req_id; on timeout the pending entry is removed and this error bails with the command name and req_id. The frame was sent but WeCom never answered in time.","triggerScenarios":"ws_send_respond_msg or send_markdown_chunks_to_scope when the WeCom server does not ack within 10s: upstream latency spikes, a half-dead socket that delivers frames but stalls responses, or slow processing of large chunked markdown payloads.","commonSituations":"Network degradation where the socket stays open but stalls; oversized markdown chunk sequences; WeCom service-side latency events.","solutions":["Retry the command once — transient upstream latency is the usual cause","Reduce markdown chunk size/count so WeCom acks faster","If timeouts are constant, force a WS reconnect — the socket is likely half-dead — and verify latency to the WeCom endpoint","Correlate the req_id in logs to confirm whether the ack arrived late vs never"],"exampleFix":"// before\nlet r = channel.send_markdown_chunks(scope, chunks).await?;\n// after\nlet r = retry_on(\"ack timeout\", 2, ||\n    channel.send_markdown_chunks(scope.clone(), chunks.clone())).await?;","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"Catch the ack-timeout bail, retry the command once after a short backoff; if the second attempt also times out, force a WS reconnect and try once more before dead-lettering the message.","preventionTips":["Keep individual WS commands small so acks come back fast","Force a reconnect after repeated ack timeouts rather than hammering a half-dead socket","Track command ack latency to catch degradation before timeouts appear"],"tags":["wecom","websocket","timeout","ack","send"],"backgroundTag":"websocket-ack-timeout","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}