{"record":{"id":"1c71f7475c67f560","repo":"sipeed/picoclaw","slug":"read-media-w","errorCode":null,"errorMessage":"read media: %w","messagePattern":"read media: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pkg/channels/wecom/media.go","lineNumber":299,"sourceCode":"\t\treturn \"\", fmt.Errorf(\"no media store available\")\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"create request: %w\", err)\n\t}\n\tresp, err := c.mediaClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"download media: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"download media returned HTTP %d\", resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"read media: %w\", err)\n\t}\n\tif len(data) > wecomOutboundMediaMaxBytes {\n\t\treturn \"\", fmt.Errorf(\"media too large\")\n\t}\n\n\tif aesKey != \"\" {\n\t\tkey, keyErr := decodeMediaAESKey(aesKey)\n\t\tif keyErr != nil {\n\t\t\treturn \"\", keyErr\n\t\t}\n\t\tdata, err = decryptAESCBC(key, data)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"decrypt media: %w\", err)\n\t\t}\n\t}\n\n\tfilename, contentType := detectWeComMediaMetadata(\n\t\tdata,","sourceCodeStart":281,"sourceCodeEnd":317,"githubUrl":"https://github.com/sipeed/picoclaw/blob/49183d7e8daed0dba89ddbb6fcb60089401d9680/pkg/channels/wecom/media.go#L281-L317","documentation":"storeRemoteMedia failed while streaming the body of an already-accepted HTTP 200 response from the WeCom CDN (media.go:297-299). The download runs on c.mediaClient, whose Timeout is wecomMediaTimeout = 30s (wecom.go:31,134) and covers the entire body read, so slow transfers of multi-MiB media surface here as deadline errors. The %w preserves the underlying *url.Error/*net.OpError for inspection.","triggerScenarios":"io.ReadAll(io.LimitReader(resp.Body, 20 MiB+1)) aborts mid-body: connection reset by CDN/proxy, TLS failure mid-stream, or the shared http.Client 30s Timeout expiring before up to 20 MiB finishes downloading after headers were already received.","commonSituations":"Mobile/weak links downloading multi-MiB voice or video messages; corporate proxies or firewalls RST-ing long-lived downloads; container networks with tight bandwidth limits; CDN node hiccups during large file transfer.","solutions":["Unwrap the error: errors.As to *net.Error / errors.Is(err, context.DeadlineExceeded) to distinguish timeout from reset","If timeout: raise wecomMediaTimeout (wecom.go:31) or reject oversized media earlier - 20 MiB in 30s needs ~0.7 MB/s sustained","If reset: curl the exact CDN url from the same host to reproduce, then fix egress/proxy rules","On retry, re-extract a fresh url from the inbound WeCom payload - CDN urls are signed and short-lived, replaying an expired one gives HTTP errors instead"],"exampleFix":"// before: fixed 30s shared client covers whole body\nmediaClient: &http.Client{Timeout: wecomMediaTimeout}\n\n// after: separate, larger budget for the media body read\nmediaClient: &http.Client{\n    Timeout:   wecomMediaTimeout,          // dial + headers\n    Transport: &http.Transport{ResponseHeaderTimeout: 10 * time.Second},\n}\n// and read the body under an explicit per-file deadline:\nctx, cancel := context.WithTimeout(ctx, 2*time.Minute)\ndefer cancel()\nreq = req.WithContext(ctx)","handlingStrategy":"retry","validationCode":null,"typeGuard":"func isNetTimeout(err error) bool {\n\tvar ne net.Error\n\tif errors.As(err, &ne) && ne.Timeout() {\n\t\treturn true\n\t}\n\treturn errors.Is(err, context.DeadlineExceeded)\n}","tryCatchPattern":"var retryErrs = []error{context.DeadlineExceeded, io.ErrUnexpectedEOF, syscall.ECONNRESET}\n\nfunc withRetry(ctx context.Context, n int, f func() error) error {\n\tvar err error\n\tfor i := 0; i < n; i++ {\n\t\tif err = f(); err == nil || !isRetryable(err) {\n\t\t\treturn err\n\t\t}\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tcase <-time.After(time.Duration(i+1) * time.Second):\n\t\t}\n\t}\n\treturn err\n}\n\nfunc isRetryable(err error) bool {\n\tfor _, target := range retryErrs {\n\t\tif errors.Is(err, target) {\n\t\t\treturn true\n\t\t}\n\t}\n\tvar ne net.Error\n\treturn errors.As(err, &ne) && ne.Timeout()\n}","preventionTips":["classify the wrapped error (net.Error.Timeout, ECONNRESET) before deciding retry vs abort","size media for the pipe: 20 MiB inside a 30s client timeout needs ~0.7 MB/s sustained","for WeCom CDN urls, retry only with a freshly extracted url from the inbound payload - signed urls expire"],"tags":["network","io","timeout","wecom","media"],"backgroundTag":null,"analyzedSha":"49183d7e8daed0dba89ddbb6fcb60089401d9680","analyzedAt":"2026-08-15T21:55:41.315Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}