sipeed/picoclaw · warning · ErrRateLimit
%w: %w
Error message
%w: %w
What it means
channels.ClassifySendError maps HTTP 429 to the ErrRateLimit sentinel, double-wrapping it with the raw platform error (message renders as 'rate limited: <raw>'). Channels doing HTTP API sends call this from their Send path; the manager then waits a fixed delay and retries, unlike ErrTemporary's exponential backoff.
Source
Thrown at pkg/channels/errutil.go:14
package channels
import (
"fmt"
"net/http"
)
// ClassifySendError wraps a raw error with the appropriate sentinel based on
// an HTTP status code. Channels that perform HTTP API calls should use this
// in their Send path.
func ClassifySendError(statusCode int, rawErr error) error {
switch {
case statusCode == http.StatusTooManyRequests:
return fmt.Errorf("%w: %w", ErrRateLimit, rawErr)
case statusCode >= 500:
return fmt.Errorf("%w: %w", ErrTemporary, rawErr)
case statusCode >= 400:
return fmt.Errorf("%w: %w", ErrSendFailed, rawErr)
default:
return rawErr
}
}
// ClassifyNetError wraps a network/timeout error as ErrTemporary.
func ClassifyNetError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%w: %w", ErrTemporary, err)
}
View on GitHub (pinned to 49183d7e8d)
Solutions
- Match it in callers with errors.Is(err, channels.ErrRateLimit) and back off (honor the platform's Retry-After header when present)
- Add client-side rate limiting/throttling before the HTTP call so 429s stop occurring
- Spread load across allowed tokens/channels or slow the send loop
- Keep the wrap as-is so errors.Is keeps matching the sentinel
Example fix
// before
resp, err := doSend(req)
return err // callers cannot distinguish 429 from other failures
// after
resp, err := doSend(req)
if err == nil && resp.StatusCode == http.StatusTooManyRequests {
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("body: %s", body))
} Defensive patterns
Strategy: retry
Validate before calling
// Client-side throttle before the HTTP call to avoid 429s
limiter := rate.NewLimiter(rate.Every(time.Second/5), 5) // example: 5 msg/s
if err := limiter.Wait(ctx); err != nil {
return err
} Type guard
func isRateLimited(err error) bool { return errors.Is(err, channels.ErrRateLimit) } Try / catch
if err := ch.Send(ctx, msg); err != nil {
if errors.Is(err, channels.ErrRateLimit) {
time.Sleep(retryAfterFromBody(err)) // fixed delay; honor Retry-After when available
return ch.Send(ctx, msg)
}
return err
} Prevention
- Throttle outbound sends client-side before hitting the platform's 429
- Honor Retry-After headers when the platform provides them
- Always match with errors.Is(err, channels.ErrRateLimit) — the sentinel survives the double-%w wrap
- Distribute bursts across time; avoid fan-out replies to many chats in one tick
When it happens
Trigger: Any channel Send path calling ClassifySendError(statusCode, rawErr) where the platform returned 429 — bursting messages, group chats with heavy traffic, several channels sharing one token, per-route method limits (e.g. Slack/Telegram).
Common situations: Bulk notifications, agent storms replying to many chats at once, multiple bot instances on one credential, no client-side throttling.
Related errors
AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15).
Data as JSON: /api/errors/2b93322bccdf43fa.
Report an issue: GitHub.