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

  1. Match it in callers with errors.Is(err, channels.ErrRateLimit) and back off (honor the platform's Retry-After header when present)
  2. Add client-side rate limiting/throttling before the HTTP call so 429s stop occurring
  3. Spread load across allowed tokens/channels or slow the send loop
  4. 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

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.