{"record":{"id":"52eea1aa8dad3c56","repo":"sipeed/picoclaw","slug":"status-d-s","errorCode":null,"errorMessage":"status %d: %s","messagePattern":"status (.+?): (.+?)","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pkg/channels/slack_webhook/slack_webhook.go","lineNumber":163,"sourceCode":"\t\treturn nil, fmt.Errorf(\"slack_webhook: network error: %w\", channels.ErrTemporary)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode >= 400 {\n\t\trespBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))\n\t\trespText := strings.TrimSpace(string(respBody))\n\t\tif respText == \"\" {\n\t\t\trespText = http.StatusText(resp.StatusCode)\n\t\t\tif respText == \"\" {\n\t\t\t\trespText = \"unknown error\"\n\t\t\t}\n\t\t}\n\t\tlogger.ErrorCF(\"slack_webhook\", \"Slack API error\", map[string]any{\n\t\t\t\"target\":   targetName,\n\t\t\t\"status\":   resp.StatusCode,\n\t\t\t\"response\": respText,\n\t\t})\n\t\tsendErr := fmt.Errorf(\"status %d: %s\", resp.StatusCode, respText)\n\t\treturn nil, fmt.Errorf(\"slack_webhook: %w\", channels.ClassifySendError(resp.StatusCode, sendErr))\n\t}\n\n\tlogger.DebugCF(\"slack_webhook\", \"Message sent successfully\", map[string]any{\n\t\t\"target\": targetName,\n\t})\n\n\treturn nil, nil\n}\n\nfunc (c *SlackWebhookChannel) buildPayload(msg bus.OutboundMessage, target config.SlackWebhookTarget) map[string]any {\n\tpayload := make(map[string]any)\n\n\tif target.Username != \"\" {\n\t\tpayload[\"username\"] = target.Username\n\t}\n\tif target.IconEmoji != \"\" {\n\t\tpayload[\"icon_emoji\"] = target.IconEmoji","sourceCodeStart":145,"sourceCodeEnd":181,"githubUrl":"https://github.com/sipeed/picoclaw/blob/49183d7e8daed0dba89ddbb6fcb60089401d9680/pkg/channels/slack_webhook/slack_webhook.go#L145-L181","documentation":"Returned when Slack responds with HTTP >= 400 to the webhook POST. The body (capped at 512 bytes) or the status text is embedded as \"status %d: %s\", then passed through channels.ClassifySendError (pkg/channels/errutil.go:11): 429 wraps ErrRateLimit, 5xx wraps ErrTemporary, other 4xx wrap ErrSendFailed. So this one message covers three different retry policies, selected via errors.Is on the returned error.","triggerScenarios":"400 = malformed payload or too-large message (channel max length is 40000); 403 = webhook revoked; 404 = webhook deleted or channel removed; 410 = webhook gone; 429 = rate-limited (Slack incoming webhooks are limited to 1/sec); 5xx = Slack incident. The final error is prefixed \"slack_webhook: \" and wraps the classified sentinel.","commonSituations":"Deleting/recreating the Slack app invalidates old webhook URLs (404/410); bursts of notifications tripping the 1-per-second webhook limit (429); oversized messages with markdown/attachments exceeding Slack limits (400); Slack-side incidents producing 5xx.","solutions":["Read the numeric status in the message: 404/410/403 mean the webhook is dead — recreate the incoming webhook in the Slack app and update config.","For 429, slow the send rate (the manager already backs off ErrRateLimit) and coalesce rapid notifications into fewer posts.","For 400, reduce the message (the channel caps at 40000 chars — check attachments/blocks size) and re-send.","For 5xx, retry via the ErrTemporary path with exponential backoff; check status.slack.com."],"exampleFix":"// before: one-size-fits-all handling\nif err := ch.Send(ctx, msg); err != nil { log.Fatal(err) }\n\n// after: branch on the classified sentinel\nif err := ch.Send(ctx, msg); err != nil {\n    switch {\n    case errors.Is(err, channels.ErrRateLimit):\n        time.Sleep(2 * time.Second) // then retry\n    case errors.Is(err, channels.ErrTemporary):\n        retryWithBackoff(msg)       // 5xx\n    default: // ErrSendFailed: 404/410/400 -> fix webhook or payload\n        log.Printf(\"slack_webhook permanent: %v\", err)\n    }\n}","handlingStrategy":"try-catch","validationCode":"// keep payloads inside Slack limits before sending\nconst slackMaxLen = 40000 // matches channels.WithMaxMessageLength(40000)\nif len(msg.Text) > slackMaxLen { msg.Text = msg.Text[:slackMaxLen] }","typeGuard":"// sentinel discrimination after the call\nfunc classify(err error) string {\n    switch {\n    case errors.Is(err, channels.ErrRateLimit): return \"rate-limit\"\n    case errors.Is(err, channels.ErrTemporary): return \"temporary\"\n    case errors.Is(err, channels.ErrSendFailed): return \"permanent\"\n    }\n    return \"unknown\"\n}","tryCatchPattern":"if err := ch.Send(ctx, msg); err != nil {\n    switch {\n    case errors.Is(err, channels.ErrRateLimit): // 429\n        time.Sleep(2 * time.Second); retry(ch, msg)\n    case errors.Is(err, channels.ErrTemporary): // 5xx\n        retryWithBackoff(ch, msg)\n    default: // 404/410/400 — webhook dead or payload rejected\n        log.Printf(\"slack_webhook permanent failure: %v\", err)\n    }\n}","preventionTips":["Map the embedded status: 404/410/403 -> recreate the webhook in the Slack app","Respect the ~1 msg/sec incoming-webhook rate limit; coalesce bursts","The status and response body are in the message text — log them for diagnosis"],"tags":["slack-webhook","http-status","rate-limit","retry","webhook"],"backgroundTag":null,"analyzedSha":"49183d7e8daed0dba89ddbb6fcb60089401d9680","analyzedAt":"2026-08-15T21:55:41.315Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}