Skip to content

fix(api): map ClickHouse query failures by class, not HTTP status - #627

Merged
EricAndrechek merged 20 commits into
mainfrom
fix/query-error-classes
Sep 26, 2026
Merged

EricAndrechek merged 20 commits into
mainfrom
fix/query-error-classes

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Fixes #403. Fixes #271. Part of #613 (workstream A2). Stacked on #619 (feat/ch-error-classes), which adds the classifier this uses.

What changes

ClickHouse answers a syntax error, a missing grant and an overloaded server alike with HTTP 500. Before this PR:

All the query paths now class the failure with chconn.Classify through one helper, writeCHError (internal/api/ch_errors.go), so they cannot drift apart again:

Class Status code retryable
Rejected (syntax, unknown table/column/identifier, type mismatch, any unlisted code) 400 clickhouse.rejected false
Rows/bytes limits (158, 307, 396); the role's own time cap (159/160) or memory cap (241) on /v1/query 400 clickhouse.limit_exceeded false
Denied: ACCESS_DENIED (497) 403 clickhouse.access_denied false
Denied: anything else (credentials, user, database, a proxy's codeless 401/403) 502 clickhouse.misconfigured false
Unavailable 503 + Retry-After: 5 clickhouse.unavailable true
Unknown 500 (native) / 502 (proxy) clickhouse.unknown true

Also in this PR:

  • Error envelope. The existing {"error": …} envelope (internal/api/errors.go) gains code and retryable on these responses. It is additive: no new envelope, and the code names are namespaced as refactor(errors): typed error kinds for stable machine-readable codes #539 proposes.
  • SDK. clients/ts/src/errors.ts takes the server's code and retryable when present, and falls back to HTTP_<status> and "5xx retries" otherwise. There is no clients/go on this base.
  • Other paths.
    • The raw-SQL proxy's 64 MiB overflow stays 502 but is now retryable: false, since the same query overflows again.
    • POST /v1/ops/schema/refresh against an unreachable ClickHouse answers 503 + Retry-After instead of 500.
  • Role time cap. A capped query now runs with no context deadline, and WaveHouse cancels it two seconds past the cap. Why: clickhouse-go v2.48.0 (context.go:238-241) overwrites max_execution_time with deadline+5s for any deadline over 1s. As it was, a cap overrun came back as a bare DeadlineExceeded, which looks the same as a wait for a pooled connection or a dial timeout, both of which are outages. Now ClickHouse enforces the cap itself and reports TIMEOUT_EXCEEDED. Anything else, including the backstop cancel, is 503. TestQueryErrors_TimeCapReachesClickHouse pins both sides of that driver behaviour against a real ClickHouse.

Why ACCESS_DENIED is a 403, and credential failures a 502

The query paths run as the ClickHouse user in the tenant's settings, not as the caller. So ACCESS_DENIED is WaveHouse's configuration in one sense. It is still a verdict on this statement:

  • ClickHouse understood the statement and refused it;
  • the same statement is refused every time;
  • other statements from the same caller succeed.

That is a 403, and it matches #403's repro exactly: a CREATE USER through /v1/ops/query with a user that lacks the grant. The integration test runs that statement against the test container, and the answer is 403 clickhouse.access_denied. A 5xx would tell clients and monitors that ClickHouse is down, and would invite retries of a request that can never pass. The operator still hears about it, because writeCHError logs every denial at WARN.

Denials that refuse every query rather than one statement are different: a wrong password, an unknown or expired user, DATABASE_ACCESS_DENIED, or a proxy's 401/403. Those are an operator fix the caller cannot act on, so they are 502 clickhouse.misconfigured. They are not retryable, because a retry a few seconds later changes nothing.

Deliberately left for later

Tests

  • Unit, internal/api/ch_errors_test.go (new): a table of real clickhouse-go errors, run through both /v1/query and pipes:

    • *clickhouse.Exception codes 47/53/60/62/158/159/202/241/497/516;
    • a real refused dial;
    • clickhouse.ErrAcquireConnTimeout;
    • pool-wait and backstop-cancel cases under a time cap;
    • an error with no verdict.

    It also pins that a time-capped query context carries no deadline, and covers the schema-refresh outage.

  • Unit, query_test.go: the proxy table covers header codes and body-only codes, a proxy with no code, the query API maps ClickHouse caller-side errors (e.g. ACCESS_DENIED) to HTTP 502 instead of 4xx #403 repro, the credential cases, and a real refused connection.

  • Integration, tests/integration/query_errors_test.go (new):

    • Against the shared live ClickHouse:
      • raw SQL SELEC 1 → 400 clickhouse.rejected, not retryable;
      • CREATE USER → 403 clickhouse.access_denied;
      • a structured query on a column dropped behind the registry → 400 (code 47).
    • Against its own container and app: stopping ClickHouse makes both /v1/ops/query and /v1/query answer 503 clickhouse.unavailable, retryable: true, Retry-After: 5.
    • The driver-contract test described under "Role time cap".
  • Updated for the new contract:

    • query_limits_test.go: the role caps are 400 limit_exceeded, not 500;
    • app_test.go: a verified token is told apart by the ClickHouse error body, because a closed ClickHouse is now 503;
    • tenant_clickhouse_test.go;
    • e2e tests/e2e/sdk/query.test.ts (role caps → 400) and admin.test.ts (syntax error through wh.sql → 400, clickhouse.rejected, not retried).
  • SDK: errors.test.ts and http.test.ts, including a 502 with retryable: false that is not retried.

  • make ci: passed at f485505c: unit, integration and e2e, all coverage gates.

Review

The pre-push-reviewer and docs-reviewer (opus) ran in fresh context against the delta vs origin/feat/ch-error-classes.

  • Round 1 (01ea6902):
    • Code: iterate, one MUST. A bare DeadlineExceeded under a role time cap was read as the cap, so a pool wait or dial timeout became a non-retryable 400. There was also a stale AGENTS.md line.
    • Docs: iterate, five SHOULDs: configuration.mdx limits, api.md cap wording, access-control naming the three caps, the SDK table missing clickhouse.unknown, and the maxRetries wording.
    • Fixed in 4ff30f31. The reviewer's suggested fix (slack on the deadline) would have been overridden by the driver, so the context now carries no deadline instead.
  • Round 2 (4ff30f31): both ship_it.
  • Rounds 3–5 (148a160b, 3a314afe, f485505c, test and CHANGELOG only, after make ci caught stale 503/500 expectations in app_test.go and query_limits_test.go):
    • Code: iterate once, because app_test.go:908 could no longer fail. Then ship_it at f485505c, after a full sweep of the test files.
    • Docs reviewer: not re-run for these commits; they touch no docs prose beyond the CHANGELOG file list.

Review markers (#454): in a wt worktree, the SubagentStop hook writes markers into the main checkout's tmp/, keyed to its HEAD. So no tmp/<reviewer>-passed-f485505c… exists for this branch. The verdicts above are the record. No marker was hand-written or skipped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL

EricAndrechek and others added 9 commits September 24, 2026 23:43
A failed batch insert went through row-by-row isolation whatever the
failure, so a down, overloaded or read-only ClickHouse parked every row
on the DLQ. chconn.Classify now classes the failure first: only a row
ClickHouse rejects is isolated and dead-lettered; an unavailable, denied
or unjudged failure is handed back with a delayed nak under a per-pool
backoff, including when ClickHouse goes away mid-isolation.

Part of #613.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 1. A read-only table (or one with too many parts or
mutations) tripped the whole pool's breaker, and a healthy neighbour's
success reopened it on every flush — the backoff never escalated and
the logs flapped. chconn.TableScoped now routes those codes to a
per-(pool, table) backoff. While a probe is out, arriving rows are
handed back with a floored delay instead of cycling through the worker.
Docs: the query handlers do not use Classify yet; list NakWithDelay in
the mq surface; complete the Denied list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 2. ACCESS_DENIED is usually a grant missing on one table,
so it joins the table-scoped codes instead of flapping the pool's
backoff. Docs: the ClickHouse password is WH_CH_PASSWORD (boot config),
not config.json; the pool backoff is per URL, user and database, not
per server; the backoffs map comment states its real bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 3: the README feature list, the landing page, the why page
and the architecture diagram still said failed inserts go to the DLQ;
an outage is now retried with backoff instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ClickHouse answers a syntax error, a missing grant and an overloaded
server alike with HTTP 500, so /v1/ops/query turned a bad statement into
a 502 and /v1/query and pipes into a 500 the SDK retried. All three now
class the failure with chconn.Classify through one helper, writeCHError:
rejected 400, limit exceeded 400, ACCESS_DENIED 403, refused credentials
502, outage 503 with Retry-After, no verdict 500/502. The error envelope
gains code and retryable, and the SDK takes them when present.

Fixes #403. Fixes #271. Part of #613.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
A bare DeadlineExceeded under a capped role was read as the cap, so a pool
wait or dial timeout answered 400 limit_exceeded. clickhouse-go overwrites
max_execution_time with deadline+5s for any deadline over 1s, so a capped
query now runs with no deadline and a cancel 2s past the cap: ClickHouse
reports the overrun as TIMEOUT_EXCEEDED, and anything else is 503.
Docs: review findings (limits in configuration, unknown code in the SDK
table, maxRetries wording, AGENTS.md).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
A pipe against a closed ClickHouse now answers 503 clickhouse.unavailable,
the same status as a verifier still fetching its key set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
A pipe against a closed ClickHouse now answers 503 clickhouse.unavailable,
the same status as a verifier still fetching its key set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 86ab3474-95bd-482a-aedc-39f955dd9b1a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/api HTTP handlers, routing, middleware area/sdk TypeScript SDK (clients/ts/) area/docs Documentation, site/, README area/app Process wiring (internal/app): component build, run, release labels Sep 25, 2026
EricAndrechek and others added 4 commits September 25, 2026 11:18
Absorbs #612 (2a2b886), which gave every tenant a queue of its own.
Doc conflicts resolved onto #612's per-tenant wording; the outage test
opens its tenant's queue with SetMaxBytes and names the tenant in
DeadLetterCounts, and the outage docs scope the ack floor, maxAckPending
and the byte budget to the tenant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
waiting takes the target lazily, so while no backoff is open a row costs
one atomic load; lookups of an existing key take a read lock, since a
table that fails and is never written again keeps the open count up.
Docs: the HTTP status does not tell a rejected row from an outage (400/404
vs 500); the drain step names the retry metric and the outage WARN; the
changelog lists every doc the entry touches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
…query-error-classes

Brings in the parent's merge of main (#612) and its review fixes.
Conflicts: AGENTS.md (this PR's api/ line beside main's app/ line) and
CHANGELOG.md (this PR's entry beside the parent's updated one).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
EricAndrechek and others added 2 commits September 25, 2026 21:24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…query-error-classes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Base automatically changed from feat/ch-error-classes to main September 26, 2026 05:50
@github-actions github-actions Bot added the area/ingest Ingest pipeline (Bento, batching, DLQ) label Sep 26, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot removed the area/ingest Ingest pipeline (Bento, batching, DLQ) label Sep 26, 2026
@EricAndrechek
EricAndrechek marked this pull request as ready for review September 26, 2026 05:59
@EricAndrechek
EricAndrechek requested review from a team and taitelee September 26, 2026 05:59
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

📚 Docs preview is live → https://613f7337-wavehouse-docs.wave-rf.workers.dev

  • Commit — ad52e08: Merge remote-tracking branch 'origin/main' into fix/query-error-classes
  • Author — @EricAndrechek
  • Committed — 2026-09-26 02:40 (UTC-04:00)
  • Deployed — 2026-09-26 02:59 EDT

@github-code-quality

github-code-quality Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall line coverage in commit ad52e08 in the fix/query-error-clas... branch remains at 94%, unchanged from commit a1774fb in the main branch.

Show a line coverage summary of the most impacted files.
File main a1774fb fix/query-error-clas... ad52e08 +/-
internal/api/query.go 90% 89% -1%
internal/api/st...ctured_query.go 98% 98% 0%
internal/api/errors.go 100% 100% 0%
internal/api/schema.go 93% 96% +3%
internal/api/pipes.go 91% 96% +5%
internal/api/ch_errors.go 0% 100% +100%

Updated September 26, 2026 06:59 UTC

EricAndrechek and others added 4 commits September 26, 2026 02:22
- A redirect or 4xx with no ClickHouse exception code (a wrong path, a
  redirect the proxy does not follow) is 502 clickhouse.misconfigured,
  not retryable, instead of clickhouse.unknown, retryable: it answers
  the same on every retry. Covers the raw-SQL proxy and the driver's
  HTTP errors; chconn.HTTPStatus is exported for it.
- On /v1/query, TIMEOUT_EXCEEDED is read as the role's time cap (400
  clickhouse.limit_exceeded) only when the cap is no longer than the
  tenant's query_timeout. Under a shorter query_timeout it answers as
  for a role with no cap (503), consistent with #620.
- structured_query: choose the query context with if/else instead of
  creating a timeout context and cancelling it straight away.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAxVTtLCcxxMQb2k9SjgV
- tests/integration/query_errors_test.go: the hand-built config names
  every layer's backend, which app.New requires since #618 (merged into
  this branch); without it TestQueryErrors_ClickHouseDown fails on the
  merge ref.
- docs: a codeless 408/429 (503) and 413 (400 rejected) keep their own
  classes, so "any other codeless 3xx/4xx is misconfigured" now says so
  in api.md and the SDK reference; access-control.mdx and the api.md
  summary row state the time cap counts only when no longer than
  query_timeout; configuration.mdx notes the role memory-cap exception;
  settings-directory.mdx describes how query_timeout reaches ClickHouse
  under a role time cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAxVTtLCcxxMQb2k9SjgV
@EricAndrechek
EricAndrechek merged commit d7420f8 into main Sep 26, 2026
19 checks passed
@EricAndrechek
EricAndrechek deleted the fix/query-error-classes branch September 26, 2026 07:01
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
#627 added a hand-built Config after this branch made an empty
roles refuse boot, as setup_test.go and tenants_test.go already do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GW5hTHhJoY3t4dbeoqkEGQ
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
main now carries #612 and #618 as squashes, plus #632, #622, #627,
#615, #619, #647, #616, #655 and #623. The merge was resolved against the
pre-squash #618 head (f129d57) as its base, so main's version wins for
everything this stack does not own and only the cache stack's changes
(#614, #621, #626 as merged here, and this PR) are re-applied on top.

Warnings keeps main's api-role gate for the cache.redis warnings too: a
split's Deployments differ only in roles, so the API's cover the others'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Absorb feat/dedupe-windowed-ingest's newer history, including its own
merge of feat/dedupe-reserve (step 1 of this pass) and origin/main
(#615 leases, #618 backend selection, #622 process roles, #627
ClickHouse error classing, and the rest through #623).

Resolved six conflicts, combining both sides' facts rather than
picking one:

- internal/settings/settings.go: kept MinDedupeRetention (this PR's
  2-minute floor, tied to the embedded queue's duplicate window) next
  to windowed-ingest's reworded DLQConfig comment ("a row ClickHouse
  still rejects", reflecting the outage-retry split from #613).
- AGENTS.md: kept this PR's dedupe/ package-inventory line (the sweep
  detail) alongside windowed-ingest's updated config/ and new coord/
  lines pulled in from main.
- CHANGELOG.md, docs/architecture.md, docs/durability.md,
  docs/settings-directory.mdx: superseded this branch's now-stale
  copies (old `evt%2D123` key spelling from before refactor/keyenc
  kept '-'; the metric name `wavehouse_dedupe_commit_failed_total`
  before it gained the `ingest_` prefix; the simpler "fits inside"
  duplicate-window wording before the `2×lease+1s` invariant was
  pinned) with windowed-ingest's current, code-matching text, then
  folded this PR's retention-specific additions back in: the
  Upgrade note now says the pre-#222 keys are "deleted by the
  retention sweep" instead of "nothing removing them yet", and
  durability.md's duplicate-window paragraph keeps its closing
  sentence tying `dedupe.retention`'s 2-minute floor to that same
  window.

internal/api/ingest.go, ingest_test.go, ingest_window_test.go,
app/wire.go, app/app_test.go, dedupe/embedded_test.go and
settings/settings.go (the rest of it) auto-merged with no textual
conflict; verified by reading the result rather than trusting that:
every Commit path windowed-ingest added (commitClaims before the
failing record in publishFailed, and after a clean window in
ingestWindow) already passes commitClaims the full pendingRecord
slice, and commitClaims groups by each record's resolved retention
and issues one Commit per distinct value — so both PRs' Commit-path
changes compose correctly. The sweep's commitMu (embedded.go/sweep.go)
and Managed.Apply's fast path (managed.go) touch disjoint locks and
did not need reconciling.

go build, go vet -tags integration (whole repo), and go test -race
across internal/dedupe, internal/settings, internal/api and
internal/mq all pass (re-run with -count=1 after one flaky timing
assertion in TestEmbedded_SweepChunkOverTombstonesDoesNotHoldCommits
— a 100ms budget the sweep raced past once under parallel-package
load — passed clean on every subsequent run, including three solo
runs and a full fresh run; unrelated to this merge).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api HTTP handlers, routing, middleware area/app Process wiring (internal/app): component build, run, release area/docs Documentation, site/, README area/sdk TypeScript SDK (clients/ts/) documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: Done

1 participant