diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index acf7d694..e45f5b8f 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -92,7 +92,7 @@ func respondSanitizedProviderError(c *gin.Context, status int, code, category, m c.JSON(status, response) } -var providerStatusCodePattern = regexp.MustCompile(`provider returned status\s+(\d{3})`) +var providerStatusCodePattern = regexp.MustCompile(`provider returned status\s+(\d{3})(?::\s*(.+))?`) func classifyProviderTestFailure(err error) (code string, category string, message string) { if err == nil { @@ -107,14 +107,18 @@ func classifyProviderTestFailure(err error) (code string, category string, messa return "PROVIDER_TEST_URL_INVALID", "validation", "Provider URL is invalid or blocked. Verify the URL and try again" } - if statusMatch := providerStatusCodePattern.FindStringSubmatch(errText); len(statusMatch) == 2 { + if statusMatch := providerStatusCodePattern.FindStringSubmatch(errText); len(statusMatch) >= 2 { + hint := "" + if len(statusMatch) >= 3 && strings.TrimSpace(statusMatch[2]) != "" { + hint = ": " + strings.TrimSpace(statusMatch[2]) + } switch statusMatch[1] { case "401", "403": return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", "Provider rejected authentication. Verify your credentials" case "404": return "PROVIDER_TEST_ENDPOINT_NOT_FOUND", "dispatch", "Provider endpoint was not found. Verify the provider URL path" default: - return "PROVIDER_TEST_REMOTE_REJECTED", "dispatch", fmt.Sprintf("Provider rejected the test request (HTTP %s)", statusMatch[1]) + return "PROVIDER_TEST_REMOTE_REJECTED", "dispatch", fmt.Sprintf("Provider rejected the test request (HTTP %s)%s", statusMatch[1], hint) } } diff --git a/backend/internal/notifications/http_wrapper.go b/backend/internal/notifications/http_wrapper.go index 981b74e3..7ed876ea 100644 --- a/backend/internal/notifications/http_wrapper.go +++ b/backend/internal/notifications/http_wrapper.go @@ -4,6 +4,7 @@ import ( "bytes" "context" crand "crypto/rand" + "encoding/json" "errors" "fmt" "io" @@ -157,6 +158,9 @@ func (w *HTTPWrapper) Send(ctx context.Context, request HTTPWrapperRequest) (*HT } if resp.StatusCode >= http.StatusBadRequest { + if hint := extractProviderErrorHint(body); hint != "" { + return nil, fmt.Errorf("provider returned status %d: %s", resp.StatusCode, hint) + } return nil, fmt.Errorf("provider returned status %d", resp.StatusCode) } @@ -410,6 +414,34 @@ func shouldRetry(resp *http.Response, err error) bool { return resp.StatusCode >= http.StatusInternalServerError } +// extractProviderErrorHint attempts to extract a short, human-readable error description +// from a JSON error response body. Only well-known fields are extracted to avoid +// accidentally surfacing sensitive or overlong content from arbitrary providers. +func extractProviderErrorHint(body []byte) string { + if len(body) == 0 { + return "" + } + var errResp map[string]any + if err := json.Unmarshal(body, &errResp); err != nil { + return "" + } + for _, key := range []string{"description", "message", "error", "error_description"} { + v, ok := errResp[key] + if !ok { + continue + } + s, ok := v.(string) + if !ok || strings.TrimSpace(s) == "" { + continue + } + if len(s) > 100 { + s = s[:100] + "..." + } + return strings.TrimSpace(s) + } + return "" +} + func readCappedResponseBody(body io.Reader) ([]byte, error) { limited := io.LimitReader(body, MaxNotifyResponseBodyBytes+1) content, err := io.ReadAll(limited)