Problem
Errors across internal/ are constructed almost entirely by fmt.Errorf string interpolation, with the contextual detail baked into the message text:
fmt.Errorf("unknown column %q for table %q", col, table) // internal/discovery
fmt.Errorf("type mismatch for column %q: cannot store %T in %s", ...)
fmt.Errorf("null value for non-nullable column %q", col)
fmt.Errorf("table %q, op %q, role %q: max_rows must be non-negative", ...) // internal/policy
fmt.Errorf("unknown column: %s", col) // internal/query
The messages themselves are good — they are precise and they name exactly what the operator needs. The problem is that the cause is only recoverable by parsing English. That costs us in four places:
1. The SDK error surface is coarse. clients/ts/src/errors.ts builds code: \HTTP_${res.status}`, so every validation failure is HTTP_400` regardless of whether it was an unknown column, a type mismatch, a null violation, or a malformed timestamp. An SDK consumer who wants to branch — retry with a corrected payload vs. surface to the user vs. re-run codegen because the schema drifted — has nothing to branch on except substring-matching a human-readable string that we do not treat as API surface.
2. Error-rate metrics can't be keyed by cause. #94 calls for error-rate counters. Without a stable classification the only options are a single undifferentiated counter or regexing messages into labels — and regexing an interpolated message into a metric label is also how an unbounded identifier ends up as a label value, which is the failure #417 and the per-table cardinality work are trying to avoid elsewhere.
3. Tests match on message substrings. That is fragile in the ordinary way — a wording improvement silently breaks or, worse, silently weakens a test — and it makes error messages effectively frozen despite not being documented as API.
4. Support and triage. Users report symptoms rather than a code, so issues start with a round-trip to establish which of four similar-sounding validation failures actually fired.
Proposed Solution
Introduce a closed set of namespaced error kinds and typed error values carrying them, without changing a single operator-facing message.
type Kind string
const (
KindUnknownColumn Kind = "discovery.unknown_column"
KindTypeMismatch Kind = "discovery.type_mismatch"
KindNullViolation Kind = "discovery.null_violation"
KindColumnDenied Kind = "query.column_denied"
// ...
)
type ValidationError struct {
Kind Kind
Table, Column string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("unknown column %q for table %q", e.Column, e.Table) // unchanged
}
Error() renders exactly what it renders today. No log output, no API message text, and no operator-visible string changes.
- Boundaries use
errors.Is / errors.As instead of substring matching.
- API error responses gain an additive
code field alongside the existing error / message — additive, so no wire break.
- The SDK maps that into
WaveHouseError.code in place of the HTTP_<status> placeholder, and the codes get documented in the SDK reference so they are real API surface with a compatibility promise.
Scope edge, deliberately: cover the error paths that reach an API response or a metric — discovery validation, query build and column authz, policy validation, ingest. Not every fmt.Errorf in the tree. Roughly 30–40 kinds, not several hundred.
Additional Context
Unblocks the error-rate-counter half of #94 (a stable, closed-set label to key on) and gives the SDK something real to put in code.
Two compatibility notes for whoever picks this up:
- Adding
code to error response bodies is additive; existing consumers reading error / message are unaffected.
- Once documented, the kind strings become API surface subject to the same compatibility expectations as any other field — which is the point, but it means the initial naming pass deserves care. Namespacing by package (
discovery., query., policy.) keeps them extendable without collision.
Problem
Errors across
internal/are constructed almost entirely byfmt.Errorfstring interpolation, with the contextual detail baked into the message text:The messages themselves are good — they are precise and they name exactly what the operator needs. The problem is that the cause is only recoverable by parsing English. That costs us in four places:
1. The SDK error surface is coarse.
clients/ts/src/errors.tsbuildscode: \HTTP_${res.status}`, so every validation failure isHTTP_400` regardless of whether it was an unknown column, a type mismatch, a null violation, or a malformed timestamp. An SDK consumer who wants to branch — retry with a corrected payload vs. surface to the user vs. re-run codegen because the schema drifted — has nothing to branch on except substring-matching a human-readable string that we do not treat as API surface.2. Error-rate metrics can't be keyed by cause. #94 calls for error-rate counters. Without a stable classification the only options are a single undifferentiated counter or regexing messages into labels — and regexing an interpolated message into a metric label is also how an unbounded identifier ends up as a label value, which is the failure #417 and the per-table cardinality work are trying to avoid elsewhere.
3. Tests match on message substrings. That is fragile in the ordinary way — a wording improvement silently breaks or, worse, silently weakens a test — and it makes error messages effectively frozen despite not being documented as API.
4. Support and triage. Users report symptoms rather than a code, so issues start with a round-trip to establish which of four similar-sounding validation failures actually fired.
Proposed Solution
Introduce a closed set of namespaced error kinds and typed error values carrying them, without changing a single operator-facing message.
Error()renders exactly what it renders today. No log output, no API message text, and no operator-visible string changes.errors.Is/errors.Asinstead of substring matching.codefield alongside the existingerror/message— additive, so no wire break.WaveHouseError.codein place of theHTTP_<status>placeholder, and the codes get documented in the SDK reference so they are real API surface with a compatibility promise.Scope edge, deliberately: cover the error paths that reach an API response or a metric — discovery validation, query build and column authz, policy validation, ingest. Not every
fmt.Errorfin the tree. Roughly 30–40 kinds, not several hundred.Additional Context
Unblocks the error-rate-counter half of #94 (a stable, closed-set label to key on) and gives the SDK something real to put in
code.Two compatibility notes for whoever picks this up:
codeto error response bodies is additive; existing consumers readingerror/messageare unaffected.discovery.,query.,policy.) keeps them extendable without collision.