Documents
Silent Error Fallback Anti-Pattern
Silent Error Fallback Anti-Pattern
Type
Topic
Status
Published
Created
Mar 20, 2026
Updated
Mar 21, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

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:

  1. Masking failures: Callers cannot distinguish between successful computation and silent fallback behavior
  2. Propagating incorrect data: Default values that "look valid" flow through the system unchecked
  3. Complicating debugging: Errors are swallowed at the source, making it difficult to trace the root cause
  4. 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:

  1. Explicit error returns: All registry operations return errors for invalid input
  2. Fail-fast initialization: Registration panics on duplicate/invalid handlers at init time
  3. Sentinel errors: ErrUnsupportedFormat provides type-safe error checking
  4. 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)
  • 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 FormatRegistry as 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 LoadResult with per-plugin failure details alongside aggregate error
  • Callers can inspect LoadResult.Failures for detailed failure information
  • errors.Join aggregates individual PluginLoadError instances 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.go covering:
    • Get() with valid/invalid formats
    • Canonical() with canonical names, aliases, and unknown formats
    • Register() panic conditions (nil handler, duplicate format, conflicting aliases)
    • ValidFormats(), Extensions(), and DefaultRegistry content 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=false silently skips (Debug log)
    • Nonexistent directory with explicitDir=true returns error
    • Empty directory loads nothing (zero Loaded, zero Failed)
    • Non-.so files are ignored
    • All .so files 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
  • Plugin manager tests: PluginManager.SetPluginDir, GetLoadResult, and InitializePlugins integration
  • CLI integration tests in audit_handler_test.go: --plugin-dir flag wiring and failure surfacing via warning logs

All tests use dependency injection (newPluginRegistryWithLoader) to avoid requiring real .so files.

Relevant Code Files#

FileDescriptionLink
internal/converter/registry.goFormatRegistry implementation, FormatHandler interface, handler implementationsView
internal/converter/registry_test.go76 test cases for registry behaviorView
internal/converter/errors.goSentinel error definitions (ErrUnsupportedFormat, ErrNilDevice)View
internal/converter/plaintext.goStripMarkdownFormatting with explicit error returnView
cmd/convert.goFile extension determination using registry with error handlingView
internal/audit/plugin.goPluginRegistry.LoadDynamicPlugins, LoadResult, PluginLoadErrorView
internal/audit/plugin_global_test.goPlugin loading test cases including error collectionView
internal/audit/plugin_manager.goPluginManager.SetPluginDir, GetLoadResult, InitializePluginsView
cmd/audit_handler.goCLI warning integration for plugin load failuresView
AGENTS.md §5.9bFormatRegistry pattern documentationView
  • 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#


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