Skip to content
Merged
13 changes: 13 additions & 0 deletions platform-api/internal/apperror/apperror.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ func (e *Error) Error() string {
// Unwrap exposes the wrapped cause to errors.Is/errors.As chains.
func (e *Error) Unwrap() error { return e.Cause }

// Is reports whether target is an *Error carrying the same catalog code, so
// errors.Is(err, SomeDef.New()) matches on the code rather than on pointer
// identity. Without it, every Def.New() allocates a fresh value and no two
// instances of the same failure would ever compare equal — the trap that the
// package-level sentinel errors this catalog replaced used to paper over.
//
// Prefer Def.Is(err) at call sites; this method exists so catalog errors also
// behave correctly when passed through the standard errors.Is protocol.
func (e *Error) Is(target error) bool {
var t *Error
return errors.As(target, &t) && t.Code == e.Code
}

// NewValidation converts a request-validation failure into an Error,
// mirroring NewValidationErrorResponse: validator.ValidationErrors
// become field-level errors under VALIDATION_FAILED; anything else
Expand Down
26 changes: 26 additions & 0 deletions platform-api/internal/apperror/apperror_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,29 @@ func TestWriteHTTP(t *testing.T) {
t.Errorf("expected trackingId echoed in body, got %+v", body)
}
}

// TestErrorIs_MatchesOnCodeNotIdentity guards the behaviour that lets
// errors.Is treat two independently constructed instances of the same catalog
// entry as equal. Def.New allocates a fresh *Error each call, so pointer
// identity would never match.
func TestErrorIs_MatchesOnCodeNotIdentity(t *testing.T) {
a := NotFound.New()
b := NotFound.New()
if a == b {
t.Fatal("Def.New must allocate a distinct *Error each call")
}
if !errors.Is(a, b) {
t.Error("errors.Is should match two errors carrying the same catalog code")
}
if errors.Is(a, Conflict.New()) {
t.Error("errors.Is must not match errors carrying different catalog codes")
}
// The code must also be found through a wrapping chain.
wrapped := fmt.Errorf("context: %w", a)
if !errors.Is(wrapped, NotFound.New()) {
t.Error("errors.Is should see through fmt.Errorf %w wrapping")
}
if !NotFound.Is(wrapped) {
t.Error("Def.Is should see through fmt.Errorf %w wrapping")
}
}
20 changes: 20 additions & 0 deletions platform-api/internal/apperror/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,23 @@ var (
ApplicationAPIKeyNotFound = def(CodeApplicationAPIKeyNotFound, http.StatusNotFound, "The specified API key could not be found.")
ApplicationAPIKeyForbidden = def(CodeApplicationAPIKeyForbidden, http.StatusForbidden, "You do not have permission to access this API key.")
)

// WebSub / WebBroker API entries.
var (
WebSubAPINotFound = def(CodeWebSubAPINotFound, http.StatusNotFound, "The specified WebSub API could not be found.")
WebSubAPIExists = def(CodeWebSubAPIExists, http.StatusConflict, "A WebSub API with this ID already exists.")
WebSubAPILimitReached = def(CodeWebSubAPILimitReached, http.StatusConflict, "WebSub API limit reached for the organization.")
WebBrokerAPINotFound = def(CodeWebBrokerAPINotFound, http.StatusNotFound, "The specified WebBroker API could not be found.")
WebBrokerAPIExists = def(CodeWebBrokerAPIExists, http.StatusConflict, "A WebBroker API with this ID already exists.")
WebBrokerAPILimitReached = def(CodeWebBrokerAPILimitReached, http.StatusConflict, "WebBroker API limit reached for the organization.")
)

// HMAC secret entries. The 32-character minimum is a fixed, publicly
// documented rule, so stating it in the client message reveals nothing the
// API contract does not already.
var (
HmacSecretNotFound = def(CodeHmacSecretNotFound, http.StatusNotFound, "The specified HMAC secret could not be found.")
HmacSecretExists = def(CodeHmacSecretExists, http.StatusConflict, "An HMAC secret with this name already exists.")
HmacSecretInvalidValue = def(CodeHmacSecretInvalidValue, http.StatusBadRequest, "The secret value must be at least 32 characters.")
HmacSecretNotConfigured = def(CodeHmacSecretNotConfigured, http.StatusServiceUnavailable, "HMAC secret management is not configured on this server.")
)
22 changes: 22 additions & 0 deletions platform-api/internal/apperror/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,25 @@ const (
CodeApplicationAPIKeyNotFound = "APPLICATION_API_KEY_NOT_FOUND"
CodeApplicationAPIKeyForbidden = "APPLICATION_API_KEY_FORBIDDEN"
)

// WebSub and WebBroker API domain codes, raised by the event-gateway plugin
// and by the two gateway-internal artifact lookups.
const (
CodeWebSubAPINotFound = "WEBSUB_API_NOT_FOUND"
CodeWebSubAPIExists = "WEBSUB_API_EXISTS"
CodeWebSubAPILimitReached = "WEBSUB_API_LIMIT_REACHED"
CodeWebBrokerAPINotFound = "WEBBROKER_API_NOT_FOUND"
CodeWebBrokerAPIExists = "WEBBROKER_API_EXISTS"
CodeWebBrokerAPILimitReached = "WEBBROKER_API_LIMIT_REACHED"
)

// HMAC secret domain codes (WebSub subscriber callback signing secrets).
// HMAC_SECRET_NOT_CONFIGURED is a 503 rather than a 500: the encryption key is
// a deployment-time setting, so the condition is transient from the client's
// point of view and resolvable without a code change.
const (
CodeHmacSecretNotFound = "HMAC_SECRET_NOT_FOUND"
CodeHmacSecretExists = "HMAC_SECRET_EXISTS"
CodeHmacSecretInvalidValue = "HMAC_SECRET_INVALID_VALUE"
CodeHmacSecretNotConfigured = "HMAC_SECRET_NOT_CONFIGURED"
)
95 changes: 95 additions & 0 deletions platform-api/internal/apperror/respond.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package apperror

import (
"errors"
"log/slog"
"net/http"

"github.com/google/uuid"
)

// FromError coerces err into an *Error. A non-catalog error becomes a generic
// Internal wrapping it, so internal details never reach the client.
//
// A handler fallback may have wrapped a more specific typed error produced
// deeper in the stack (service layer) in a generic Internal — the loop prefers
// the specific one so service-origin errors keep their code and status.
func FromError(err error) *Error {
var appErr *Error
if !errors.As(err, &appErr) {
return Internal.Wrap(err)
}
for appErr.Code == CodeCommonInternalError && appErr.Cause != nil {
var inner *Error
if !errors.As(appErr.Cause, &inner) {
break
}
appErr = inner
}
return appErr
}

// LogAndWrite is the single implementation of the log-then-respond severity
// split, shared by middleware.MapErrors and the event-gateway plugin's
// respondCatalogError so the two paths cannot drift.
//
// A 4xx is a client outcome, not a system fault — it logs at WARN without the
// stack, and no tracking ID is exposed. A 5xx is a system fault — it logs at
// ERROR with the origin stack captured at the Def.New/Def.Wrap call site, and
// the tracking ID is echoed in the response body so the client can quote it
// back for correlation.
//
// extra supplies additional slog key/value pairs (e.g. "path", "method") and
// is appended after the status field so field order stays stable across
// callers.
func LogAndWrite(w http.ResponseWriter, slogger *slog.Logger, e *Error, extra ...any) {
trackID := uuid.NewString()

logFields := []any{
"trackingId", trackID,
"code", e.Code,
"status", e.HTTPStatus,
}
logFields = append(logFields, extra...)
if e.LogMessage != "" {
logFields = append(logFields, "detail", e.LogMessage)
}
if e.Cause != nil {
logFields = append(logFields, "cause", e.Cause.Error())
}
// origin is the file:line where the error was actually constructed
// (Def.New/Def.Wrap call site) — not to be confused with slog's own
// "source" attribute, which always points at this log call.
if origin := e.Origin(); origin != "" {
logFields = append(logFields, "origin", origin)
}

if e.HTTPStatus >= http.StatusInternalServerError {
if len(e.Stack) > 0 {
logFields = append(logFields, "stack", e.StackString())
}
slogger.Error("request failed", logFields...)
WriteHTTP(w, e, trackID)
return
}

slogger.Warn("request failed", logFields...)
WriteHTTP(w, e, "")
}
Loading
Loading