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

  1. Read the embedded status: 403 -> the signed URL expired; re-obtain a fresh url/media_id from the source and fetch promptly
  2. 404/410 -> the object is gone; drop the part and notify the sender, do not retry
  3. 401/403 on your own buckets -> fetch via signed URLs generated at send time
  4. 429/5xx -> retry with backoff; the message includes the body to confirm the origin's error text
  5. 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

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


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/8a0bfddd6b30ff39. Report an issue: GitHub.