sipeed/picoclaw · error
download media returned HTTP %d: %s
Error message
download media returned HTTP %d: %s
What it means
The remote server answered outbound media fetch with a non-200 status (media.go:432-434); the error embeds the status code and the first 1 KiB of the body for diagnosis. 403 typically means an expired signed URL or hotlink protection, 404 a deleted object, 401 required auth, 5xx an upstream failure. Unlike the inbound twin at media.go:294, this variant includes the body snippet.
Source
Thrown at pkg/channels/wecom/media.go:434
func (c *WeComChannel) downloadRemoteMediaToTemp(
ctx context.Context,
resourceURL, fallbackName string,
) (string, string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("create request: %w", err)
}
resp, err := c.mediaClient.Do(req)
if err != nil {
return "", "", "", fmt.Errorf("download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", "", "", fmt.Errorf("download media returned HTTP %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1))
if err != nil {
return "", "", "", fmt.Errorf("read media: %w", err)
}
if len(data) > wecomOutboundMediaMaxBytes {
return "", "", "", fmt.Errorf("media too large")
}
filename, contentType := detectWeComMediaMetadata(
data,
fallbackName,
resp.Header.Get("Content-Type"),
resourceURL,
resp.Header.Get("Content-Disposition"),
)
tmpPath, err := writeWeComTempFile("wecom-outbound", filename, data)View on GitHub (pinned to 49183d7e8d)
Solutions
- Read the embedded status: 403 -> the signed URL expired; re-obtain a fresh url/media_id from the source and fetch promptly
- 404/410 -> the object is gone; drop the part and notify the sender, do not retry
- 401/403 on your own buckets -> fetch via signed URLs generated at send time
- 429/5xx -> retry with backoff; the message includes the body to confirm the origin's error text
- For hotlink-protected hosts, mirror the file to storage you control and send that ref
Defensive patterns
Strategy: try-catch
Try / catch
var httpStatusRe = regexp.MustCompile(`download media returned HTTP (\d+)`)
func classifyDownloadStatus(err error) (int, bool) {
m := httpStatusRe.FindStringSubmatch(err.Error())
if m == nil {
return 0, false
}
n, _ := strconv.Atoi(m[1])
return n, true
}
// usage
if code, ok := classifyDownloadStatus(err); ok {
switch {
case code == 403 || code == 401:
// refresh the signed URL / credentials, then retry once
case code == 404 || code == 410:
// permanent: drop the media part, keep sending the caption
case code == 429 || code >= 500:
// backoff and retry
}
} Prevention
- fetch WeCom-hosted media immediately after receipt - signed urls are short-lived
- prefer refs to storage you control for outbound media
- log the embedded body snippet; it usually names the real reason
When it happens
Trigger: GET to part.Ref returns non-200: WeCom media_url used after expiry (signed, short-lived), object deleted before fetch, S3/OSS bucket requiring signed access, rate-limit 429 from the file host, or a 500 from a flaky origin.
Common situations: Forwarding WeCom-hosted media too long after receipt; passing ephemeral pre-signed S3 links through the bot; origin enforcing Referer/UA checks that reject the bot's fetch.
Related errors
- unexpected status %s: %s
- device code request failed: %s
- %s failed: %d %s
- Failed to load config
- Request failed with status ${res.status}
AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15).
Data as JSON: /api/errors/8a0bfddd6b30ff39.
Report an issue: GitHub.