Silent Error Fallback Anti-Pattern#
Lead Section#
The Silent Error Fallback Anti-Pattern is a software design anti-pattern where functions silently return default or fallback values when encountering errors, instead of propagating the error to the caller. This pattern is particularly problematic in Go, which has explicit error handling as a core language feature.
This anti-pattern was identified and systematically addressed in the opnDossier project through PR #434, which was merged on March 20, 2026. The PR introduced the FormatRegistry pattern to replace scattered format dispatch logic and eliminate three specific instances of silent error fallbacks: StripMarkdownFormatting returning raw markdown on conversion failure, Canonical() returning lowercased input for unknown formats, and file extension determination silently falling back to .md when registry lookup failed.
The fix demonstrates a critical principle in robust software design: functions accepting user-controlled input must return either (T, error) or (T, bool) to allow callers to distinguish success from failure. Silent fallbacks mask bugs, make debugging difficult, and can lead to subtle data corruption as incorrect values propagate silently through the system.
Problem Description#
Core Issue#
The Silent Error Fallback Anti-Pattern violates Go's explicit error handling idiom by:
- Masking failures: Callers cannot distinguish between successful computation and silent fallback behavior
- Propagating incorrect data: Default values that "look valid" flow through the system unchecked
- Complicating debugging: Errors are swallowed at the source, making it difficult to trace the root cause
- Creating implicit contracts: Functions have undocumented "fallback behavior" that callers may unknowingly depend on
Manifestation in opnDossier#
The pattern appeared in three critical areas identified in PR #434:
1. StripMarkdownFormatting Silent Error Swallowing#
Problem: The StripMarkdownFormatting function originally returned only a string. When the goldmark library failed to convert markdown to HTML (stage 1 of the conversion pipeline), the function silently returned the raw markdown string instead of propagating the error. Callers received what appeared to be valid plain text, but it actually contained unstripped markdown syntax.
Impact: Silent conversion failures produced documentation with raw markdown formatting (**bold**, [links](url)) in plain text output, degrading documentation quality without any indication of the error.
2. Canonical() Silent Unknown Format Handling#
Problem: The Canonical() method was used for format alias resolution (e.g., "md" → "markdown"). When given an unrecognized format, it silently returned the lowercased input with no indication that the format was unknown. Callers using Canonical() for validation would pass unregistered format strings further down the pipeline, leading to downstream failures that were difficult to trace back to the original invalid input.
Impact: Invalid formats like "foo" would be accepted and passed through the system until they eventually failed at generation time, with error messages that didn't clearly indicate the root cause was an invalid format at the CLI/config level.
3. File Extension Silent .md Fallback#
Problem: Before the FormatRegistry pattern, file extension determination used switch statements with a default: case that returned .md. When format lookup failed (e.g., for an unknown format string), the code silently generated a .md file instead of reporting the error.
Impact: Users requesting unsupported formats would receive .md files with no error message, making it appear that the tool had succeeded when it had actually failed to honor the requested format.
Solution: Explicit Error Handling#
The FormatRegistry Pattern#
PR #434 resolved Issue #325 by introducing the FormatRegistry pattern, which centralizes format dispatch and eliminates all silent fallback behavior through:
- Explicit error returns: All registry operations return errors for invalid input
- Fail-fast initialization: Registration panics on duplicate/invalid handlers at init time
- Sentinel errors:
ErrUnsupportedFormatprovides type-safe error checking - Single source of truth: One registry eliminates inconsistent format handling across 8+ locations
Implementation Details#
FormatHandler Interface#
The FormatHandler interface defines the contract for all format implementations:
type FormatHandler interface {
FileExtension() string
Aliases() []string
Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error)
GenerateToWriter(g *HybridGenerator, w io.Writer, data *common.CommonDevice, opts Options) error
}
FormatRegistry.Get() - Explicit Error Propagation#
The Get() method returns explicit errors instead of silent fallbacks:
func (r *FormatRegistry) Get(format string) (FormatHandler, error) {
r.mu.RLock()
defer r.mu.RUnlock()
key := strings.TrimSpace(strings.ToLower(format))
if h, ok := r.handlers[key]; ok {
return h, nil
}
if canonical, ok := r.aliases[key]; ok {
return r.handlers[canonical], nil
}
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
}
Key change: Unknown formats return ErrUnsupportedFormat instead of falling back to a default handler.
Canonical() - Explicit Recognition Flag#
The Canonical() method now returns a boolean flag indicating whether the format was recognized:
func (r *FormatRegistry) Canonical(format string) (string, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
key := strings.TrimSpace(strings.ToLower(format))
if _, exists := r.handlers[key]; exists {
return key, true
}
if canonical, exists := r.aliases[key]; exists {
return canonical, true
}
return key, false
}
Key change: Returns (lowercased_input, false) for unknown formats, allowing callers to detect and handle invalid input explicitly via Get().
StripMarkdownFormatting - Error Return Added#
The StripMarkdownFormatting function now returns (string, error):
func StripMarkdownFormatting(markdown string) (string, error) {
// Stage 1: Render markdown to HTML via goldmark
var buf strings.Builder
if err := goldmarkRenderer.Convert([]byte(markdown), &buf); err != nil {
return "", fmt.Errorf("failed to convert markdown to plain text: %w", err)
}
htmlContent := buf.String()
// ... additional processing stages ...
return strings.TrimSpace(text) + "\n", nil
}
Key change: Goldmark conversion errors are explicitly returned rather than swallowing the error and returning raw markdown.
File Extension Determination - Registry Lookup#
File extension determination in cmd/convert.go now uses explicit registry lookup:
// Determine file extension from the registry
handler, err := converter.DefaultRegistry.Get(string(opt.Format))
if err != nil {
ctxLogger.Error("format passed validation but registry lookup failed",
"format", opt.Format, "error", err)
errs <- fmt.Errorf("internal error determining file extension for %q: %w", opt.Format, err)
return
}
fileExt = handler.FileExtension()
Key change: The silent .md fallback is eliminated; Get() errors are propagated to the caller.
Fail-Fast Registration#
The Register() method implements fail-fast validation:
func (r *FormatRegistry) Register(format string, handler FormatHandler) {
if handler == nil {
panic(fmt.Sprintf("converter: nil handler for format %q", format))
}
r.mu.Lock()
defer r.mu.Unlock()
key := strings.TrimSpace(strings.ToLower(format))
if key == "" {
panic("converter: empty format name")
}
if _, exists := r.handlers[key]; exists {
panic(fmt.Sprintf("converter: format %q already registered", key))
}
// ... additional validation and alias checking ...
}
Key change: Configuration errors (duplicate registrations, nil handlers, conflicting aliases) panic at init time rather than silently succeeding or failing at runtime.
Go Best Practices for Error Handling#
The opnDossier fix demonstrates several Go error handling best practices:
1. Return Errors Explicitly#
Functions should return errors rather than silently substituting defaults:
// BAD (Silent Error Fallback Anti-Pattern):
func Canonical(format string) string {
if canonical, ok := r.aliases[format]; ok {
return canonical
}
return strings.ToLower(format) // Silent fallback!
}
// GOOD (Explicit error via Get):
func Get(format string) (FormatHandler, error) {
// ... lookup logic ...
return nil, ErrUnsupportedFormat
}
2. Sentinel Errors for Type-Safe Checking#
Define package-level sentinel errors for common failure modes:
// From internal/converter/errors.go
var (
ErrUnsupportedFormat = errors.New("unsupported format")
ErrNilDevice = errors.New("device configuration is nil")
)
// Callers can use errors.Is() for type-safe checking
if errors.Is(err, converter.ErrUnsupportedFormat) {
// Handle unsupported format specifically
}
3. Wrap Errors with Context#
Use fmt.Errorf with %w to add context while preserving the error chain:
return fmt.Errorf("failed to convert markdown to plain text: %w", err)
4. Fail Fast at Initialization#
Catch configuration errors at program startup via panics in init-time registration:
// Panics if handler is nil, format is duplicate, or aliases conflict
r.Register("markdown", &markdownHandler{})
5. Boolean Returns for Non-Error Failure Cases#
Use (T, bool) returns when the "failure" is not exceptional:
// Canonical returns (resolved_format, was_recognized)
func Canonical(format string) (string, bool)
Related Patterns and Anti-Patterns#
Related Anti-Patterns#
- Scattered Switch Statements: Before PR #434, format dispatch used 8+ switch blocks with
default:cases across the codebase, each a potential site for silent fallbacks - Hardcoded Format Lists: Hardcoded slices like
[]string{"markdown", "json", "yaml"}in multiple places created inconsistency and silent failures when lists diverged - Silent Plugin Load Failures: Fixed in PR #445 (see Dynamic Plugin Loading example)
Correct Patterns Applied#
- Registry-Based Dispatch: Centralized
FormatRegistryas single source of truth, documented in AGENTS.md §5.9b - Fail-Fast Initialization:
database/sql-style panic on registration errors - Error Propagation: Functions return
(T, error)to allow caller-controlled error handling - Explicit Validation: Input validation delegates to authoritative registry methods
Usage Examples#
Before: Silent Error Fallback (Anti-Pattern)#
// Old code with silent fallback
extension := getFileExtension(format) // Returns ".md" for unknown formats
outputPath := fmt.Sprintf("%s%s", baseName, extension)
// User gets .md file for "foo" format with no error!
After: Explicit Error Propagation (Correct Pattern)#
// New code with explicit error handling
handler, err := converter.DefaultRegistry.Get(string(opt.Format))
if err != nil {
return fmt.Errorf("unsupported format %q: %w", opt.Format, err)
}
fileExt := handler.FileExtension()
outputPath := fmt.Sprintf("%s%s", baseName, fileExt)
// User gets clear error message for "foo" format
Adding a New Format#
With the FormatRegistry pattern, adding a new format requires only implementing the interface and registering:
// 1. Implement FormatHandler
type csvHandler struct{}
func (h *csvHandler) FileExtension() string { return ".csv" }
func (h *csvHandler) Aliases() []string { return []string{} }
func (h *csvHandler) Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error) {
// CSV generation logic
}
func (h *csvHandler) GenerateToWriter(g *HybridGenerator, w io.Writer, data *common.CommonDevice, opts Options) error {
// CSV streaming logic
}
// 2. Register in newDefaultRegistry()
func newDefaultRegistry() *FormatRegistry {
r := NewFormatRegistry()
// ... existing registrations ...
r.Register("csv", &csvHandler{})
return r
}
No switch statements to update, no hardcoded lists to maintain — the registry handles all dispatch, validation, and error reporting automatically.
Dynamic Plugin Loading Error Collection#
PR #445 resolved Issue #311 by fixing the Silent Error Fallback Anti-Pattern in dynamic plugin loading. Before this fix, LoadDynamicPlugins logged all plugin load failures but always returned nil, giving callers no indication that plugins had failed to load.
Before: Silent Plugin Load Failures (Anti-Pattern)#
// Old implementation: logs errors but always returns nil
func (pr *PluginRegistry) LoadDynamicPlugins(ctx context.Context, dir string, logger *logging.Logger) error {
entries, err := os.ReadDir(dir)
// ... directory check ...
for _, entry := range entries {
// ... plugin loading logic ...
if err := pr.pluginLoader(path); err != nil {
logger.Error("Failed to load plugin", "file", path, "error", err)
continue // Silent failure — caller never knows!
}
// ... registration ...
}
return nil // Always succeeds, even if all plugins failed!
}
// Caller has no way to detect failures
if err := registry.LoadDynamicPlugins(ctx, dir, logger); err != nil {
// This branch never executes for per-plugin failures
}
Problem: Plugin load failures were logged but not returned to the caller. CLI users received no indication that their custom compliance plugins failed to load, leading to incomplete audit reports with no warning.
After: Explicit Error Collection and Propagation (Correct Pattern)#
The fix introduces three key changes:
1. LoadResult Type for Structured Failure Reporting
// From internal/audit/plugin.go
type PluginLoadError struct {
Name string
Err error
}
func (f PluginLoadError) Error() string {
return fmt.Sprintf("plugin %s: %v", f.Name, f.Err)
}
type LoadResult struct {
Loaded int
Failures []PluginLoadError
}
func (r LoadResult) Failed() int {
return len(r.Failures)
}
2. LoadDynamicPlugins Returns (LoadResult, error)
// New implementation: collects failures and returns aggregate error
func (pr *PluginRegistry) LoadDynamicPlugins(
ctx context.Context,
dir string,
explicitDir bool,
logger *logging.Logger,
) (LoadResult, error) {
var (
loaded int
failures []PluginLoadError
)
for _, entry := range entries {
// ... plugin loading logic ...
compliancePlugin, err := pr.pluginLoader(path)
if err != nil {
logger.Error("Failed to load plugin", "file", path, "error", err)
failures = append(failures, PluginLoadError{Name: entry.Name(), Err: err})
continue // Failure recorded in LoadResult
}
// ... registration logic with failure recording ...
loaded++
}
// Aggregate individual failures into a single error via errors.Join
var aggregateErr error
if len(failures) > 0 {
errs := make([]error, len(failures))
for i := range failures {
errs[i] = failures[i]
}
aggregateErr = errors.Join(errs...)
}
return LoadResult{Loaded: loaded, Failures: failures}, aggregateErr
}
Key changes:
- Returns
LoadResultwith per-plugin failure details alongside aggregate error - Callers can inspect
LoadResult.Failuresfor detailed failure information errors.Joinaggregates individualPluginLoadErrorinstances for error chain compatibility
3. CLI Integration Surfaces Failures to Users
// From cmd/audit_handler.go
if err := pm.InitializePlugins(ctx); err != nil {
return "", fmt.Errorf("initialize plugins: %w", err)
}
// Surface any dynamic plugin load failures to the CLI user
if loadResult := pm.GetLoadResult(); loadResult.Failed() > 0 {
failedNames := make([]string, len(loadResult.Failures))
for i, f := range loadResult.Failures {
failedNames[i] = f.Name
}
logger.Warn("Some dynamic plugins failed to load",
"failed", loadResult.Failed(),
"loaded", loadResult.Loaded,
"files", strings.Join(failedNames, ", "),
)
}
Key changes:
PluginManager.GetLoadResult()exposes plugin load results to CLI layer- Warning log with failed plugin filenames alerts users to incomplete plugin loading
- Non-fatal failures allow audit to proceed with available plugins
Fail-Fast vs. Silent Skip Behavior#
The explicitDir parameter distinguishes user-specified paths (fail-fast) from optional defaults (silent skip):
// Missing directory with explicitDir=false (default path) → silent skip
result, err := LoadDynamicPlugins(ctx, "/opt/plugins", false, logger)
// err == nil, result.Loaded == 0, Debug log only
// Missing directory with explicitDir=true (--plugin-dir flag) → error
result, err := LoadDynamicPlugins(ctx, "/opt/plugins", true, logger)
// err != nil: "plugin directory \"/opt/plugins\" does not exist"
This ensures that explicit --plugin-dir flag usage fails fast if the directory is missing, while default/optional paths are silently skipped.
Test Coverage via Dependency Injection#
PR #445 introduces pluginLoaderFunc injection to enable testing without real .so files:
// Production loader: opens .so files
func defaultPluginLoader(path string) (compliance.Plugin, error) {
p, err := pluginlib.Open(path)
// ... symbol lookup and type assertion ...
}
// Test loader: returns canned plugins or errors
testLoader := func(path string) (compliance.Plugin, error) {
if strings.Contains(path, "fail") {
return nil, errors.New("mock load failure")
}
return &mockPlugin{}, nil
}
registry := newPluginRegistryWithLoader(testLoader)
result, err := registry.LoadDynamicPlugins(ctx, dir, false, logger)
// Deterministic test behavior without .so files
This demonstrates the Dependency Injection pattern enabling testability of error handling logic.
Test Coverage#
FormatRegistry (PR #434)#
PR #434 includes comprehensive test coverage:
- Registry tests: 76 test cases in
registry_test.gocovering:Get()with valid/invalid formatsCanonical()with canonical names, aliases, and unknown formatsRegister()panic conditions (nil handler, duplicate format, conflicting aliases)ValidFormats(),Extensions(), andDefaultRegistrycontent validation- Handler dispatch correctness
- Processor tests: 12 new tests for text/html format support and alias resolution (
md,yml,txt,htm) - Integration tests: CLI and converter integration tests verify end-to-end error propagation
All tests pass with 100% coverage of the registry module.
Dynamic Plugin Loading (PR #445)#
PR #445 includes comprehensive test coverage:
- Plugin registry tests in
plugin_global_test.go:- Nonexistent directory with
explicitDir=falsesilently skips (Debug log) - Nonexistent directory with
explicitDir=truereturns error - Empty directory loads nothing (zero Loaded, zero Failed)
- Non-
.sofiles are ignored - All
.sofiles failing returns aggregate error with correct counts - Partial failure records both loaded and failed plugins correctly
- Registration failure (duplicate name) recorded in failures
- Nil plugin from loader treated as failure (not panic)
- Nil logger returns immediate error
PluginLoadError.Error()formats filename and underlying error
- Nonexistent directory with
- Plugin manager tests:
PluginManager.SetPluginDir,GetLoadResult, andInitializePluginsintegration - CLI integration tests in
audit_handler_test.go:--plugin-dirflag wiring and failure surfacing via warning logs
All tests use dependency injection (newPluginRegistryWithLoader) to avoid requiring real .so files.
Relevant Code Files#
| File | Description | Link |
|---|---|---|
internal/converter/registry.go | FormatRegistry implementation, FormatHandler interface, handler implementations | View |
internal/converter/registry_test.go | 76 test cases for registry behavior | View |
internal/converter/errors.go | Sentinel error definitions (ErrUnsupportedFormat, ErrNilDevice) | View |
internal/converter/plaintext.go | StripMarkdownFormatting with explicit error return | View |
cmd/convert.go | File extension determination using registry with error handling | View |
internal/audit/plugin.go | PluginRegistry.LoadDynamicPlugins, LoadResult, PluginLoadError | View |
internal/audit/plugin_global_test.go | Plugin loading test cases including error collection | View |
internal/audit/plugin_manager.go | PluginManager.SetPluginDir, GetLoadResult, InitializePlugins | View |
cmd/audit_handler.go | CLI warning integration for plugin load failures | View |
AGENTS.md §5.9b | FormatRegistry pattern documentation | View |
Related Topics#
- Error Handling in Go: Go's explicit error handling philosophy and best practices
- Registry Pattern: Centralized registration and lookup for pluggable implementations
- Fail-Fast Design: Catching configuration errors at initialization rather than runtime
- Software Anti-Patterns: Common design mistakes that lead to maintenance problems
- Code Consolidation: Replacing scattered logic with centralized abstractions
- Defensive Programming: Input validation and explicit error checking
References#
- PR #434: refactor(converter): introduce FormatRegistry to centralize format dispatch — Merged March 20, 2026
- Issue #325: Add FormatRegistry to replace scattered format routing — Resolved by PR #434
- PR #445: feat(plugin): surface dynamic plugin load failures to user — Merged March 21, 2026
- Issue #311: Surface dynamic plugin load failures — Resolved by PR #445
- PR #167: fix: replace panic based error handling in production code — Related error handling improvement
- Refactoring Patterns - Registry-Based Dispatch Pattern — Full pattern documentation
- Architecture Review Findings - Converter Pattern Consolidation — Notes on PR #434 resolution
- AGENTS.md §5.9b — Implementation guidance for FormatRegistry pattern
Last Updated: March 21, 2026 (based on PR #445 merge date)
Status: Multiple instances resolved in opnDossier (PR #434, PR #445); documented as anti-pattern for future reference