Add Elasticsearch specific response types#205
Conversation
Mpdreamz
left a comment
There was a problem hiding this comment.
Alternative design: avoid the 7 concrete Elasticsearch*Response types
The PR achieves the right goal but introduces significant boilerplate: 7 near-identical concrete types + 6 new *ResponseBase classes + generic builder variants + an 8-arm SetServerError switch. Net +681 lines for what is conceptually a small addition.
Proposed alternative: single ElasticsearchResponse<TBody>
Replace all 7 concrete response types with one generic:
public sealed class ElasticsearchResponse<TBody> : TransportResponse, IElasticsearchResponse
{
public TBody? Body { get; internal set; }
public ElasticsearchServerError? ElasticsearchServerError { get; internal set; }
public bool IsValidResponse =>
ElasticsearchResponseHelper.IsValidResponse(ApiCallDetails, ElasticsearchServerError);
public IEnumerable<string> ElasticsearchWarnings =>
ElasticsearchResponseHelper.GetElasticsearchWarnings(ApiCallDetails);
public string DebugInformation =>
ElasticsearchResponseHelper.GetDebugInformation(IsValidResponse, ApiCallDetails, ElasticsearchServerError);
public bool TryGetOriginalException(out Exception? exception) =>
ElasticsearchResponseHelper.TryGetOriginalException(ApiCallDetails, out exception);
public override string ToString() => DebugInformation;
}ElasticsearchResponse<string>replacesElasticsearchStringResponseElasticsearchResponse<byte[]>replacesElasticsearchBytesResponseElasticsearchResponse<DynamicDictionary>replacesElasticsearchDynamicResponseElasticsearchResponse<JsonNode>replacesElasticsearchJsonResponseElasticsearchResponse<Stream>replacesElasticsearchStreamResponseElasticsearchResponse<Void>replacesElasticsearchVoidResponse
Builder: factory delegate, no casts
Instead of a BuildBody that does (TBody)(object) double-casts, inject the deserialization logic as a delegate at construction:
internal sealed class ElasticsearchPrimitiveResponseBuilder<TBody> : IResponseBuilder
{
private readonly Func<BoundConfiguration, Stream, string, long, TBody?> _factory;
public ElasticsearchPrimitiveResponseBuilder(
Func<BoundConfiguration, Stream, string, long, TBody?> factory) => _factory = factory;
bool IResponseBuilder.CanBuild<TResponse>() => typeof(TResponse) == typeof(ElasticsearchResponse<TBody>);
TResponse? IResponseBuilder.Build<TResponse>(ApiCallDetails details,
BoundConfiguration config, Stream stream, string contentType, long contentLength)
{
var response = new ElasticsearchResponse<TBody> { Body = _factory(config, stream, contentType, contentLength) };
return response as TResponse;
}
}Registration becomes self-documenting and cast-free:
public override IReadOnlyCollection<IResponseBuilder> ResponseBuilders { get; } =
[
new ElasticsearchErrorDecorator<ElasticsearchResponse<string>>(
new ElasticsearchPrimitiveResponseBuilder<string>((cfg, s, _, _) => s.ReadToEnd(cfg.MemoryStreamFactory))),
new ElasticsearchErrorDecorator<ElasticsearchResponse<byte[]>>(
new ElasticsearchPrimitiveResponseBuilder<byte[]>((cfg, s, _, _) => s.ToByteArray())),
new ElasticsearchErrorDecorator<ElasticsearchResponse<DynamicDictionary>>(
new ElasticsearchPrimitiveResponseBuilder<DynamicDictionary>((cfg, s, _, _) => cfg.RequestResponseSerializer.Deserialize<DynamicDictionary>(s))),
new ElasticsearchErrorDecorator<ElasticsearchResponse<JsonNode>>(
new ElasticsearchPrimitiveResponseBuilder<JsonNode>((cfg, s, _, _) => JsonNode.Parse(s))),
// Stream/Void/Pipe keep their trivial builders (they don't read the stream)
new ElasticsearchErrorDecorator<ElasticsearchResponse<Stream>>(new ElasticsearchStreamResponseBuilder()),
new ElasticsearchErrorDecorator<ElasticsearchResponse<Void>>(new ElasticsearchVoidResponseBuilder()),
new ElasticsearchResponseBuilder()
];Fix SetServerError — eliminate the 8-arm switch
Add an internal setter interface:
internal interface IElasticsearchResponseSetter
{
ElasticsearchServerError? ElasticsearchServerError { set; }
}Both ElasticsearchResponse<TBody> and the existing ElasticsearchResponse abstract base implement it. Then:
// Before (8-arm switch):
switch (response)
{
case ElasticsearchResponse r: r.ElasticsearchServerError = error; break;
case ElasticsearchStringResponse r: r.ElasticsearchServerError = error; break;
// ...6 more arms...
}
// After:
if (response is IElasticsearchResponseSetter r)
r.ElasticsearchServerError = error;Preserving Get<T>(path) for dynamic/JSON
DynamicResponseBase.Get<T> and JsonResponseBase.Get<T> are no longer inherited, but they're easily restored via extension methods:
public static T Get<[DynamicallyAccessedMembers(...)] T>(
this ElasticsearchResponse<DynamicDictionary> r, string path) =>
r.Body?.Get<T>(path) ?? default!;
public static T Get<[DynamicallyAccessedMembers(...)] T>(
this ElasticsearchResponse<JsonNode> r, string path) =>
JsonNodePathTraversal.Get<T>(r.Body, path);What's eliminated vs this PR
| PR #205 | Alternative | |
|---|---|---|
| New public types | 7 (Elasticsearch*Response) |
1 (ElasticsearchResponse<T>) |
| New base classes | 6 (*ResponseBase) |
0 |
| Generic builder variants | 4 | 0 |
SetServerError switch arms |
8 | 0 (interface cast) |
| Net new lines (approx) | +681 | ~+150 |
Trade-off to consider
ElasticsearchStringResponse is more discoverable in docs/IntelliSense than ElasticsearchResponse<string>. If the named types are important for the public API surface of elasticsearch-net, that's a valid reason to keep them — but the 6 base classes and the SetServerError switch should still be cleaned up regardless.
|
@Mpdreamz Bodies like Besides that:
This sounds good in theory, but does not work out (it at least did not for me). In my test implementation had to duplicate a lot of code due to the error detection + stream duplication / reset functionality and the different ownership semantics. Good suggestion for the internal Regarding the Maybe I should have split this into 2 different PRs. The change to The use case is that consumers sub-class e.g. |
Mpdreamz
left a comment
There was a problem hiding this comment.
Good points! Especially the disposal semantics on a generic type — I can see now why you swayed towards the concrete types. The internal interface is still worth picking up to kill the switch, but the overall design is sound. 👍
Mpdreamz
left a comment
There was a problem hiding this comment.
Good points! Especially the disposal semantics on a generic type — I can see now why you swayed towards the concrete types. The IElasticsearchResponseSetter internal interface is still worth picking up to kill the switch, but the overall design is sound.
|
Thanks @Mpdreamz , I'll refactor the switch away using your suggestion before I merge. I'll also remove the byte[] and void variants since we don't have a use case for them at the moment. |
|
I switched back to draft since since I'm currently exploring just another design. I'm off next week, but I'll pick it up again the week after 🙂 |
c920f7b to
6170651
Compare
|
I updated the design (check the new PR description). Could you please have another look @Mpdreamz ? |
6170651 to
528a3d0
Compare
Mpdreamz
left a comment
There was a problem hiding this comment.
Overall this is a solid direction: centralizing extraction, ProductError, and clearer ES-flavored response types makes the pipeline easier to reason about than scattering error handling in builders.
Below are a few spots where behavior changes in ways that can surprise callers or regress diagnostics. If these trade-offs are deliberate, it would help to document them in code (e.g. /// <remarks> on HttpStatusCodeClassifier / IsErrorContentType / TryExtractError, TryGetServerErrorReason, ElasticsearchResponseHelper.IsValidResponse, and/or DefaultResponseFactory around mayHaveErrorBody), so future readers do not assume parity with main without reading the PR.
404 and IsValidResponse
On main, every HTTP 404 was treated as an invalid Elasticsearch response. On this PR, 404 can be valid when no extracted server error has HasError(), and invalid when it does.
If intentional: this is a compatibility- and test-sensitive change; worth an explicit remark that 404 is not universally “invalid” and that callers must not rely on status alone for document-not-found vs real failure.
TryGetServerErrorReason
On main, a reason could still be produced by parsing StringResponse / BytesResponse / generic TransportResponse via TryGetElasticsearchServerError. Here, reasons come only from ApiCallDetails.ProductError when it is an ElasticsearchServerError with HasError().
If intentional: document that there is no body-parse fallback for reasons, so TryGetServerErrorReason may return false even when ResponseBodyInBytes contains human-readable or parseable content (e.g. after content-type gating or failed extraction).
IsErrorContentType + TryExtractError
On main, the builder’s error branch attempted error deserialization without this content-type gate. Here, ProductError is only populated when IsErrorContentType passes.
If intentional: document that structured ProductError depends on headers, and that non-JSON / unexpected Content-Type means no ProductError, with raw diagnostics in ResponseBodyInBytes instead.
Non-success body path includes 3xx
On main, the Elasticsearch builder’s “try error first” logic was tied to HttpStatusCode > 399. Here, mayHaveErrorBody follows !HttpStatusCodeClassifier (for Elasticsearch, outside 200–299), so 3xx enters buffering / extraction-related logic, not only 4xx/5xx.
If intentional: a short remark on why 3xx shares this path (or that it is harmless by design) would prevent future “bug” reports about redirects.
Short-circuit when ProductError?.HasError()
Skipping full TResponse deserialization when a structured error is recognized is not new in spirit, but it is now factory-driven. If extraction ever misclassifies, callers still lose the full typed body—same risk class as before, different layer.
If intentional: a one-line note that full body deserialize is skipped when ProductError indicates a real error helps set expectations for edge cases.
Ask
If the maintainers confirm these behaviors, please encode them in /// remarks (or a single central comment in DefaultResponseFactory) so the PR’s intent stays obvious after merge. Happy to re-review after those docs land.
|
The remarks bit is AI slop, I just want to ensure all the behavioral changes are intended :) @flobernd |
|
Thanks for the review @Mpdreamz 404 and IsValidResponseThis change is intended. This was incorrect earlier as commented here: // Elasticsearch returns 404 for valid responses in some cases (e.g. `GET /my-index/_doc/missing-doc-id`) but also for actual error cases like
// missing endpoints, missing indices (e.g. `GET /missing-index/_mapping`), etc.
// We consider all status codes >= 200 and < 300 valid by default. For 404, we assume "invalid" and try to parse the Elasticsearch
// error response from the body.
// A 404 status code without an error body indicates a valid response.Cases like TryGetServerErrorReasonAI slop as well 😅 There is a fallback that preserves the old semantics. IsErrorContentType + TryExtractErrorIntentional. Makes no sense trying to parse things like http/text/etc from a proxy into a structured error type that expects JSON. Non-success body path includes 3xxMy thinking was that we usually won't see these codes since Short-circuit when ProductError?.HasError()If that condition is met, we definitely have an error. No need to call custom response builders IMO. |
|
Thanks again for walking through the behavioral intent — super helpful. One follow-up on 1. On
|
|
Thanks for the thorough walkthrough — really appreciate the side-by-side code traces. I believe
|
## Summary Migrates `Elastic.Clients.Esql` to `Elastic.Transport` 0.16.0 and adopts the Elasticsearch-specific response types introduced in [elastic/elastic-transport-net#205](elastic/elastic-transport-net#205). Error extraction is now driven by the transport pipeline via a proper `ElasticsearchProductRegistration`, `EsqlExecutionException` carries the full structured failure context, and the outbound client meta header now attributes traffic to this client rather than colliding with elasticsearch-net. ## Changes ### Transport 0.16.0 adoption - Bumps `Elastic.Transport` pin to `0.16.0`. - New `EsqlProductRegistration` (public, subclass of `ElasticsearchProductRegistration`) with `typeof(EsqlClient)` as the version marker and a `Default` singleton. Mirrors the pattern in `elasticsearch-net`. - `EsqlClientSettings(Uri)` and `EsqlClientSettings(NodePool)` wire `EsqlProductRegistration.Default` into the built `TransportConfiguration`. The `EsqlClientSettings(ITransport)` overload documents that callers supplying their own transport should pass `EsqlProductRegistration.Default` themselves. - All 8 transport call sites in `EsqlTransportExecutor` now use `ElasticsearchStreamResponse` / `ElasticsearchPipeResponse` (NET10+) instead of `StreamResponse` / `PipeResponse`. The manual `ElasticsearchServerError.TryCreate(response.Body, …)` parse is gone — the transport's product registration populates `ElasticsearchServerError` automatically via `TryExtractError`. ### Richer `EsqlExecutionException` - Collapsed to a single primary constructor `(string message, ApiCallDetails? apiCallDetails, ElasticsearchServerError? serverError)`. - New properties: `ServerError : ElasticsearchServerError?` and `ApiCallDetails : ApiCallDetails?`. `StatusCode` / `ResponseBody` remain as convenience shortcuts derived from those. - **Breaking (0.x):** drops the unused `EsqlExecutionException(string)`, `EsqlExecutionException(string, string?, int?)`, and `EsqlExecutionException(string, Exception)` constructors. No in-repo caller used them. ### Distinct `x-elastic-client-meta` identifier - `EsqlProductRegistration` overrides `MetaHeaderProvider` to emit `esql=<version>` instead of the inherited `es=<version>`. Without this, the meta header would be indistinguishable from elasticsearch-net's (both clients share the base `ElasticsearchProductRegistration.ServiceIdentifier` which is `sealed` at `"es"`), making server-side client attribution unreliable. ### Integration-test coverage for the error path - Tightened `EdgeCaseTests.NonExistentIndex_ThrowsEsqlExecutionException` — now asserts on `StatusCode`, `ResponseBody`, `ApiCallDetails`, and exact `ServerError.Error.Type`. - New `ErrorHandlingTests` file with four cases covering invalid syntax, unknown-field references, and non-existent-index failures across both sync and async submit paths. All tests assert on `ServerError.Error.Type` (stable across Elasticsearch versions) rather than brittle `Message` substring matches. - `ElasticsearchFixture` now passes `EsqlProductRegistration.Default` into the manually-built `TransportConfiguration` used by the integration suite, so the new error-extraction path is actually exercised.
Summary
The existing
ElasticsearchResponseprovides Elasticsearch-specific error handling (IsValidResponse,ElasticsearchServerError,ElasticsearchWarnings,DebugInformation) but requires JSON-deserialized subclasses. Native response types (StringResponse,StreamResponse,PipeResponse, etc.) have no Elasticsearch error handling at all.This is a problem for endpoints returning non-JSON responses (ESQL binary, ML models, etc.) — we want
StreamResponse-style body access withElasticsearchResponse-style error handling. The ESQL client also benefits from this.This PR introduces:
IElasticsearchResponseinterface — common contract for Elasticsearch error handling, implemented by both the existingElasticsearchResponsebase and new native-type variants.Elasticsearch-flavored response types —
ElasticsearchStringResponse,ElasticsearchStreamResponse,ElasticsearchDynamicResponse,ElasticsearchJsonResponse,ElasticsearchPipeResponse. Each extends a shared base class and implementsIElasticsearchResponse. Dispose semantics are natural (stream/pipe types inheritIDisposable/IAsyncDisposablefrom their base).Base classes for all special response types —
StringResponseBase,DynamicResponseBase,JsonResponseBase,PipeResponseBase(following the existingStreamResponseBasepattern). Existing types re-parented. Shared behavior lives in the base; both transport-level and Elasticsearch-level types derive from the same base.Generic response builders —
StringResponseBuilder<T>,DynamicResponseBuilder<T>,JsonResponseBuilder<T>. Body-building logic written once against the base type. Non-generic aliases kept for backward compat. Zero duplication between transport and Elasticsearch builders.Centralized error extraction in
DefaultResponseFactory— product-specific error handling moved from builders into the factory via two newProductRegistrationextension points:IsErrorContentType(string?)— does the content-type indicate a parseable error body? Prevents JSON-parsing HTML proxy errors.TryExtractError(BoundConfiguration, Stream)— extract a product-specificErrorResponsefrom a seekable stream.The factory stores the result in a new
ApiCallDetails.ProductErrorproperty. AllIElasticsearchResponsetypes readElasticsearchServerErroras a computed property fromApiCallDetails.ProductError— no stored state, no setter interfaces.Error body always buffered on failure — for non-success status codes, the response body is always captured in
ApiCallDetails.ResponseBodyInBytes, even when the content-type doesn't match the product's error format (HTML from proxies, unknown JSON, binary). Users can always inspect the raw error body.Improved
DefaultResponseFactoryreadability — flattened nesting, early returns, extractedBufferResponseStreamAsync/CaptureResponseBytesAsynchelpers, properStream?nullability annotations,FinalizeResponse()local function.Key architectural decisions
ElasticsearchResponse<T>) — generic wrapper can't express dispose semantics (ElasticsearchResponse<StreamResponse>isn'tIDisposable). Concrete types make disposal discoverable to users and analyzers.ElasticsearchStreamResponse : StreamResponseBaserather thanElasticsearchResponse<StreamResponse>. Natural inheritance, no body-copying, no forwarding.IsErrorContentTypegate — error extraction only attempts JSON parsing when the content-type isapplication/jsonorapplication/vnd.elasticsearch+json. Prevents wasted work on proxy HTML errors.ElasticsearchServerError— reads fromApiCallDetails.ProductError, no stored property orinternal set. NoIElasticsearchResponseSetterinterface needed.Breaking changes
StreamResponseBaseconstructor changed frompublictoprotected(it's an abstract class).Stream responseStreamparameter inResponseFactory.Create/CreateAsyncchanged toStream? responseStream(nullable).