sipeed/picoclaw · error · ErrTemporary

feishu send media: %w

Error message

feishu send media: %w

What it means

A media part failed to upload/send (image or file) and the raw cause is REPLACED by the ErrTemporary sentinel: the underlying error is only logged (with part type), not wrapped into the returned error. Consequence: callers correctly see a retryable class, but must check logs for the real cause - and permanent 4xx causes are mislabeled as temporary.

Source

Thrown at pkg/channels/feishu/feishu_64.go:567

	defer file.Close()

	switch part.Type {
	case "image":
		err = c.sendImage(ctx, chatID, file)
	default:
		filename := part.Filename
		if filename == "" {
			filename = "file"
		}
		err = c.sendFile(ctx, chatID, file, filename, part.Type)
	}

	if err != nil {
		logger.ErrorCF("feishu", "Failed to send media", map[string]any{
			"type":  part.Type,
			"error": err.Error(),
		})
		return fmt.Errorf("feishu send media: %w", channels.ErrTemporary)
	}
	return nil
}

func firstMediaCaption(parts []bus.MediaPart) string {
	for _, part := range parts {
		if caption := strings.TrimSpace(part.Caption); caption != "" {
			return caption
		}
	}
	return ""
}

// --- Inbound message handling ---

func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
	if event == nil || event.Event == nil || event.Event.Message == nil {
		return nil

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Check the channel logs first: 'Failed to send media' carries the true underlying error and part type
  2. Verify the media part is within Feishu limits and the media store is reachable
  3. Retry only when the logged cause is transient; a 4xx in logs will fail forever despite the temporary label
  4. Fix the wrapper to wrap rawErr (fmt.Errorf("feishu send media: %w: %w", channels.ErrTemporary, err)) so callers can classify

Example fix

// before (pkg/channels/feishu/feishu_64.go)
return fmt.Errorf("feishu send media: %w", channels.ErrTemporary) // cause lost

// after
return fmt.Errorf("feishu send media: %w: %w", channels.ErrTemporary, err) // class + cause
Defensive patterns

Strategy: retry

Validate before calling

// cheap pre-checks before SendMedia
func mediaSendPreconditions(store MediaStore, msg bus.OutboundMediaMessage) error {
	if store == nil { return errors.New("no media store") }
	for _, p := range msg.Parts {
		if p.Size > maxFeishuUploadBytes { return fmt.Errorf("part %q exceeds size limit", p.Filename) }
	}
	return nil
}

Type guard

func isTemporarySendMedia(err error) bool {
	return err != nil && errors.Is(err, channels.ErrTemporary)
}

Try / catch

err := ch.SendMedia(ctx, msg)
for attempt := 0; isTemporarySendMedia(err) && attempt < 4; attempt++ {
	time.Sleep(min(500*time.Millisecond<<attempt, 8*time.Second))
	err = ch.SendMedia(ctx, msg)
}
// CAUTION: the sentinel hides the cause - if it keeps failing, read the channel log
// ('Failed to send media') and stop retrying if the cause is a 4xx/size error

Prevention

When it happens

Trigger: sendImage/sendFile (via sendMediaPartFn) returns any error - oversized file rejected, media store fetch failure, upload timeout, unsupported part type - and the wrapper discards it in favor of ErrTemporary.

Common situations: Files over Feishu size limits; media store (S3/local) unreachable so the bytes never load; transient upload timeouts; a rare part type the switch does not handle falling into the error path.

Related errors


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