catcher

package
v1.1.34 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 21, 2026 License: MIT Imports: 17 Imported by: 30

README ΒΆ

ThreatWinds Catcher - Error Handling, Logging and Retry System

Complete error handling, structured logging and retry operations system for ThreatWinds APIs.

🎯 Features

  • πŸ”§ Robust error handling with complete stack traces and unique codes
  • πŸ“ Dual logging system - Error() for errors, Info() for informational events
  • ⚑ High Performance - Optional asynchronous logging to minimize latency
  • πŸ” Controllable Verbosity - Optional stack trace generation to save CPU/memory
  • 🎨 Visual Severity - Severity icons for better log readability
  • πŸ”„ Advanced retry system with exponential backoff, jitter support, and granular configuration
  • 🏷️ Enriched metadata for better debugging and monitoring
  • πŸ”— Native integration with Gin framework and HTTP status codes
  • 🎯 Structured logging - JSON with unique codes and stack traces

Benchmarks

The catcher package has been designed to offer an optimal balance between observability and performance. Below are the results obtained on an AMD Ryzen 9 9950X3D processor:

Scenario Time (ns/op) Memory (B/op) Allocations (allocs/op)
Catcher Info Async (No Trace) 708.3 1104 12
Catcher Error Async (No Trace) 792.4 1344 14
Catcher Info Sync (No Trace) 853.0 960 12
Catcher Error Sync (No Trace) 933.2 1169 14
Catcher Info Sync (With Trace) 2459.0 2315 27
Catcher Error Sync (With Trace) 2181.0 2331 27
slog JSON (Standard) 503.3 48 1
Catcher Nested Errors (3 levels) 1174.0 2329 23
slog Nested Errors (3 levels) 884.4 280 9
Catcher Nested Errors (6 levels) 1490.0 3362 32
slog Nested Errors (6 levels) 1144.0 552 15
Standard Log 193.2 48 1
Catcher Info Async (Parallel) 444.8 1421 19
Catcher Error Async (Parallel) 454.8 1557 19
Catcher Info Sync (Parallel) 535.1 1267 19
Catcher Error Sync (Parallel) 560.2 1387 19
slog JSON (Parallel) 384.5 56 1
πŸ’‘ Performance Analysis and Clarifications
  1. Concurrency and Asynchronous Mode:
    • In highly concurrent environments (Parallel benchmarks), Catcher shows its true strength. The time per operation in Async mode stays around ~450ns when running in parallel, demonstrating excellent scalability and the efficiency of delegating I/O to a dedicated goroutine.
    • Catcher Error in parallel performs almost identically to Catcher Info (~454ns vs ~444ns), confirming that the overhead of error creation (Cause, additional fields) and metadata processing is negligible in high-load scenarios.
    • Compared to slog (Parallel: 384ns), Catcher provides significantly richer metadata, unique error IDs, and structured trace information for a minimal performance trade-off.
    • The asynchronous mechanism uses a dedicated goroutine and a buffered channel, allowing multiple logging goroutines to hand off their work without blocking on I/O.
  2. Realistic Nested Errors and Short-circuit:
    • Realistic Simulation: Benchmarks now use recursive function calls to simulate a real call stack where errors are enriched or propagated across layers.
    • Short-circuit Efficiency: catcher detects if an error is already of type SdkError. If it is, it propagates it immediately without re-processing traces, generating new hashes, or emitting duplicate logs. This results in O(1) cost for upper layers.
    • Comparison: While slog shows good performance, its nesting cost involves manual fmt.Errorf wrapping and serialization of the entire error chain. Catcher automates this with high efficiency while maintaining full traceability from the origin.

πŸ“¦ Installation

go get github.com/threatwinds/go-sdk/catcher

πŸš€ Quick Start

Basic Error Handling
package main

import (
    "errors"
    "github.com/threatwinds/go-sdk/catcher"
)

func main() {
	// Create an enriched error
    err := catcher.Error("database operation failed", 
        errors.New("connection timeout"), 
        map[string]any{
            "operation": "insert",
            "table": "users",
            "status": 500,
        })

	// Error is automatically logged
	// Output: {"code":"abc123...", "trace":[...], "msg":"database operation failed", ...}
}
Basic Logging
func main() {
// Informational startup log
catcher.Info("service starting", map[string]any{
"service": "api-gateway",
"version": "v1.0.0",
"port": 8080,
})

// Create error with context
err := catcher.Error("database connection failed", dbErr, map[string]any{
"host": "localhost:5432",
"status": 500,
})
}
Retry with Logging
func fetchData() error {
config := &catcher.RetryConfig{
MaxRetries: 5,
WaitTime:   2 * time.Second,
}

return catcher.Retry(func () error {
data, err := apiCall()
if err != nil {
return catcher.Error("API call failed", err, map[string]any{
"endpoint": "/api/data",
"status": 500,
})
}

// Log successful operation
catcher.Info("data fetched successfully", map[string]any{
"endpoint": "/api/data",
"records": len(data),
})

return nil
}, config, "authentication_failed")
}

βš™οΈ Configuration

The system can be configured using the following environment variables:

Variable Description Default
CATCHER_ASYNC If set to true, enables asynchronous logging using a buffered channel (10,000 entries). This drastically reduces latency in high-concurrency environments. true
CATCHER_NO_TRACE If set to true, disables the automatic generation of stack traces. Recommended for production environments to save CPU and memory. true
CATCHER_BEAUTY If set to true, adds severity icons (e.g., ❌, ⚠️, ℹ️) to the logs for better readability in terminal. true

βš™οΈ Retry Configuration

type RetryConfig struct {
MaxRetries int           // Maximum number of retries (0 = infinite)
WaitTime   time.Duration // Wait time between retries
}

// Default configuration
var DefaultRetryConfig = &RetryConfig{
MaxRetries: 5,
WaitTime:   1 * time.Second,
}

πŸ“ Logging System

The catcher package provides two distinct logging systems for different purposes:

πŸ”΄ Error Logging - For Error Conditions

Purpose: Exclusively for logging real error conditions with complete context for debugging.

// Returns *SdkError, logs automatically
err := catcher.Error("operation failed", originalErr, map[string]any{
"operation": "payment",
"status": 500,
})

Features:

  • βœ… Complete stack trace (25 frames, optional)
  • βœ… Severity Icons (optional)
  • βœ… Unique MD5 code based on the message content
  • βœ… Error chaining with original cause
  • βœ… Enriched metadata in args
  • βœ… Gin integration with GinError()
  • βœ… Automatic logging when creating error
πŸ”΅ Info Logging - For Informational Events

Purpose: For logging important informational events with structured context, without being errors.

// Logs directly, returns no value
catcher.Info("operation completed", map[string]any{
"operation": "payment",
"success": true,
})

Features:

  • βœ… Lightweight stack trace for context (or none if disabled)
  • βœ… Severity Icons (optional)
  • βœ… Unique MD5 code based on the message content
  • βœ… Structured metadata in args
  • βœ… Consistent JSON format
  • ❌ No error chaining (not an error)
  • βœ… Direct logging without returning object
When to Use Each System
Use Error() Use Info()
❌ Connection failures βœ… Service startup
❌ Validation errors βœ… Operations completed
❌ Timeouts βœ… Configuration loaded
❌ Exceptions βœ… Important metrics
❌ Authentication failures βœ… Business events
❌ Resource not found (critical) βœ… System state changes
Log Structure Comparison

Error Log Structure:

{
  "code": "a1b2c3d4e5f6789...",
  "trace": [
    "main.processPayment 123",
    "api.handleRequest 45"
  ],
  "msg": "payment processing failed",
  "cause": "connection timeout",
  "args": {
    "payment_id": "pay_123",
    "amount": 100.00,
    "status": 500
  }
}

Info Log Structure:

{
  "code": "b7c8d9e0f1a2b3c4...",
  "trace": [
    "main.startService 89",
    "config.initDatabase 34"
  ],
  "msg": "service started successfully",
  "args": {
    "service": "payment-processor",
    "version": "v1.2.3",
    "port": 8080,
    "environment": "production"
  }
}

πŸ”§ Available Retry Functions

1. Retry - Limited retry with maximum attempts
err := catcher.Retry(func () error {
return performOperation()
}, config, "exception1", "exception2")
2. InfiniteRetry - Infinite retry until success or exception
err := catcher.InfiniteRetry(func () error {
return connectToDatabase()
}, config, "auth_failed")
3. InfiniteLoop - Infinite loop until exception
catcher.InfiniteLoop(func () error {
return processMessages()
}, config, "shutdown_signal")
4. InfiniteRetryIfXError - Retry only on specific error
err := catcher.InfiniteRetryIfXError(func () error {
return connectToService()
}, config, "connection_timeout")
5. RetryWithBackoff - Retry with exponential backoff
err := catcher.RetryWithBackoff(func () error {
return callExternalAPI()
}, config,
30*time.Second, // max backoff
2.0, // multiplier
"rate_limited")

πŸ” Error Handling

Creating Enriched Errors
// Basic error
err := catcher.Error("operation failed", originalErr, map[string]any{
"user_id": "123",
"status": 500,
})

// Database operation error
err := catcher.Error("database query failed", dbErr, map[string]any{
"query": "SELECT * FROM users",
"table": "users",
"operation": "select",
"status": 500,
"retry_able": true,
})

// External API error
err := catcher.Error("external API call failed", apiErr, map[string]any{
"service": "payment_processor",
"endpoint": "/api/v1/charge",
"method": "POST",
"status": 502,
"external": true,
})
Checking Error Types
// Basic exception checking
if catcher.IsException(err, "not_found", "forbidden") {
// Handle specific exception
}

// Advanced checking for SdkError
if sdkErr := catcher.ToSdkError(err); sdkErr != nil {
// Access error metadata
if operation, ok := sdkErr.Args["operation"]; ok {
log.Printf("Failed operation: %s", operation)
}

// Check exceptions in SdkError
if catcher.IsSdkException(sdkErr, "timeout") {
// Handle timeout specifically
}
}

🌐 Gin Integration

func handleRequest(c *gin.Context) {
err := performOperation()
if err != nil {
// If it's a SdkError, it will be sent automatically with appropriate headers
if sdkErr := catcher.ToSdkError(err); sdkErr != nil {
sdkErr.GinError(c)
return
}

// For other errors, create SdkError
sdkErr := catcher.Error("request failed", err, map[string]any{
"status": 500,
"request_id": c.GetHeader("X-Request-ID"),
})
sdkErr.GinError(c)
}
}

πŸ“‹ Practical Examples

Database Operation
func getUserByID(userID string) (*User, error) {
var user *User

config := &catcher.RetryConfig{
MaxRetries: 5,
WaitTime:   500 * time.Millisecond,
}

err := catcher.RetryWithBackoff(func () error {
u, err := db.GetUser(userID)
if err != nil {
return catcher.Error("failed to get user", err, map[string]any{
"user_id": userID,
"operation": "getUserByID",
"table": "users",
"status": 500,
})
}
user = u
return nil
}, config, 2*time.Second, 2.0, "user_not_found")

return user, err
}
Connect to External Service
func connectToRedis() error {
return catcher.InfiniteRetryIfXError(func () error {
err := redis.Connect()
if err != nil {
return catcher.Error("redis connection failed", err, map[string]any{
"service": "redis",
"host": "localhost:6379",
"critical": true,
"status": 500,
})
}

// Log successful connection
catcher.Info("redis connected successfully", map[string]any{
"service": "redis",
"host": "localhost:6379",
"pool_size": 10,
})

return nil
}, &catcher.RetryConfig{
WaitTime: 5 * time.Second,
}, "connection_refused")
}
Process Message Queue
func processMessageQueue() {
catcher.InfiniteLoop(func () error {
message, err := queue.GetNext()
if err != nil {
return catcher.Error("failed to get message", err, map[string]any{
"queue": "processing",
"operation": "getMessage",
})
}

if message != nil {
err = processMessage(message)
if err != nil {
// Log error but continue processing
catcher.Error("failed to process message", err, map[string]any{
"message_id": message.ID,
"queue": "processing",
})
} else {
// Log successful processing
catcher.Info("message processed successfully", map[string]any{
"message_id": message.ID,
"queue": "processing",
})
}
}

return nil
}, &catcher.RetryConfig{
WaitTime: 1 * time.Second,
}, "shutdown")
}

πŸ“Š Logging and Monitoring

Complete Application Example
package main

import (
	"github.com/threatwinds/go-sdk/catcher"
	"github.com/gin-gonic/gin"
)

func main() {
	// Informational startup log
	catcher.Info("payment service starting", map[string]any{
		"version": "v1.0.0",
		"port":    8080,
	})

	r := gin.Default()
	r.POST("/payment", handlePayment)

	catcher.Info("payment service ready", map[string]any{
		"endpoints": []string{"/payment"},
		"status":    "ready",
	})

	r.Run(":8080")
}

func handlePayment(c *gin.Context) {
	paymentID := c.Param("id")

	// Informational operation log
	catcher.Info("processing payment", map[string]any{
		"payment_id": paymentID,
		"user_id":    c.GetString("user_id"),
	})

	err := processPayment(paymentID)
	if err != nil {
		// Error log with complete context
		sdkErr := catcher.Error("payment processing failed", err, map[string]any{
			"payment_id": paymentID,
			"user_id":    c.GetString("user_id"),
			"status":     500,
		})
		sdkErr.GinError(c)
		return
	}

	// Informational success log
	catcher.Info("payment processed successfully", map[string]any{
		"payment_id": paymentID,
		"status":     "completed",
	})

	c.JSON(200, gin.H{"status": "success"})
}
Automatic Retry Logging

The system automatically logs:

  • βœ… Retry start with configuration
  • πŸ”„ Failed attempts with error details
  • βœ… Success after retries
  • ❌ Final failure after maximum retries
  • πŸ›‘ Exception stop

πŸ§ͺ Testing

func TestRetryOperation(t *testing.T) {
attempts := 0

err := catcher.Retry(func () error {
attempts++
if attempts < 3 {
return errors.New("temporary error")
}
return nil
}, &catcher.RetryConfig{
MaxRetries: 5,
WaitTime:   10 * time.Millisecond,
})

assert.NoError(t, err)
assert.Equal(t, 3, attempts)
}

πŸ” Debugging and Monitoring

Filter by Type
# Only errors (have "cause")
jq 'select(.cause != null)' app.log

# Only info logs (no "cause")  
jq 'select(.cause == null)' app.log

# Filter by specific code
jq 'select(.code == "a1b2c3d4e5f6789...")' app.log
Error Analysis
# Top most frequent errors
jq -r '.code' app.log | sort | uniq -c | sort -nr | head -10

# Errors from specific service
jq 'select(.args.service == "payment-processor" and .cause != null)' app.log

πŸš€ Monitoring Integration

Both systems generate structured logs ideal for:

  • πŸ“Š Elasticsearch/OpenSearch - Indexing and search
  • πŸ“ˆ Grafana - Dashboards and alerts
  • πŸ”” Alertmanager - Notifications by error codes
  • πŸ“‹ Jaeger/Zipkin - Distributed tracing using unique codes

πŸ“ˆ Benefits of the Catcher System

  1. ⚑ High Performance: Low-latency logging (down to ~450 ns/op) with asynchronous mode and reduced allocations.
  2. πŸ” Better Debugging: Complete stack traces and unique error codes.
  3. πŸ“Š Advanced Monitoring: Rich metadata for alerts and metrics.
  4. βš™οΈ Flexibility: Granular retry configuration per operation and controllable verbosity.
  5. πŸš€ Reliability: Automatic fallback to synchronous logging if the async buffer is full.
  6. πŸ› οΈ Maintainability: Clear separation between logging and retry logic.
  7. πŸ”— Integration: Native support for web frameworks like Gin.

πŸ†˜ Troubleshooting

❓ Problem: Why don't I see successful retry logs?

βœ… Solution: This is intentional - catcher only logs real errors, not successful operations

❓ Problem: Complex configuration

βœ… Solution: Use catcher.DefaultRetryConfig or create reusable configs


πŸ’‘ Tips and Best Practices

  1. Use descriptive metadata in your errors for better debugging
  2. Configure retry strategies specific to operation type
  3. Avoid infinite retry in time-critical operations
  4. Use exponential backoff for external services
  5. Group configurations by application domain (DB, API, etc.)
  6. Use Error() only for real errors - not for informational events
  7. Include unique identifiers (IDs) when relevant
  8. Don't include sensitive information in logs

The catcher system is ready to improve the robustness and observability of your ThreatWinds applications! πŸš€

Documentation ΒΆ

Index ΒΆ

Constants ΒΆ

This section is empty.

Variables ΒΆ

View Source
var DefaultRetryConfig = &RetryConfig{
	MaxRetries: 5,
	WaitTime:   1 * time.Second,
}

DefaultRetryConfig provides sensible defaults for retry operations

Functions ΒΆ

func AdoptErrorID ΒΆ added in v1.1.29

func AdoptErrorID(id string) string

AdoptErrorID guards the one path by which a foreign, remote-controlled value enters the field this package otherwise mints itself. It returns id when id may be adopted as an occurrence id, and "" when it may not β€” in which case the caller falls through to build's own uuid.NewString, so the error still gets an id and only the false correlation is lost.

Only a canonical 36-character UUID is accepted: the shape this package always mints, and therefore the only shape a ThreatWinds service can legitimately send in x-error-id. Without the guard, a buggy or hostile endpoint can hand over an id already in use by an unrelated in-flight failure β€” which breaks the single property ErrorID exists to provide, that grepping one id finds the lines of exactly one incident β€” or an id megabytes long, since http.Transport.MaxResponseHeaderBytes allows 10MB of headers. The adopted value is echoed straight back out in the adopting service's own x-error-id response header and in every log line about the failure, so neither is hypothetical.

It lives here, rather than once per transport package, because this package owns the id's format: it is the only thing that mints one, and the only thing that has to keep every id in the logs the same shape.

Where it is actually enforced ΒΆ

build applies it to every id that reaches an *SdkError from outside: the caller-supplied args["error_id"] (the idiom build's own comment recommends for a service re-raising an upstream failure, and therefore the usual way an inbound x-error-id header is plumbed in by hand) and every id donated by an ErrorIDCarrier cause. That is what makes the guarantee true rather than advisory β€” no *SdkError this package constructs can carry an id it did not either mint or vet, whatever a caller or a foreign carrier hands it.

The transport packages (utils.DoReq, client.APIError) apply the same rule to decide what they report and donate, but they do it with their own six-line copy rather than by importing this package: catcher links gin, and utils is linked by binaries β€” the ETL fetchers β€” that have no business carrying an HTTP framework or this package's logging goroutine. Their copies are not load-bearing for the invariant above (build vets their output anyway) and each is pinned to this function by a parity test in its own package, so the three cannot drift apart without a test failing.

func Configure ΒΆ added in v1.1.0

func Configure(b, a, nt bool)

Configure sets the catcher configuration and can be called programmatically to override env variables and defaults. - b: Beautify output (colors, indentation, etc.) - a: Enable async mode (log messages are sent to a channel instead of printed to stdout) - nt: Disable stack trace printing

func GetSeverityIcon ΒΆ added in v1.0.36

func GetSeverityIcon(severity string) string

GetSeverityIcon returns an icon based on the severity level

func InfiniteLoop ΒΆ added in v1.0.29

func InfiniteLoop(f func() error, config *RetryConfig, exceptions ...string)

InfiniteLoop continuously executes a provided function until it produces a matching exception error. Enhanced version of logger.InfiniteLoop for catcher system.

Logging ΒΆ

A non-exception error from f is swallowed silently, on purpose, exactly as before: this loop exists to tolerate failure indefinitely, and a function that errors on some cycles and succeeds on others is InfiniteLoop's designed steady state, not an incident. Logging that β€” even deduplicated β€” would turn ordinary operation into a permanent stream of log lines for something nobody needs to act on.

A matching exception is different: it is the loop *terminating*. Nothing else observes an InfiniteLoop goroutine returning, so that is the one event on this path worth recording, and it is logged once, immediately before returning. See logLoopTermination for why that log is INFO, not ERROR.

Parameters:

  • f: Function to execute repeatedly
  • config: Configuration for wait time and logging (MaxRetries is ignored)
  • exceptions: Error patterns that should stop the loop

func InfiniteRetry ΒΆ added in v1.0.29

func InfiniteRetry(f func() error, config *RetryConfig, exceptions ...string) error

InfiniteRetry executes a function repeatedly until it succeeds or returns an error containing specified exception patterns. Enhanced version of logger.InfiniteRetry.

Parameters:

  • f: Function to execute that returns an error
  • config: Retry configuration (MaxRetries is ignored, use nil for defaults)
  • exceptions: Error patterns that should stop retrying immediately

Returns:

  • error: nil on success, error on exception match

func InfiniteRetryIfXError ΒΆ added in v1.0.29

func InfiniteRetryIfXError(f func() error, config *RetryConfig, exception string) error

InfiniteRetryIfXError retries a function f() infinitely only if the error returned matches the specified exception. Enhanced version of logger.InfiniteRetryIfXError.

This function provides advanced error filtering: - Retries only if error matches the specific exception - Returns immediately on different errors or success - Logs the exception only once to avoid log saturation - Logs when the issue is resolved

Parameters:

  • f: Function to execute that returns an error
  • config: Retry configuration (MaxRetries is ignored)
  • exception: Specific error pattern to retry on

Returns:

  • error: nil on success, non-matching error immediately, or context error

func Info ΒΆ added in v1.0.29

func Info(msg string, args map[string]any)

Info logs a message with a unique code, stack trace, and optional contextual arguments in a structured format.

func IsException ΒΆ added in v1.0.29

func IsException(err error, exceptions ...string) bool

IsException checks if an error matches any of the specified exception patterns

func IsSdkException ΒΆ added in v1.0.29

func IsSdkException(err *SdkError, exceptions ...string) bool

IsSdkException checks if an SdkError matches any of the specified exception patterns This provides enhanced checking for SdkError types including message and cause

func Log ΒΆ added in v1.1.0

func Log(msg string, args map[string]any)

Log logs a message with a unique code, stack trace, and optional contextual arguments in a structured format.

func Retry ΒΆ added in v1.0.29

func Retry(f func() error, config *RetryConfig, exceptions ...string) error

Retry executes a function repeatedly until it succeeds, the maximum retries are reached, or a matching exception is encountered. Enhanced version of logger.Retry for catcher system.

Parameters:

  • f: Function to execute that returns an error
  • config: Retry configuration (use nil for defaults)
  • exceptions: Error patterns that should stop retrying immediately

Returns:

  • error: nil on success, last error on failure or exception match

func RetryWithBackoff ΒΆ added in v1.0.29

func RetryWithBackoff(f func() error, config *RetryConfig, maxBackoff time.Duration, backoffMultiplier float64, exceptions ...string) error

RetryWithBackoff executes a function with exponential backoff retry strategy. This is a new enhanced retry function not available in the original logger.

Parameters:

  • f: Function to execute that returns an error
  • config: Base retry configuration
  • maxBackoff: Maximum backoff duration
  • backoffMultiplier: Multiplier for exponential backoff (typically 2.0)
  • exceptions: Error patterns that should stop retrying immediately

Returns:

  • error: nil on success, last error on failure or exception match

func Warn ΒΆ added in v1.1.7

func Warn(msg string, args map[string]any)

Warn logs a message with WARNING severity.

Types ΒΆ

type ErrorIDCarrier ΒΆ added in v1.1.29

type ErrorIDCarrier interface {
	CatcherErrorID() string
}

ErrorIDCarrier is implemented by an error that already carries a catcher error id minted somewhere else β€” by utils.RemoteError and client.APIError, which read it from an upstream ThreatWinds service's x-error-id response header, and by *SdkError itself.

It exists so a failure can keep one occurrence id across a service boundary without the transport helper having to return an *SdkError to achieve it. Returning an *SdkError would work, but at a price: build short-circuits on an *SdkError cause and hands it straight back, discarding the wrapping call's msg, args and status override. A plain error implementing this interface lets the caller build its *own* error β€” its message, its args, its status β€” that happens to inherit the upstream's id.

build consults it only when the cause is not itself an *SdkError (that case short-circuits before this is reached) and the call supplied no args["error_id"], so an explicitly supplied id always wins over an inherited one, and an inherited one always wins over a freshly minted UUID.

An implementation returning "" means "no id to donate" and is treated as if the interface were not implemented at all.

Why the method is not called ErrorID() ΒΆ

The obvious spelling for this method is ErrorID() string, matching the field it exposes. It cannot be: SdkError already has an ErrorID *field*, and Go forbids a type from declaring a field and a method of the same name, so *SdkError β€” the type most in need of satisfying this interface uniformly β€” could never implement it. The field is the half that cannot move. It is serialized as "error_id" (see its json tag), read out of log lines and off the wire by consumers, and written into the x-error-id response header by GinError; renaming it would break every one of those readers, to buy nothing. So the method takes the disambiguating prefix instead, and every implementation in the SDK spells it the same way.

The dependency stays inverted through this interface: catcher imports neither utils nor client, and never will. An error type opts in by declaring the method; nothing else about it has to change.

type RetryConfig ΒΆ added in v1.0.29

type RetryConfig struct {
	MaxRetries int           // Maximum number of retries (0 = infinite)
	WaitTime   time.Duration // Wait time between retries
}

RetryConfig defines configuration options for retry operations

type SdkError ΒΆ

type SdkError struct {
	Timestamp string `json:"timestamp"`
	Code      string `json:"code"`
	// ErrorID identifies this error *occurrence*, as opposed to Code, which
	// identifies the error's *type* (an md5 of the message, shared by every
	// occurrence of the same message anywhere). ErrorID is meant to be
	// carried unchanged by every service that handles the same failure as it
	// propagates β€” gateway to ai-api to auth-api, say β€” so an operator can
	// grep one value across every service's logs and find every log line
	// that pertains to one specific failure. See build's args["error_id"]
	// handling for how it is populated, and the short-circuit branch of
	// build for why it stays fixed once set.
	ErrorID  string         `json:"error_id"`
	Trace    []string       `json:"trace,omitempty"`
	Msg      string         `json:"msg"`
	Cause    *string        `json:"cause,omitempty"`
	Args     map[string]any `json:"args,omitempty"`
	Severity string         `json:"severity"`
	// contains filtered or unexported fields
}

SdkError is a struct that implements the Go error interface.

func Error ΒΆ

func Error(msg string, cause error, args map[string]any) *SdkError

Error tries to cast the cause as an SdkError, if it is not an SdkError, it creates a new SdkError with the given parameters. It logs the error message and returns the error. If cause is nil, it will store a blank string in the Cause field. The field Code is a hash of the message and trace. It is used to identify the recurrence of an error. Params: msg: the error message. cause: the error that caused this error. args: a map of additional information. Recognized keys when used with GinError():

"status" (int)         β†’ HTTP status code (default 500)
"retry" (int)          β†’ Retry-After header value in seconds
"code_override" (string) β†’ overrides the error code in the JSON body
"param" (string)       β†’ included as "param" in the JSON error detail
"error_id" (string)    β†’ identifies this error occurrence; adopted if
                         supplied and it passes AdoptErrorID (a
                         canonical 36-character UUID, the only shape
                         this package mints), otherwise inherited from
                         a cause implementing ErrorIDCarrier,
                         otherwise generated as a UUID. Meant to be
                         carried unchanged by every service that
                         re-raises the same failure so it can be
                         traced across services. Ignored in the
                         short-circuit case below, same as msg and
                         every other arg.

This package logs at construction, not at handling ΒΆ

If cause is already an *SdkError, Error does NOT construct a new error and does NOT log anything new: it returns cause unchanged, exactly as built, and calls Log() on it β€” a no-op if that pointer was already logged (which every existing call site's cause is, since Error has always logged at construction), but not a no-op if it came from New(), which does not log. msg, args, and any args["status"] override are silently ignored in the short-circuit case β€” the returned error's identity, Code, Severity and Args must stay stable as it propagates back up through however many call sites already have it, and re-logging it once per layer on the way up is not something this function does.

This means passing an existing *SdkError as cause is never the right way to add logged context. To log context about an error you already have, pass nil as cause and fold the error text into args instead:

sdkErr := catcher.ToSdkError(err)
catcher.Error("context about the failure", nil, map[string]any{"cause": sdkErr.Error()})

New is the preferred constructor for new call sites migrating away from logging at construction; see its doc comment.

Returns: *SdkError: the error. This type implements the Go error interface.

func New ΒΆ added in v1.1.29

func New(msg string, cause error, args map[string]any) *SdkError

New constructs an *SdkError exactly like Error β€” same short-circuit when cause is already an *SdkError, same trace capture, same code hash, same severity derivation from args["status"] β€” but it does not log.

New is the preferred constructor. The error is logged once it reaches a boundary: automatically, the first time GinError writes it to an HTTP response, or explicitly, by calling Log() on it. Log is idempotent, so it is safe to call from both places during a migration, or from neither if GinError will handle it. What is not safe is constructing with New and then discarding the result without ever reaching a boundary or calling Log(): unlike Error, a swallowed *SdkError from New is never logged anywhere, by anyone β€” if you catch it and decide not to propagate it, call Log() yourself before you let it go.

func ToSdkError ΒΆ

func ToSdkError(err error) *SdkError

ToSdkError tries to cast an error to a SdkError. If the error isn't an SdkError, it returns nil.

It also returns nil when err's dynamic value is a nil *SdkError boxed in a non-nil error interface β€” e.g. a function declared to return *SdkError that returns nil, then gets assigned to an `error` variable. errors.As matches that case (the concrete type is right) and sets the target to that nil pointer; returning it as-is would hand callers a *SdkError that is not == nil as a concrete pointer but panics the moment any value-receiver method (Error, JSON, SecureString) is called on it.

func (*SdkError) CatcherErrorID ΒΆ added in v1.1.29

func (e *SdkError) CatcherErrorID() string

CatcherErrorID implements ErrorIDCarrier, returning this error's occurrence id β€” the same value as the ErrorID field, which stays the serialized, consumer-visible one. See ErrorIDCarrier's doc for why the method carries a prefix the field does not.

build never reaches this on an *SdkError cause: ToSdkError short-circuits first, so an existing *SdkError is returned by identity rather than donating its id to a second error. The method exists so that code outside this package can ask "which occurrence is this?" of any error the SDK produces β€” an *SdkError and a transport-level carrier alike β€” without type-switching over every error type in the SDK.

Pointer receiver, and nil-safe, for the same reason as Unwrap and Log: this package produces typed-nil *SdkError values in the wild (see ToSdkError), and errors.As matches on type alone, so an interface method can be reached on a nil receiver by a routine errors.As walk.

func (SdkError) Error ΒΆ

func (e SdkError) Error() string

Error returns the error message.

func (SdkError) GinError ΒΆ

func (e SdkError) GinError(c *gin.Context)

GinError is a helper function to return an error to the client using Gin framework context. It sets the headers x-error and x-error-id with the error message and the error's UUID (ErrorID) respectively β€” restoring this doc comment's long-standing claim, which the code had drifted away from by sending Code (a hash of the message, shared by every occurrence of that message) in x-error-id instead of a per-occurrence id. It also writes a JSON error body β€” which now includes error_id alongside the existing code, for a caller that reads the body rather than headers β€” and sets the status code.

Before writing anything, it logs the error if it has not already been logged β€” the same line Log() would emit. This gives every HTTP path a guaranteed boundary log with no handler changes required: an *SdkError built with New() (which does not log) is still logged the moment it reaches GinError, and one built with Error() or already Log()'d is not logged a second time.

GinError keeps its value receiver for backwards compatibility (see Error, JSON and SecureString above), so it only ever holds a *copy* of the SdkError, not the caller's pointer. That copy still genuinely marks the error logged for every other holder of it, because logged is a *atomic.Bool (see the field doc and Log's doc comment): copying the struct copies the pointer, not the flag it points to, so GinError's local copy shares the exact same atomic.Bool the caller's original pointer does. (&e).Log() below does its check-and-set on that shared flag, so the write is visible through the caller's original pointer too β€” a later err.Log() call, or a second GinError(c) call, on that same original pointer sees logged already true and stays silent. This is what makes it safe for both "New() built it, GinError logs it" and "Error() already logged it, GinError must not log it again" to share one code path.

The one case this does not cover is an SdkError whose logged pointer is nil to begin with β€” a zero-value SdkError{} literal, or one decoded from JSON, that was never built by build(). There, GinError's copy has its own independent nil flag before Log ever runs, Log lazily allocates a flag for that copy alone, and β€” because a value receiver cannot write that allocation back to the caller β€” a later call against the original starts from its own independent nil flag and logs again. See the "nil logged pointer" section of Log's doc comment for the full explanation; it is unreachable through Error or New, which always allocate the flag before any copy is made. Additional Args keys:

  • "retry": N β€” sets the Retry-After header to N seconds.
  • "code_override": string β€” overrides the error code in the response body.
  • "param": string β€” included as "param" in the response error detail.
  • "error_id": string β€” see build's doc comment; reflected in the x-error-id header and the response body's error_id, not the code.

func (SdkError) JSON ΒΆ added in v1.1.0

func (e SdkError) JSON() string

JSON returns the JSON string representation of the SdkError.

func (*SdkError) Log ΒΆ added in v1.1.29

func (e *SdkError) Log()

Log emits this error's log line β€” exactly the line Error() emits at construction β€” and records that it has done so. A second call is a no-op, whether it arrives via the same *SdkError, a pointer taken to a copy of that same struct (e.g. GinError's local copy β€” see its doc comment), or a concurrent goroutine racing another call on the same pointer: this idempotency is the whole basis of a safe migration away from "log at construction". During the transition an error may end up logged at construction (Error), at an explicit handling boundary (Log), or at the HTTP boundary (GinError) β€” sometimes more than one of those will run against what turns out to be the same logical error β€” and it must never produce two lines for one error.

Log takes a pointer receiver, unlike Error, JSON, SecureString and GinError below (which stay on value receivers for backwards compatibility). What actually makes a second call a no-op, though, is not the receiver — it's that logged is a *atomic.Bool: every SdkError built by build() (i.e. by Error or New) gets one allocated at construction, and every copy taken of that struct afterward — by a value-receiver method, by dereferencing the pointer, by storing an SdkError value somewhere — carries a pointer to that same atomic.Bool, not a copy of its value. A check-and-set through any copy's pointer is therefore visible to every other copy, including the caller's original. That also makes concurrent Log() calls on the same error safe without an external lock: CompareAndSwap(false, true) guarantees exactly one caller observes the false→true transition, so exactly one of them logs no matter how many goroutines call at once (see TestLogConcurrentGoroutinesLogOnce, run with -race).

A nil logged pointer means this SdkError never went through build β€” a zero-value SdkError{} literal, or one decoded from JSON (decoding never calls Log, and the field is unexported so JSON tags can't touch it either). That is not an error state: Log treats "no flag yet" as "not logged yet", lazily allocating one β€” under mu, so two goroutines making their first call on the same nil-flag pointer don't race to install different flags β€” before doing the check-and-set. That makes repeated Log() calls on that *same* pointer idempotent, exactly as above, because the second call sees the flag the first call installed. What it cannot fix is GinError specifically: GinError has a value receiver, so when called on an SdkError whose logged pointer is nil, it is already holding an independent copy of the whole struct β€” nil pointer included β€” before Log ever runs. The flag Log lazily allocates for that call is real and makes that one call idempotent against itself, but a value receiver has no way to write it back to the caller's original (see GinError's doc comment), so a later Log() on the original pointer, or a second GinError call, starts from its own independent nil flag and logs again. This gap is unreachable through Error or New, which always allocate the flag in build() before any copy is ever made; it is only reachable by code that builds an SdkError{} literal directly, or decodes one from JSON, and then hands the same value to GinError (or Log) more than once.

Safe to call on a nil *SdkError: it returns without doing anything, rather than panicking, matching the nil-safety already established for Unwrap and ToSdkError in this file.

func (SdkError) SecureString ΒΆ added in v1.1.0

func (e SdkError) SecureString() string

SecureString renders this error for a consumer who is not this platform's operator: the response body and the x-error header GinError puts on the wire.

At >= 500 it returns Msg alone β€” a server-side failure is not the caller's to diagnose, and Cause at that point is this service's own internals.

Below 500 it returns the full description, minus the Args that privateArgs names. A 4xx is about something in the caller's own request, so explaining it is the point; disclosing the platform's internal topology while doing so is not.

The log line is unaffected either way: Log serializes the struct itself (see JSON) and never goes through this method, so operators keep every key.

func (*SdkError) Unwrap ΒΆ added in v1.1.29

func (e *SdkError) Unwrap() error

Unwrap returns the original error this SdkError was constructed from, if any, so errors.Is and errors.As can traverse beneath it.

This uses a pointer receiver, unlike Error, JSON and SecureString below (which stay on value receivers for backwards compatibility β€” changing them would stop a bare SdkError value, as opposed to *SdkError, from satisfying the error interface). errors.Is/errors.As call Unwrap on every node of a chain without a nil check first. A value receiver has to dereference the receiver to build its copy, which panics immediately when called on a typed-nil *SdkError β€” and this package already produces exactly that value in the wild (see ToSdkError's typed-nil handling below). A pointer receiver instead returns nil for a nil receiver, matching Unwrap's documented contract of "no further error", so a routine errors.Is/As call can never turn into a panic. Since Error() (below) returns *SdkError everywhere in this package, *SdkError's method set β€” which includes both this pointer-receiver method and the value-receiver ones β€” is what every real caller's error chain actually carries.

type SdkLog ΒΆ added in v1.0.29

type SdkLog struct {
	Timestamp string         `json:"timestamp"`
	Code      string         `json:"code"`
	Trace     []string       `json:"trace,omitempty"`
	Msg       string         `json:"msg"`
	Args      map[string]any `json:"args,omitempty"`
	Severity  string         `json:"severity"`
}

SdkLog represents a structured log entry with unique code, stack trace, message, and optional additional arguments.

func (SdkLog) JSON ΒΆ added in v1.1.0

func (e SdkLog) JSON() string

JSON returns the JSON-encoded string representation of the SdkLog instance.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL