Skip to content

When a Generic Error Swallows the Validation Message

Adityo Guni Waluyo

The service composed a precise validation message; the handler answered generically. A bridge pattern forwards audited static detail, locked by a regression test.

TL;DR

A shared respondErr helper collapsed a precise validation message into one generic string, leaving an admin guessing. The fix forwards static business-rule detail after auditing every wrap site for user input, while dynamic detail stays in logs. A regression test locks the message so it cannot silently vanish again.

Two Messages for One Failure

An admin wizard saved one field. The screen answered with one line: "invalid type or status". The field was neither a type nor a status. It held a receipt number, 140 characters long, while the service cap was 100.

First guess: the length validation was missing at the service layer. Inspection said otherwise. The service detected the violation and composed a precise message, "receipt_number is limited to 100 characters". That message reached the handler, then disappeared. A shared respondErr helper maps ErrInvalidInput to one generic string for every case, and the precise detail was swallowed with it.

Why the Generic Message Is Deliberate

Swallowing detail is not an accident but a deliberate, common security policy. OWASP places error handling inside application security: leaked errors assist attacker reconnaissance[2] and can reveal injection points[2]. Responses to clients should stay generic; full detail belongs in server logs.

The principle is right for detail that comes from the outside world: user input content, stack traces, table names. But a validation message like "limited to 100 characters" is not a secret. It is a business rule the user must know to fix their input. Applying the generic policy blindly leaves an administrator guessing across a many-field wizard.

Here is the Go side of it. Wrapping one error inside another has been an official part of the language since Go 1.13[1]. The catch: wrapping through fmt.Errorf with a plain verb discards everything from the original error except its text[1]. The ErrInvalidInput sentinel stays detectable, but the precise detail lives only as a string in front of it. This codebase chose that convention, so the handler needs one extra step to forward the text.

The Bridge Pattern: Forward Only What Was Audited

if errors.Is(err, ErrInvalidInput) {
    // static detail, audited across every wrap site: constants only, no input echo
    detail := strings.TrimSuffix(err.Error(), ": "+ErrInvalidInput.Error())
    if detail != ErrInvalidInput.Error() {
        response.Error(w, http.StatusBadRequest, "INVALID_INPUT", detail)
        return
    }
}
respondErr(w, err)

The pattern is safe under one expensive condition: an audit. Every wrap site must be checked so the detail is a constant, never a slice of user input. Audit once when writing the handler, then lock it with a regression test. The test name in this commit is long and specific on purpose: TestPutOfficerFieldsOverCapShowsPreciseMessage. If someone later changes respondErr or the wrapping convention and the precise message disappears again, that test fails before any user can complain.

What This Case Leaves Behind

The choice between secure and clear is often framed as either-or. This case points to a third option: classify the message. Dynamic detail derived from user input goes to logs. Static detail that states a business rule gets forwarded, after an audit. Each half is protected by a different mechanism, logs for the first, a regression test for the second.

One line like "receipt_number is limited to 100 characters" costs fewer bytes than the generic message it replaces. Clarity, it turns out, was never expensive. Confusion is.

Sources

  1. Working with Errors in Go 1.13 - The Go Programming Language (accessed 2026-10-12).
  2. Error Handling - OWASP Cheat Sheet Series (accessed 2026-10-12).

Related articles