Pulse/internal/notifications/webhook_retry_test.go
rcourtman 18635fe960 Add unit tests for isRetryableWebhookError (internal/notifications)
47 test cases covering webhook retry logic classification:
- Network errors: timeout, connection refused/reset, DNS lookup, network unreachable
- HTTP 5xx server errors: all status codes 500-599 (retryable)
- HTTP 4xx client errors: all status codes 400-499 except 429 (not retryable)
- HTTP 429 rate limiting: special case (retryable)
- Case insensitivity verification
- Real-world error message patterns from Go net/http
- All status code boundary tests

Coverage 37.4% -> 38.0%.
2025-11-30 22:03:26 +00:00

277 lines
7.4 KiB
Go

package notifications
import (
"errors"
"fmt"
"testing"
)
func TestIsRetryableWebhookError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
// Network errors - should be retryable
{
name: "timeout error",
err: errors.New("connection timeout"),
expected: true,
},
{
name: "timeout uppercase",
err: errors.New("TIMEOUT"),
expected: true,
},
{
name: "connection refused",
err: errors.New("dial tcp: connection refused"),
expected: true,
},
{
name: "connection reset",
err: errors.New("read: connection reset by peer"),
expected: true,
},
{
name: "no such host",
err: errors.New("lookup webhook.example.com: no such host"),
expected: true,
},
{
name: "network unreachable",
err: errors.New("dial tcp: network unreachable"),
expected: true,
},
// HTTP 5xx errors - should be retryable
{
name: "status 500 internal server error",
err: errors.New("webhook returned status 500: Internal Server Error"),
expected: true,
},
{
name: "status 502 bad gateway",
err: errors.New("webhook returned status 502: Bad Gateway"),
expected: true,
},
{
name: "status 503 service unavailable",
err: errors.New("webhook returned status 503: Service Unavailable"),
expected: true,
},
{
name: "status 504 gateway timeout",
err: errors.New("webhook returned status 504: Gateway Timeout"),
expected: true,
},
{
name: "status 520 cloudflare error",
err: errors.New("webhook returned status 520"),
expected: true,
},
{
name: "status 522 connection timed out",
err: errors.New("webhook returned status 522: Connection timed out"),
expected: true,
},
{
name: "status 599 network connect timeout",
err: errors.New("webhook returned status 599"),
expected: true,
},
// HTTP 429 rate limiting - should be retryable
{
name: "status 429 too many requests",
err: errors.New("webhook returned status 429: Too Many Requests"),
expected: true,
},
// HTTP 4xx client errors - should NOT be retryable
{
name: "status 400 bad request",
err: errors.New("webhook returned status 400: Bad Request"),
expected: false,
},
{
name: "status 401 unauthorized",
err: errors.New("webhook returned status 401: Unauthorized"),
expected: false,
},
{
name: "status 403 forbidden",
err: errors.New("webhook returned status 403: Forbidden"),
expected: false,
},
{
name: "status 404 not found",
err: errors.New("webhook returned status 404: Not Found"),
expected: false,
},
{
name: "status 405 method not allowed",
err: errors.New("webhook returned status 405: Method Not Allowed"),
expected: false,
},
{
name: "status 410 gone",
err: errors.New("webhook returned status 410: Gone"),
expected: false,
},
{
name: "status 413 payload too large",
err: errors.New("webhook returned status 413: Payload Too Large"),
expected: false,
},
{
name: "status 415 unsupported media type",
err: errors.New("webhook returned status 415: Unsupported Media Type"),
expected: false,
},
{
name: "status 422 unprocessable entity",
err: errors.New("webhook returned status 422: Unprocessable Entity"),
expected: false,
},
// Note: 429 is handled specially as retryable (checked before 4xx loop)
// Edge cases
{
name: "unknown error defaults to retryable",
err: errors.New("something unexpected happened"),
expected: true,
},
{
name: "empty error message",
err: errors.New(""),
expected: true,
},
{
name: "TLS handshake error",
err: errors.New("tls: handshake failure"),
expected: true,
},
{
name: "EOF error",
err: errors.New("unexpected EOF"),
expected: true,
},
{
name: "DNS resolution error",
err: errors.New("lookup failed: no such host"),
expected: true,
},
// Case insensitivity tests
{
name: "TIMEOUT uppercase",
err: errors.New("REQUEST TIMEOUT"),
expected: true,
},
{
name: "Connection Refused mixed case",
err: errors.New("Connection Refused"),
expected: true,
},
{
name: "Network Unreachable mixed case",
err: errors.New("Network Unreachable"),
expected: true,
},
// Real-world error formats
{
name: "dial tcp timeout format",
err: errors.New("dial tcp 192.168.1.1:443: i/o timeout"),
expected: true,
},
{
name: "context deadline exceeded",
err: errors.New("context deadline exceeded"),
expected: true,
},
{
name: "wrapped timeout error",
err: fmt.Errorf("request failed: %w", errors.New("timeout")),
expected: true,
},
{
name: "status code in message format",
err: errors.New("HTTP error: status 503"),
expected: true,
},
{
name: "status code only",
err: errors.New("status 401"),
expected: false,
},
// Boundary tests for status code ranges
{
name: "status 499 last 4xx",
err: errors.New("webhook returned status 499"),
expected: false,
},
{
name: "status 500 first 5xx",
err: errors.New("webhook returned status 500"),
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isRetryableWebhookError(tt.err)
if result != tt.expected {
t.Errorf("isRetryableWebhookError(%q) = %v, want %v", tt.err.Error(), result, tt.expected)
}
})
}
}
// TestIsRetryableWebhookError_AllStatusCodes verifies status code classification
func TestIsRetryableWebhookError_AllStatusCodes(t *testing.T) {
// Test all 4xx codes are not retryable (except 429)
for code := 400; code <= 499; code++ {
err := fmt.Errorf("webhook returned status %d", code)
result := isRetryableWebhookError(err)
if code == 429 {
// 429 is special - should be retryable
if !result {
t.Errorf("status %d should be retryable (rate limited)", code)
}
} else {
// Other 4xx should not be retryable
if result {
t.Errorf("status %d should NOT be retryable (client error)", code)
}
}
}
// Test all 5xx codes are retryable
for code := 500; code <= 599; code++ {
err := fmt.Errorf("webhook returned status %d", code)
result := isRetryableWebhookError(err)
if !result {
t.Errorf("status %d should be retryable (server error)", code)
}
}
}
// TestIsRetryableWebhookError_NetworkPatterns tests various network error patterns
func TestIsRetryableWebhookError_NetworkPatterns(t *testing.T) {
networkErrors := []string{
"dial tcp 10.0.0.1:443: connect: connection refused",
"dial tcp: lookup api.example.com: no such host",
"read tcp 192.168.1.1:52345->10.0.0.1:443: read: connection reset by peer",
"write tcp 192.168.1.1:52345->10.0.0.1:443: write: broken pipe",
"dial tcp 10.0.0.1:443: i/o timeout",
"net/http: request canceled while waiting for connection (Client.Timeout exceeded)",
"dial tcp [::1]:443: connect: network unreachable",
"Post \"https://api.example.com/webhook\": dial tcp: lookup api.example.com: no such host",
}
for _, errStr := range networkErrors {
t.Run(errStr[:min(50, len(errStr))], func(t *testing.T) {
err := errors.New(errStr)
if !isRetryableWebhookError(err) {
t.Errorf("network error %q should be retryable", errStr)
}
})
}
}