Skip to content

refactor(errors): typed error kinds for stable machine-readable codes #539

Description

@EricAndrechek

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiHTTP handlers, routing, middlewarearea/sdkTypeScript SDK (clients/ts/)enhancementNew feature or request

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions