Skip to main content

The 3-Tier Error Propagation Chain

How LZStock guarantees zero information leakage to clients while maintaining pristine, traceable error logs for developers using Go and gRPC.

TL;DR
  • Zero-Leakage Error Firewall: Mapped raw internal domain errors to safe gRPC status codes at the Controller layer, translating them into sanitized HTTP JSON at the Gateway to guarantee absolute client-side security.
  • Pristine Traceability via Error Wrapping: Leveraged Go's fmt.Errorf with %w to construct a detailed error chain (Domain ➡️ UseCase ➡️ Controller), preserving the exact execution context and parameters for internal APM logs.
  • Bulletproof Panic Recovery: Engineered a custom deferred recovery utility to gracefully intercept fatal crashes, actively stripping sensitive absolute server paths from the dumped stack trace to localize the blast radius and assist rapid debugging.

The Objective

In a distributed financial system, error handling serves two conflicting masters:

  1. The Developer: Needs exhaustive context (the exact file, line, and parameter that failed) to resolve issues rapidly.
  2. The Client (and Security): Must never see internal stack traces, database schema names, or raw SQL errors, which represent severe security vulnerabilities (Information Leakage).

The objective of this architecture is to build a unified Error Propagation Chain. It catches panics, wraps domain errors with execution context, translates them into standard gRPC status codes at the microservice boundary, and finally masks them into user-friendly JSON at the API Gateway.

The Mental Model & Codebase Anatomy

The error transforms as it bubbles up through the Clean Architecture layers:

The Error Translation Pipeline:

Codebase Anatomy

lzstock/
├──mods/
│ bc1-indicator-insights
│ │ ├── controllers
│ │ │ ├── dashboardCommand.go
│ │ │ └── dashboardQuery.go
│ │ ├── domain
│ │ │ ├── DashboardAggregate.go
│ │ │ ├── DashboardID.go
│ │ │ ├── DashboardName.go
│ │ │ └── DashboardStatus.go
│ │ └── useCases
│ │ └── Dashboard.go
│ bc15-api-gateway/server/restful
│ └── bc1-indicator-insights
│ ├── CONTRACT_TESTING_IMPLEMENTATION.md
│ ├── CREATE_DASHBOARD_TEST_CASE_GUIDE.md
│ ├── Note.md
│ ├── company.go
│ └── dashboard.go
└──shared/go
├── application
│ ├── core
│ │ ├── Guard.go
│ │ └── Promise.go
│ ├── restful
│ │ └── BaseController.go
└── errors
└── AppError.go

Core Implementation

Below are the curated abstractions of the error propagation engine.

The Propagation Chain (Domain ➡️ UseCase ➡️ Controller)

The Domain defines the error. The UseCase adds context. The Controller acts as the final firewall: it logs the detailed error for developers, but only returns a sanitized status.Status to the outside world.


// 1. Domain Layer: Define the Sentinel Error
var ErrEmptyDashboardName = errors.New("dashboard name is empty")

// 2. UseCase Layer: Wrap the error with context (%w)
func (u *UseCase) CreateDashboard(req *dto.CreateDashboard) error {
dashboardName, err := domain.NewDashboardName(req.DashboardName)
if err != nil {
// Traces exactly WHERE the error occurred and WHAT the input was
return fmt.Errorf("new dashboardName from input '%s': %w", req.DashboardName, err)
}
return nil
}

// 3. Controller Layer: The Firewall
func (c *Ctrl) CreateDashboard(ctx context.Context, req *pb.CreateDashboardReq) (*pb.CreateDashboardRes, error) {
defer apperrs.Recover(&err) // Protect against panics

if err := c.useCase.CreateDashboard(req); err != nil {
// Dump the complete, wrapped error chain to internal APM/Logs
log.Printf("[ERROR] create dashboard: %s", err)

// Translate to safe gRPC status code
if errors.Is(err, domain.ErrEmptyDashboardName) {
st := status.New(codes.InvalidArgument, "Invalid dashboard configuration")
st, _ = st.WithDetails(&errdetails.ErrorInfo{Reason: "EMPTY_DASHBOARD_NAME"})
return nil, st.Err()
}

// Fallback: Mask all unknown errors as Internal Server Error
return nil, status.New(codes.Internal, "An unexpected error occurred").Err()
}
return &pb.CreateDashboardRes{}, nil
}

The Gateway Translation (gRPC ➡️ REST)

When the API Gateway (FastHTTP) receives the gRPC error, it parses the errdetails and maps it to a localized, UI-friendly HTTP JSON response.

// shared/go/application/restful/BaseController

func HandleGRPCErr(c *routing.Context, err error, messageMap map[string]string) {
st, ok := status.FromError(err)
if !ok {
// Failsafe for non-gRPC errors
ToRestfulErr(c, "INTERNAL_ERROR", "Unknown error", 500)
return
}

// Extract the strict Reason string (e.g., "EMPTY_DASHBOARD_NAME")
var reason string
for _, detail := range st.Details() {
if info, ok := detail.(*errdetails.ErrorInfo); ok {
reason = info.Reason
break
}
}

// Map to sanitized HTTP response
localizedMessage := messageMap[reason]
httpCode := mapGRPCToHTTPStatus(st.Code()) // e.g., codes.InvalidArgument -> 400

ToRestfulErr(c, reason, localizedMessage, httpCode)
}

Bulletproof Panic Recovery

The custom Recover utility intercepts panics, formats the raw stack trace (stripping absolute server paths for security), and wraps it into a standard error.

// shared/go/errors/AppError.go

// Recover is designed to be deferred at the top level of every Controller or Goroutine
func Recover(err *error) {
if r := recover(); r != nil {
var panicError error
switch v := r.(type) {
case error:
panicError = v
case string:
panicError = fmt.Errorf("panic: %s", v)
}

// Clean up the raw debug.Stack() to remove absolute GOPATHs
// and format it for readable log aggregation
cleanStack := formatStackTrace(string(debug.Stack()))

*err = fmt.Errorf("CRITICAL PANIC: %v\nStack Trace:\n%s", panicError, cleanStack)
}
}

See More: A lightweight Go library for panic recovery and error handling with customizable stack traces.

Edge Cases & Trade-offs

  • Performance vs. Traceability (%w): Using fmt.Errorf with the %w verb allocates a new error struct on the heap, which incurs a slight performance penalty compared to returning static sentinel errors. However, for core business logic, the diagnostic value of the error chain vastly outweighs the microsecond allocation cost. For ultra-hot paths (e.g., the TST search tree loop), I strictly use static errors to bypass this overhead.

  • The "Unknown Error" Blackhole: If a developer forgets to map a domain error to a gRPC status in the Controller, it defaults to codes.Internal. While this safely prevents information leakage, it results in generic "500 Server Error" messages for the user.

The Outcome

By establishing a strict three-tier error boundary, the backend achieves absolute Information Security for the client, while empowering the engineering team to debug complex, multi-service workflows instantly via the seamlessly wrapped log chains.