zeroclaw-labs/zeroclaw · error · anyhow::Error
Reply API failed ({status}): {err}
Error message
Reply API failed ({status}): {err} What it means
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.
Source
Thrown at crates/zeroclaw-channels/src/line.rs:1044
// LINE Reply API accepts at most 5 messages per call.
for batch in messages.chunks(5) {
let body = serde_json::json!({
"replyToken": reply_token,
"messages": batch,
});
let resp = self
.client
.post(&url)
.bearer_auth(&self.channel_access_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Reply API failed ({status}): {err}");
}
}
Ok(())
}
/// Send text via the Push API (requires a paid LINE plan for high volume).
async fn send_push(&self, to: &str, text: &str) -> anyhow::Result<()> {
let url = format!("{}/v2/bot/message/push", self.api_base_url);
let sender_name = (self.sender_name_resolver)()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "AI".to_string());
let sender_icon = self.sender_icon.read().clone();
let messages: Vec<serde_json::Value> = Self::split_message(text)
.into_iter()
.map(|chunk| {
let mut msg = serde_json::json!({"type": "text", "text": chunk});
if let Some(sender) = Self::build_sender_obj(&sender_name, &sender_icon) {
msg["sender"] = sender;View on GitHub (pinned to 88bb9c8533)
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).
Example fix
// before: hold the reply token through slow inference, then reply once
let reply = agent.generate(input).await?;
line.send_reply(&reply_token, &reply).await?;
// after: fail over to push when the reply token is spent/expired
if let Err(e) = line.send_reply(&reply_token, &reply).await {
tracing::warn!(error = %e, "reply token unusable; falling back to push");
line.send_push(&user_id, &reply).await?;
} Defensive patterns
Strategy: fallback
Validate before calling
// Track reply token freshness: drop tokens older than a few seconds and reply fast
struct ReplyToken { token: String, issued_at: std::time::Instant }
impl ReplyToken {
fn usable(&self) -> bool { self.issued_at.elapsed() < Duration::from_secs(50) && !self.consumed }
} Type guard
fn is_reply_token_spent(err: &anyhow::Error) -> bool {
let s = err.to_string();
s.contains("Reply API failed") && (s.contains("400") || s.contains("Invalid reply token"))
} Try / catch
if let Err(e) = line.send_reply(&reply_token, &msg).await {
if is_reply_token_spent(&e) {
tracing::warn!("reply token expired/used; falling back to push");
return line.send_push(&user_id, &msg).await;
}
return Err(e);
} Prevention
- 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.
When it happens
Trigger: 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).
Common situations: 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.
Related errors
- Push API failed ({status}): {err}
- audio download failed ({status}) for message {message_id}
- audio exceeds {} byte limit for message {message_id}
- Cannot persist empty LINE userId
- Missing [channels.line.{}] section
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/0a26f5d53b6441b7.
Report an issue: GitHub.