fix(api): map ClickHouse query failures by class, not HTTP status - #627
Conversation
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
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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
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>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
📚 Docs preview is live → https://613f7337-wavehouse-docs.wave-rf.workers.dev
|
Code Coverage OverviewLanguages: Go GoThe overall line coverage in commit ad52e08 in the Show a line coverage summary of the most impacted files.
Updated |
- 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
#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
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
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>
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:
/v1/ops/querykeyed on that status, so a bad statement came back as502(query API maps ClickHouse caller-side errors (e.g. ACCESS_DENIED) to HTTP 502 instead of 4xx #403)./v1/queryand pipes returned a flat500, which the SDK retried (bug(api): request-caused query errors return HTTP 500 + retryable:true (should be 4xx, non-retryable) #271).All the query paths now class the failure with
chconn.Classifythrough one helper,writeCHError(internal/api/ch_errors.go), so they cannot drift apart again:coderetryableclickhouse.rejected/v1/queryclickhouse.limit_exceededACCESS_DENIED(497)clickhouse.access_deniedclickhouse.misconfiguredRetry-After: 5clickhouse.unavailableclickhouse.unknownAlso in this PR:
{"error": …}envelope (internal/api/errors.go) gainscodeandretryableon these responses. It is additive: no new envelope, and thecodenames are namespaced as refactor(errors): typed error kinds for stable machine-readable codes #539 proposes.clients/ts/src/errors.tstakes the server'scodeandretryablewhen present, and falls back toHTTP_<status>and "5xx retries" otherwise. There is noclients/goon this base.502but is nowretryable: false, since the same query overflows again.POST /v1/ops/schema/refreshagainst an unreachable ClickHouse answers503+Retry-Afterinstead of500.context.go:238-241) overwritesmax_execution_timewith deadline+5s for any deadline over 1s. As it was, a cap overrun came back as a bareDeadlineExceeded, 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 reportsTIMEOUT_EXCEEDED. Anything else, including the backstop cancel, is503.TestQueryErrors_TimeCapReachesClickHousepins both sides of that driver behaviour against a real ClickHouse.Why
ACCESS_DENIEDis a 403, and credential failures a 502The query paths run as the ClickHouse user in the tenant's settings, not as the caller. So
ACCESS_DENIEDis WaveHouse's configuration in one sense. It is still a verdict on this statement:That is a 403, and it matches #403's repro exactly: a
CREATE USERthrough/v1/ops/querywith a user that lacks the grant. The integration test runs that statement against the test container, and the answer is403 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, becausewriteCHErrorlogs every denial atWARN.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 are502 clickhouse.misconfigured. They are not retryable, because a retry a few seconds later changes nothing.Deliberately left for later
/v1/ops/query, and on/v1/queryfor a role with no cap, aTIMEOUT_EXCEEDED/MEMORY_LIMIT_EXCEEDED/query_timeoutexpiry is503and gets retried. The same codes also come from a busy server, so there I kept the classifier's verdict.max_memory_usage, a server-totalMEMORY_LIMIT_EXCEEDEDis also answered400. This is documented in api.md.codecatalogue (discovery / policy / ingest validation). This PR only addsclickhouse.*.Tests
Unit,
internal/api/ch_errors_test.go(new): a table of real clickhouse-go errors, run through both/v1/queryand pipes:*clickhouse.Exceptioncodes 47/53/60/62/158/159/202/241/497/516;clickhouse.ErrAcquireConnTimeout;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):SELEC 1→400 clickhouse.rejected, not retryable;CREATE USER→403 clickhouse.access_denied;400(code 47)./v1/ops/queryand/v1/queryanswer503 clickhouse.unavailable,retryable: true,Retry-After: 5.Updated for the new contract:
query_limits_test.go: the role caps are400 limit_exceeded, not500;app_test.go: a verified token is told apart by the ClickHouse error body, because a closed ClickHouse is now503;tenant_clickhouse_test.go;tests/e2e/sdk/query.test.ts(role caps → 400) andadmin.test.ts(syntax error throughwh.sql→ 400,clickhouse.rejected, not retried).SDK:
errors.test.tsandhttp.test.ts, including a 502 withretryable: falsethat is not retried.make ci: passed atf485505c: unit, integration and e2e, all coverage gates.Review
The
pre-push-revieweranddocs-reviewer(opus) ran in fresh context against the delta vsorigin/feat/ch-error-classes.01ea6902):iterate, one MUST. A bareDeadlineExceededunder 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.iterate, five SHOULDs:configuration.mdxlimits, api.md cap wording, access-control naming the three caps, the SDK table missingclickhouse.unknown, and themaxRetrieswording.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.4ff30f31): bothship_it.148a160b,3a314afe,f485505c, test and CHANGELOG only, aftermake cicaught stale 503/500 expectations inapp_test.goandquery_limits_test.go):iterateonce, becauseapp_test.go:908could no longer fail. Thenship_itatf485505c, after a full sweep of the test files.Review markers (#454): in a
wtworktree, the SubagentStop hook writes markers into the main checkout'stmp/, keyed to its HEAD. So notmp/<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