Documentation
ΒΆ
Index ΒΆ
- Variables
- func AdoptErrorID(id string) string
- func Configure(b, a, nt bool)
- func GetSeverityIcon(severity string) string
- func InfiniteLoop(f func() error, config *RetryConfig, exceptions ...string)
- func InfiniteRetry(f func() error, config *RetryConfig, exceptions ...string) error
- func InfiniteRetryIfXError(f func() error, config *RetryConfig, exception string) error
- func Info(msg string, args map[string]any)
- func IsException(err error, exceptions ...string) bool
- func IsSdkException(err *SdkError, exceptions ...string) bool
- func Log(msg string, args map[string]any)
- func Retry(f func() error, config *RetryConfig, exceptions ...string) error
- func RetryWithBackoff(f func() error, config *RetryConfig, maxBackoff time.Duration, ...) error
- func Warn(msg string, args map[string]any)
- type ErrorIDCarrier
- type RetryConfig
- type SdkError
- type SdkLog
Constants ΒΆ
This section is empty.
Variables ΒΆ
var DefaultRetryConfig = &RetryConfig{ MaxRetries: 5, WaitTime: 1 * time.Second, }
DefaultRetryConfig provides sensible defaults for retry operations
Functions ΒΆ
func AdoptErrorID ΒΆ added in v1.1.29
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
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
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
IsException checks if an error matches any of the specified exception patterns
func IsSdkException ΒΆ added in v1.0.29
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
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
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 ΒΆ
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
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 ΒΆ
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
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) GinError ΒΆ
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
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
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 β a driver error, a dependency's message, a connection string.
Below 500 it returns Msg and, when there is one, Cause. A 4xx is about something in the caller's own request, so explaining it is the whole point.
Args are never rendered, at any status. They are operator context β the diagnostic key/values a call site attaches for its log line β and the log keeps them regardless: Log serializes the struct itself (see JSON) and never goes through this method. Rendering them here published internal topology to anonymous callers, because Args routinely carries the URL a request was relayed to:
"url": "https://auth-<project-number>.us-central1.run.app/api/auth/v2/keypair"
and because GinError writes this string into x-error, which the *next* service adopts as its own Cause, one hop's args became permanent text in every message downstream of it β a caller of the gateway was told the internal address of auth-api, and the masked api-key it had rejected.
Nothing a client legitimately needs is lost: GinError promotes the args a consumer acts on into fields of their own (param, code_override, retry, and the status itself), so they are read as structure rather than parsed back out of a rendered blob.
func (*SdkError) Unwrap ΒΆ added in v1.1.29
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.