Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
37770ed
Refactor error handling in API handlers to use standardized error res…
Thushani-Jayasekera Jul 7, 2026
60d4c81
Update error handling in API key and application handlers to use new …
Thushani-Jayasekera Jul 7, 2026
2d66374
Add application error handling with standardized error codes and tests
Thushani-Jayasekera Jul 7, 2026
8f134e0
Refactor error messages in API handlers for clarity and consistency
Thushani-Jayasekera Jul 7, 2026
33a7e4d
Enhance error messages in artifact guard and gateway handlers for imp…
Thushani-Jayasekera Jul 7, 2026
0a0734e
Merge remote-tracking branch 'upstream/main' into err_resp_fix
Thushani-Jayasekera Jul 7, 2026
e887a34
Add setLocation utility to various handlers for consistent Location h…
Thushani-Jayasekera Jul 7, 2026
270baa1
Refactor error handling in API and gateway handlers to utilize typed …
Thushani-Jayasekera Jul 8, 2026
20785ec
Merge remote-tracking branch 'upstream/main' into err_resp_fix
Thushani-Jayasekera Jul 8, 2026
b052ad1
Improve error handling in SyncCustomPolicy method by separating gatew…
Thushani-Jayasekera Jul 8, 2026
c927a0b
Apply suggestions from code review
malinthaprasan Jul 8, 2026
db510bc
Refactor error handling to use centralized error response structure
Thushani-Jayasekera Jul 8, 2026
f1cff12
Add Origin method to apperror for enhanced error tracing
Thushani-Jayasekera Jul 8, 2026
09d4e00
Enhance error logging in SyncCustomPolicy method
Thushani-Jayasekera Jul 8, 2026
4956865
Merge remote-tracking branch 'upstream/main' into err_resp_fix
Thushani-Jayasekera Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions platform-api/internal/apperror/apperror.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* 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 defines the typed application error that handlers,
// services, and repositories return instead of writing HTTP error responses
// inline. A single mapper at the routing layer (middleware.MapErrors) catches
// the error, logs it, and serializes it onto the wire via WriteHTTP — see
// resources/ERROR_HANDLING_IMPLEMENTATION_PLAN.md.
package apperror

import (
"errors"
"fmt"
"net/http"
"runtime"
"strings"

"github.com/go-playground/validator/v10"
)

// Error carries everything the error mapper needs to log a failure and write
// the client-facing ErrorResponse: the catalog code and HTTP status,
// the sanitized client message, optional field-level validation errors and
// structured details, plus internal-only diagnostics (log message, wrapped
// cause, and the call stack captured at construction time).
type Error struct {
Code string // catalog code, e.g. CodeCommonValidationFailed
HTTPStatus int // status to write
Message string // client-facing message
FieldErrors []FieldError // optional, for validation failures
Details any // optional structured metadata
LogMessage string // internal-only detail; never sent to client
Cause error // wrapped original error, for logging/unwrapping
Stack []uintptr // captured at construction time, see New
}

// Error satisfies the error interface. It intentionally includes only the
// code and client message — internal diagnostics stay in LogMessage/Cause.
func (e *Error) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}

// Unwrap exposes the wrapped cause to errors.Is/errors.As chains.
func (e *Error) Unwrap() error { return e.Cause }

// NewValidation converts a request-validation failure into an Error,
// mirroring NewValidationErrorResponse: validator.ValidationErrors
// become field-level errors under VALIDATION_FAILED; anything else
// maps to the generic "Invalid input." message with the original error kept
// as the cause for internal logging.
func NewValidation(err error) *Error {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
fieldErrors := make([]FieldError, 0, len(ve))
for _, fe := range ve {
fieldErrors = append(fieldErrors, FieldError{
Field: fe.Field(),
Message: fmt.Sprintf("The field %s is %s", fe.Field(), fe.Tag()),
})
}
return newWithSkip(3, CodeCommonValidationFailed, http.StatusBadRequest,
"Validation failed for the request parameters.").WithFieldErrors(fieldErrors)
}
return newWithSkip(3, CodeCommonValidationFailed, http.StatusBadRequest, "Invalid input.").WithCause(err)
}

// newWithSkip captures the stack skipping the given number of frames so the
// first frame is the exported constructor's caller. It is the only way an
// *Error comes into existence — exported construction goes through the
// catalog (Def.New / Def.Wrap, see catalog.go) or NewValidation, so the
// stack capture can never be skipped and code/status/message always come
// from a declared catalog entry.
//
// LogMessage defaults to the client message so the mapper always has
// something to log, even if the call site never chains WithLogMessage.
// WithLogMessage overrides this default with a more specific internal
// detail when the client message is too generic to be useful for diagnosis
// (e.g. Unauthorized's fixed message).
func newWithSkip(skip int, code string, status int, message string) *Error {
var pcs [32]uintptr
n := runtime.Callers(skip, pcs[:])
return &Error{Code: code, HTTPStatus: status, Message: message, LogMessage: message, Stack: pcs[:n]}
}

// WithLogMessage overrides the default internal-only detail message (see
// newWithSkip) that the mapper logs but never sends to the client.
func (e *Error) WithLogMessage(msg string) *Error {
e.LogMessage = msg
return e
}

// WithFieldErrors attaches field-level validation errors surfaced to the
// client in the errors[] array.
func (e *Error) WithFieldErrors(fe []FieldError) *Error {
e.FieldErrors = fe
return e
}

// WithDetails attaches optional structured metadata surfaced to the client
// in the details field.
func (e *Error) WithDetails(details any) *Error {
e.Details = details
return e
}

// WithCause wraps the original error for logging and errors.Is/As chains.
func (e *Error) WithCause(err error) *Error {
e.Cause = err
return e
}

// Origin symbolizes just the first frame of the stack captured at
// construction time — the file:line of the Def.New/Def.Wrap call site where
// the error actually originated, e.g. "/internal/handler/application.go:142".
// The path is trimmed to start at "/internal/" so logs carry a short,
// repo-relative path instead of the full build-machine absolute path. This is
// distinct from slog's own "source" attribute, which always points at the
// mapper's log call site (middleware/error_mapper.go) regardless of where
// the error was created, and is therefore useless for locating the throw
// site. Returns "" if no stack was captured.
func (e *Error) Origin() string {
if len(e.Stack) == 0 {
return ""
}
frames := runtime.CallersFrames(e.Stack)
frame, _ := frames.Next()
if frame.File == "" {
return ""
}
file := frame.File
if idx := strings.Index(file, "/internal/"); idx != -1 {
file = file[idx:]
}
return fmt.Sprintf("%s:%d", file, frame.Line)
}

// StackString symbolizes the stack captured at construction time. Kept as
// raw []uintptr on the struct (cheap to capture) and only symbolized lazily
// when the mapper actually logs it, rather than paying runtime.CallersFrames
// cost on every construction.
func (e *Error) StackString() string {
if len(e.Stack) == 0 {
return ""
}
var sb strings.Builder
frames := runtime.CallersFrames(e.Stack)
for {
frame, more := frames.Next()
fmt.Fprintf(&sb, "%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line)
if !more {
break
}
}
return sb.String()
}
139 changes: 139 additions & 0 deletions platform-api/internal/apperror/apperror_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* 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 (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestDefNewCapturesStack(t *testing.T) {
e := Internal.New()
if len(e.Stack) == 0 {
t.Fatal("expected non-empty stack captured at construction time")
}
stack := e.StackString()
if !strings.Contains(stack, "TestDefNewCapturesStack") {
t.Errorf("expected stack trace to include the calling function, got:\n%s", stack)
}
}

func TestDefNewFormatsMessage(t *testing.T) {
e := ValidationFailed.New("API name is required")
if e.Message != "API name is required" {
t.Errorf("unexpected message %q", e.Message)
}
if e.Code != CodeCommonValidationFailed || e.HTTPStatus != http.StatusBadRequest {
t.Errorf("Def did not carry its declared code/status: %+v", e)
}

// Fixed-message entries called with no args return the template verbatim.
if got := RESTAPINotFound.New().Message; got != "The specified REST API could not be found." {
t.Errorf("unexpected fixed message %q", got)
}

// Templates with verbs interpolate args.
if got := DeploymentNotActive.New("API").Message; got != "No active deployment found for this API on the gateway." {
t.Errorf("unexpected formatted message %q", got)
}
}

func TestDefWrapAttachesCause(t *testing.T) {
cause := errors.New("db connection refused")
e := Internal.Wrap(cause)
if !errors.Is(e, cause) {
t.Error("expected errors.Is to find the wrapped cause")
}
if len(e.Stack) == 0 {
t.Error("expected Wrap to capture the stack too")
}

// A wrapped *Error must still be discoverable via errors.As — the
// propagation contract relies on this.
wrapped := fmt.Errorf("service layer context: %w", e)
var appErr *Error
if !errors.As(wrapped, &appErr) {
t.Fatal("expected errors.As to find *Error through a %w wrap")
}
if appErr.Code != CodeCommonInternalError {
t.Errorf("unexpected code %q", appErr.Code)
}
}

func TestDefIs(t *testing.T) {
err := fmt.Errorf("outer: %w", ProjectNotFound.New())
if !ProjectNotFound.Is(err) {
t.Error("expected Def.Is to match through a %w wrap")
}
if RESTAPINotFound.Is(err) {
t.Error("Def.Is matched a different catalog entry")
}
if ProjectNotFound.Is(errors.New("plain")) {
t.Error("Def.Is matched a plain error")
}
}

func TestNewValidationFallback(t *testing.T) {
e := NewValidation(errors.New("bad json"))
if e.HTTPStatus != http.StatusBadRequest {
t.Errorf("expected 400, got %d", e.HTTPStatus)
}
if e.Code != CodeCommonValidationFailed {
t.Errorf("expected %s, got %s", CodeCommonValidationFailed, e.Code)
}
if e.Message != "Invalid input." {
t.Errorf("unexpected message %q", e.Message)
}
}

func TestWriteHTTP(t *testing.T) {
rec := httptest.NewRecorder()
WriteHTTP(rec, ProjectNotFound.Wrap(errors.New("sql: no rows in result set")).
WithLogMessage("internal detail that must not leak"), "")

if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
var body ErrorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON body: %v", err)
}
if body.Status != "error" || body.Code != CodeProjectNotFound {
t.Errorf("unexpected envelope: %+v", body)
}
if strings.Contains(rec.Body.String(), "sql:") || strings.Contains(rec.Body.String(), "internal detail") {
t.Error("internal diagnostics leaked into the client response")
}
if strings.Contains(rec.Body.String(), "trackingId") {
t.Error("empty trackingId must be omitted from the response")
}

rec = httptest.NewRecorder()
WriteHTTP(rec, Internal.New(), "d2f1c0aa-0000-0000-0000-000000000000")
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON body: %v", err)
}
if body.TrackingID != "d2f1c0aa-0000-0000-0000-000000000000" {
t.Errorf("expected trackingId echoed in body, got %+v", body)
}
}
Loading
Loading