{"record":{"id":"0a26f5d53b6441b7","repo":"zeroclaw-labs/zeroclaw","slug":"reply-api-failed-status-err","errorCode":null,"errorMessage":"Reply API failed ({status}): {err}","messagePattern":"Reply API failed \\((.+?)\\): (.+?)","errorType":"http","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/line.rs","lineNumber":1044,"sourceCode":"\n        // LINE Reply API accepts at most 5 messages per call.\n        for batch in messages.chunks(5) {\n            let body = serde_json::json!({\n                \"replyToken\": reply_token,\n                \"messages\": batch,\n            });\n            let resp = self\n                .client\n                .post(&url)\n                .bearer_auth(&self.channel_access_token)\n                .json(&body)\n                .send()\n                .await?;\n\n            if !resp.status().is_success() {\n                let status = resp.status();\n                let err = resp.text().await.unwrap_or_default();\n                anyhow::bail!(\"Reply API failed ({status}): {err}\");\n            }\n        }\n        Ok(())\n    }\n\n    /// Send text via the Push API (requires a paid LINE plan for high volume).\n    async fn send_push(&self, to: &str, text: &str) -> anyhow::Result<()> {\n        let url = format!(\"{}/v2/bot/message/push\", self.api_base_url);\n        let sender_name = (self.sender_name_resolver)()\n            .filter(|s| !s.is_empty())\n            .unwrap_or_else(|| \"AI\".to_string());\n        let sender_icon = self.sender_icon.read().clone();\n        let messages: Vec<serde_json::Value> = Self::split_message(text)\n            .into_iter()\n            .map(|chunk| {\n                let mut msg = serde_json::json!({\"type\": \"text\", \"text\": chunk});\n                if let Some(sender) = Self::build_sender_obj(&sender_name, &sender_icon) {\n                    msg[\"sender\"] = sender;","sourceCodeStart":1026,"sourceCodeEnd":1062,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/line.rs#L1026-L1062","documentation":"LINE channel: send_reply POSTs to /v2/bot/message/reply with the webhook's replyToken and got non-2xx. LINE reply tokens are single-use and short-lived, so the dominant cause is an expired or already-consumed token — typically because the agent's processing (e.g. LLM latency) exceeded the token's validity, or a reply was already sent for that webhook. Status and error body are included.","triggerScenarios":"Reply token expired due to slow processing between webhook receipt and reply; replyToken already used by an earlier reply or a duplicate webhook delivery; 401 with an invalid channel access token; malformed reply payload (400).","commonSituations":"High-latency agent pipelines that hold the reply token during long inference; retry logic re-sending with the same token; fan-out handlers where two paths both reply to one webhook.","solutions":["Cut time-to-first-reply: reply immediately (e.g. a 'thinking...' message) or reduce processing latency, then follow up via push.","Never reuse a replyToken — send exactly one reply per webhook event and drop the token afterwards.","If the token is already expired/used, fall back to the Push API for that recipient.","401 -> fix the channel access token (see error 116)."],"exampleFix":"// before: hold the reply token through slow inference, then reply once\nlet reply = agent.generate(input).await?;\nline.send_reply(&reply_token, &reply).await?;\n\n// after: fail over to push when the reply token is spent/expired\nif let Err(e) = line.send_reply(&reply_token, &reply).await {\n    tracing::warn!(error = %e, \"reply token unusable; falling back to push\");\n    line.send_push(&user_id, &reply).await?;\n}","handlingStrategy":"fallback","validationCode":"// Track reply token freshness: drop tokens older than a few seconds and reply fast\nstruct ReplyToken { token: String, issued_at: std::time::Instant }\nimpl ReplyToken {\n    fn usable(&self) -> bool { self.issued_at.elapsed() < Duration::from_secs(50) && !self.consumed }\n}","typeGuard":"fn is_reply_token_spent(err: &anyhow::Error) -> bool {\n    let s = err.to_string();\n    s.contains(\"Reply API failed\") && (s.contains(\"400\") || s.contains(\"Invalid reply token\"))\n}","tryCatchPattern":"if let Err(e) = line.send_reply(&reply_token, &msg).await {\n    if is_reply_token_spent(&e) {\n        tracing::warn!(\"reply token expired/used; falling back to push\");\n        return line.send_push(&user_id, &msg).await;\n    }\n    return Err(e);\n}","preventionTips":["Reply within seconds of the webhook; move slow work off the reply path.","Use each replyToken exactly once; keep single-use discipline in retries.","Budget push quota as the fallback for expired reply tokens."],"tags":["line","reply","reply-token","expired-token","messaging-api"],"backgroundTag":"expired-reply-token","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}