zeroclaw-labs/zeroclaw · error · anyhow::Error
login failed ({status}): {body}
Error message
login failed ({status}): {body} What it means
When `bot_token` is unset, the channel performs the password flow `POST /api/v4/users/login` and expects a session token in the `Token` response header. This error is raised when the login response has a non-2xx status; the status and body are included. 401 means wrong login_id/password; other statuses cover locked accounts, SSO-only servers, or rate limiting.
Source
Thrown at crates/zeroclaw-channels/src/mattermost.rs:318
/// Perform the Mattermost password login flow and return the session
/// token. The session token is returned via the `Token` response header
/// per Mattermost API v4.
async fn login(&self, login_id: &str, password: &str) -> Result<String> {
let resp = self
.http_client()
.post(format!("{}/api/v4/users/login", self.base_url))
.json(&serde_json::json!({
"login_id": login_id,
"password": password,
}))
.send()
.await
.context("login request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("login failed ({status}): {body}");
}
let token = resp
.headers()
.get("Token")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
::zeroclaw_log::record!(
ERROR,
::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
.with_outcome(::zeroclaw_log::EventOutcome::Failure),
"login succeeded but the response had no Token header"
);
anyhow::Error::msg("login succeeded but the response had no Token header")
})?
.to_string();
::zeroclaw_log::record!(
INFO,
::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),View on GitHub (pinned to 88bb9c8533)
Solutions
- Reproduce with curl to see the exact body: `curl -i -X POST <server>/api/v4/users/login -H 'Content-Type: application/json' -d '{"login_id":"...","password":"..."}'`
- Fix the login_id format (email vs username) and password; strip stray whitespace
- Prefer a personal access token (`bot_token`) over password login for bots
- Read the body in the error message — it states why login was refused
Example fix
# before [channels.mattermost.team] base_url = "https://mm.example.com" login_id = "bot@example.com" password = "wrong" # after [channels.mattermost.team] base_url = "https://mm.example.com" bot_token = "<personal access token>"
Defensive patterns
Strategy: try-catch
Validate before calling
let has_token = !bot_token.trim().is_empty();
let can_login = !login_id.trim().is_empty() && !password.trim().is_empty();
if !has_token && !can_login {
return Err(anyhow::anyhow!("mattermost needs bot_token or login_id+password"));
} Try / catch
match mm_channel.listen(tx).await {
Err(e) if e.to_string().starts_with("login failed") => {
// credentials rejected: alert, do not retry-loop with the same password
}
other => other,
} Prevention
- Prefer bot_token (personal access token) over password login for bots
- If password login is required, verify credentials with curl before deploy
- Alert on login failures instead of retrying — repeated failures can trip account lockout
When it happens
Trigger: `bot_token` unset so `token()` falls back to `login()` with the configured login_id and password, and Mattermost rejects the credentials (bad password, unknown login_id, account locked, local logins disabled).
Common situations: Password changed or expired; login_id given as email when the server matches on username (or vice versa); server enforces SSO and refuses local passwords; trailing whitespace/newline in the password from copy-paste.
Related errors
- GET /users/me/channels returned {}
- GET /channels/{id} returned {}: explicit channel_id is not a
- Mattermost WebSocket authentication was rejected
- post failed ({status}): {body}
- Mochat send message failed ({status}): {err}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/e714b9f817c479ef.
Report an issue: GitHub.