R1FIX.3 — preserve the HTTP status of thrown client exceptions (#184) - #186
Merged
Conversation
`GetApi.getAsync` hardcoded `statusCode = Some(500)` and dropped the
exception entirely, so a missing index surfaced as an undifferentiated
server error instead of a 404 and every `IF EXISTS` / 404-keyed branch
misfired. Reachable only on es8/es9, whose `executeGetAsync` returns a
failed Future.
Adds one overridable seam on the existing `ElasticClientHelpers` trait --
`private[client] statusOf(Throwable): Option[Int]`, plus the total
`statusOrServerError` used by core's asynchronous flattening sites -- and
overrides it once per client family, where the ES exception types are on
the classpath. `None` keeps meaning "no HTTP status could be determined";
only the async sites normalise unknown to 500.
Also fixed, same defect class:
- the four sibling async flattening sites (indexAsync, updateAsync,
deleteAsync, insertByQuery + copyInto) now derive the status and keep
the exception as `ElasticError.cause`;
- `updateAsync` reported `operation = Some("deleteAsync")` and an
"Exception while deleting..." message -- a verbatim copy-paste that sent
operators debugging a failed UPDATE to the wrong API;
- es6/jest's async path returned `None` for every failure: our own
`JestClientResultHandler` wrapped each non-succeeded `JestResult` in a
status-less `Exception`, making the `getResponseCode` mapping
unreachable. It now raises an `ElasticError` carrying the status, with a
byte-identical message;
- `ElasticClientDelegator` did not forward `copyInto`, so on the wrapper
the REPL and JDBC driver actually hold, the status derivation was inert.
It now forwards both `copyInto` and the seam itself, and
`MetricsElasticClient` measures `copyInto` explicitly so forwarding does
not silence COPY INTO metrics and alerts.
Totality is enforced structurally, not asserted: `statusOrServerError`
absorbs `LinkageError` as well as `NonFatal` (NoClassDefFoundError is a
real failure mode here, cf. #168), `unwrapThrowable` is iterative with a
depth cap so a cause cycle cannot StackOverflow, and the Promise is
completed before logging. A throw in any of those places would leave the
future uncompleted and hang the caller -- strictly worse than a wrong
status.
Tests: one shared Docker integration trait in the testkit template with
five concrete per-version subclasses (es6/jest, es6/rest, es7, es8, es9),
four Docker-free per-client `statusOf` specs, and three core unit specs.
The es8 trait was red 2-of-4 before the fix (`Some(500)` where `Some(404)`
was expected) and es7 green throughout -- es6/es7 never had the bug and
their subclasses pin the contract.
Closed Issue #184
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #184. Story R1FIX.3 of the R1 defect-closure epic (Layer 0, elasticsql).
GetApi.getAsynchardcodedstatusCode = Some(500)and never setcause, so a missing index surfaced as an undifferentiated server error and everyIF EXISTS/ 404-keyed branch misfired — includingDROP MATERIALIZED VIEW IF EXISTS(companion: extensions#40).The fix
One overridable seam on the existing
ElasticClientHelperstrait —private[client] def statusOf(t: Throwable): Option[Int], with a concreteNonedefault plus the totalstatusOrServerErrorused by core's asynchronous flattening sites. It is overridden once per client family, where the ES exception types are already on the classpath. No new trait, no signature churn, and because the default is concrete,NopeClientApi/MockElasticClientApi/ElasticClientDelegatorand every third-party client compile untouched.Nonekeeps meaning "no HTTP status could be determined" — it is not a synonym for 500 and must never be read as "not found". Only the async flattening sites normalise unknown to 500; the synchronous branches still emitNone.The four-ES-line assertion is a contract, not four fixes.
getAsyncandgetAsyncAsbothSome(500) was not equal to Some(404). Their syncgetwas already green.executeGetAsyncreturns a successful Future wrapping anElasticFailurethat already carries the status).Some(404); measured against live ES 6.8.23 it returnedNone.Red-before-green was captured for real: the testkit trait and its five subclasses were written and run first, with no production code, and the es8 output was
Tests: succeeded 2, failed 2.The es6/jest discovery
Our own
JestClientResultHandler.completedwrapped every non-succeededJestResultin a status-lessnew Exception(...), which makesexecuteAsyncJestAction'sgetResponseCodemapping unreachable on the async path. That is the same defect class as #184 — our code fabricating a throwable from a status-bearing response and discarding the status — so it is fixed here. Blast radius is provably contained: that handler has exactly one consumer repo-wide, and the error message is byte-identical to before. There is still deliberately nostatusOfoverride inJestClientHelpers.Also fixed (same defect class)
indexAsync,updateAsync,deleteAsync,insertByQuery+copyInto) now derive the status and keep the exception asElasticError.cause.updateAsyncreportedoperation = Some("deleteAsync")and "Exception while deleting…" — a verbatim copy-paste fromDeleteApithat sent anyone debugging a failed UPDATE to the wrong API.ElasticError.operationis part of the public failure payload, so anything keyed on the old (wrong) value changes. Nothing in-repo keys on it; metrics label independently.ElasticClientDelegatordid not forwardcopyInto, so on the wrapper the REPL and JDBC driver actually hold (MonitoredElasticClient→MetricsElasticClient→ElasticClientDelegator) the whole derivation was inert. It now forwardscopyIntoand the seam itself, so no wrapper can ever disagree with the client it wraps.What the code review changed
Three parallel adversarial layers ran before commit. No BLOCKER; the acceptance audit returned "no BLOCKER or HIGH — satisfies AC 1-8 and every audited constraint". Thirteen patches were applied anyway, four of which are worth your attention:
copyIntoforward silently removed allCOPY INTOmetrics. Before it, the wrapper ran core's body and the operation was measured incidentally through thegetIndex/bulkWithResultcalls that body makes onthis. Forwarding routed it pastMetricsElasticCliententirely, soCOPY INTOwould have contributed zero operations togetMetricsandMonitoredElasticClient's alerts would have stopped seeing file-copy traffic — on the default deployment path. Repaired with an explicitmeasureAsync("copyInto", …). General rule this establishes: adding a delegator forward can un-measure an operation.statusOrServerErrorwas not actually total.NonFatalexcludesLinkageError, andNoClassDefFoundErroris an observed failure mode in this repo (ES6 client crashes with NoClassDefFoundError: org/apache/logging/log4j/LogManager (log4j fully excluded from the es6 closure) #168) — the es8/es9case ex: ResponseExceptionis aninstanceofagainst a class a shaded/ProGuarded fat jar can remove. It now absorbsLinkageErrortoo;OutOfMemoryErrorstill propagates.unwrapThrowablerecursed forever on a 2-object cause cycle, and the resultingStackOverflowErroris fatal — nothing caught it and thePromisehung. Now iterative with a depth cap.valand broke binary compatibility.core/testwent red instantly withAbstractMethodError: … UnwrapDepthLimit_$eqfrom three unrelated pre-existing specs: avalin a trait adds an initializer setter to the interface that every pre-compiled implementor must supply. Made method-local. Worth remembering — it is exactly the guarantee this change relies on.The four
Promisesites also now complete before they log, because this PR newly passes the throwable tologger.error, which makes the appender walk the whole cause chain.Verification
core/testheaderCheck,scalafmtCheckstatusOfspecses8java/testOnly *JavaClient*(regression sweep)es6jest/testreports 232 passed, 0 failed, and 3 suites aborted —JestClientCompanionSpec,JestClientPipelineApiSpec,JestBulkApiSpec. Those are pre-existing and environmental, A/B-proven against pristinemain@2e02e45c:EmbeddedElasticTestKitlaunches an x86 embedded ES that cannot boot on an arm64 machine (Unrecognized VM option 'UseAVX=2'). They fail in the spec constructor, before any client code runs.Version line
Bumped
build.sbt:23from0.20.2to0.20.3-SNAPSHOT. This is the first elasticsql PR of the epic, so it owns the line; R1FIX.4 and R1FIX.5 must verify it and change nothing.🔴 Nothing was published
No
publish, nopublishLocal, norelease.ymldispatch, nov*tag. This story ends at the PR — the release is yours.Task 10 (the per-client
statusOfunit specs) was marked OPTIONAL/PROPOSED in the spec, to be dropped if theProbestub fought the_: JavaClientCompanion =>/_: RestHighLevelClientCompanion =>self-types. It did not — all four were written, they satisfy both self-types with only anelasticConfigoverride, and they are Docker-free (apply()is never called).🤖 Generated with Claude Code