Skip to content

feat: per-wrapper partial-failure error mask + v5preview parity - #844

Merged
sean- merged 20 commits into
opensearch-project:mainfrom
sean-:gh-816
Jun 4, 2026
Merged

feat: per-wrapper partial-failure error mask + v5preview parity#844
sean- merged 20 commits into
opensearch-project:mainfrom
sean-:gh-816

Conversation

@sean-

@sean- sean- commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replace Config.ReturnQueryErrors (a single boolean) with a 15-bit errmask.ErrorMask bitfield, where each bit corresponds to one wrapper schema in the proposed x-error-responses OpenAPI spec extension. Generators read those annotations, so v5preview/opensearchapi gets the same partial-failure handling that v4 has had as hand-written code. The same branch also reworks discriminated-union decoding (single-pass merge for success|error unions, lazy As<T>() for discriminator-less aggregation/suggest unions) and makes node discovery blocking.

Closes #816 (final piece of the gh-816 burn-down stack: #804 / #815 / #801 already merged).

Why

OpenSearch returns HTTP 200 for partial successes -- bulk item failures, search-shard failures, single-doc replica failures, MSearch sub-response errors -- so callers must remember a second check after err == nil. v4's ReturnQueryErrors bool flipped all partial-failure conversions on or off as one switch; users wanting shard-level errors but tolerating bulk item failures (or vice versa) had no way to express it.

The bitfield gives one bit per wire shape. Setting a bit masks (suppresses) that category; the unset state reports it. errmask.Empty reports everything; errmask.All masks everything.

Lifecycle

Version Config.Errors == nil default Behavior
v4 (this PR) errmask.All No behavior change for existing callers. Opt in to typed errors with Config.Errors: errmask.New().
v5 errmask.Empty Every category reported by default. Callers opt out individual bits via Config.Errors or OPENSEARCH_GO_ERROR_MASK.
v6 n/a Config.Errors and OPENSEARCH_GO_ERROR_MASK removed. Behavior is unconditionally errmask.Empty (report everything).

Usability

The caller surface is a typed error return + a typed response. Both are non-nil on partial failure: the response is fully populated even when the error fires. Config.Errors is a *errmask.ErrorMask; because the named values are constants (not addressable), build the pointer with errmask.New(...).

client, _ := opensearchapi.NewClient(opensearchapi.Config{
    Client: opensearch.Config{Addresses: addrs},
    Errors: errmask.New(), // report every partial-failure category
})

resp, err := client.Bulk(ctx, opensearchapi.BulkReq{Body: body})
switch e := err.(type) {
case nil:
    // every item succeeded
case *opensearchapi.PartialBulkError:
    // some items failed; resp is fully populated
    log.Printf("%d/%d items failed",
        len(e.FailedItems), e.SucceededCount+len(e.FailedItems))
    retryFailedItems(e.FailedItems)
default:
    return err // transport / HTTP / decode error
}

For ops that can fire multiple wrapper categories on a single response (MSearch, MSearchTemplate), the package-level opensearchapi.Errors helper flattens single- and multi-error returns so one switch handles both:

resp, err := client.MSearch(ctx, req)
for _, sub := range opensearchapi.Errors(err) {
    switch e := sub.(type) {
    case *opensearchapi.PartialSearchError:       // shard aggregation
    case *opensearchapi.MultiSearchItemError:     // per-sub-response Error envelope
    default:
        return err
    }
}

opensearchapi.Errors(nil) returns nil; a non-partial err returns a single-element slice. It only flattens recognized partial-failure wrappers, so a joined non-partial error keeps its top-level identity.

err = opensearchapi.RequireSuccessRate(err, 0.99) // nil unless <99% succeeded in EVERY category
err = opensearchapi.ToleratePartialFailures(err)  // nil for any partial failure

What changed

New: public errmask package

  • Top-level github.com/opensearch-project/opensearch-go/v4/errmask, importable by external consumers.
  • ErrorMask with one bit per wrapper schema: BulkItems, SearchShards, WriteShards, BroadcastShards, NodeFailures, BulkByScrollFailures, TaskFailures, MultiSearchItems, MultiDocItems, SnapshotCreateShardFailures, SnapshotGetShardFailures, SimulateDocFailures, RankEvalFailures, IngestionShardFailures, PitNodeFailures.
  • errmask.Empty (zero value), errmask.All, errmask.None/errmask.Unknown aliases for Empty, and New(bits ...ErrorMask) *ErrorMask to build the Config.Errors pointer (New() = report everything).
  • Has, canonical String(), and Parse(s, base) accepting comma-separated +/- snake_case tokens (e.g. +all,-bulk_items). Unknown tokens are dropped (forward-compatible) and reported via the debug logger.

v4 opensearchapi/

  • Config.Errors *errmask.ErrorMask (pointer disambiguates "use the version's default" from "explicit Empty"). Config.ReturnQueryErrors is removed.
  • Resolver merges cfg + OPENSEARCH_GO_ERROR_MASK.
  • Typed errors: MultiSearchItemError, MSearchErrors, MSearchTemplateErrors (Go 1.20+ multi-error via Unwrap() []error). RequireSuccessRate evaluates every category a multi-error carries (not just the first match).
  • Per-Resp helpers (BulkItemFailures, SearchShardFailures, WriteShardFailures, MultiSearchItemFailures) + PartialFailures(mask).
  • Package-level opensearchapi.Errors(err) []error.

v5preview/opensearchapi

  • errors.go mirrors v4 using the spec-driven types: BulkRespItem, ShardSearchFailure, ErrorRespBase (embedded in MultiSearchItemFailure).
  • api.go wires Config.Errors and clientInit(rootClient, mask).
  • Codegen emits per-Resp helpers + PartialFailures(mask); dispatch is trivial, with the per-op container referenced only when 2+ wrapper categories can fire.

Discriminated-union decoding (osgen)

  • Merged single-pass decode for all-object unions with a discriminable primary branch (mget/msearch success|error items, indices-open, BulkByScrollTaskStatus | ErrorCause).
  • Lazy As<T>() for discriminator-less, caller-keyed map-valued unions (aggregation/suggest result families): raw bytes retained, decoded on demand into the requested type.
  • Raw bytes are aliased from the response buffer (no per-item copy); UnmarshalJSON resets prior branch state so reused decode targets are safe.

Transport

  • DiscoverNodes is now blocking (concurrent callers wait on the in-flight cycle); added DiscoverNodesOnStart *bool. A waiter no longer inherits the runner's context.Canceled when its own context is healthy.

Spec (opensearch-openapi.yaml)

  • 15 wrapper schemas under components.schemas as _common.errors___<WrapperName>; 115 operations annotated with x-error-responses.

cmd/osgen

  • spec.go: reads x-error-responses from the OpenAPI extension into apiOperation.ErrorWrappers.
  • errwrap: hardcoded fallback catalog for plugin operations not yet annotated upstream.
  • ir.Operation.ErrorWrappers carries the resolved wrapper list.
  • emit/frag_clients.go: generated Client struct has errors errmask.ErrorMask; clientInit takes the mask.
  • emit/frag_dispatch.go: dispatch template simplified to call data.PartialFailures(...) -- per-wrapper logic moved out of the template.
  • emit/frag_partial_failure.go (new): emits per-Resp helper methods + PartialFailures(mask) aggregator. Each wrapper has a RenderMethod function plus an Applies predicate that walks the typed response (including embedded structs via the type registry) to confirm the field path the emission references; spec annotations whose typed Resp lacks the matching field are silently skipped (e.g. v5preview's CreateResp has no _shards, msearch's union response item resolves through unionFromResponses to find the success/error branches).
  • Mask-bit identifiers and method names derive from the wrapper string itself: errmask.<wrapper> for the bit, singularize(wrapper) + "Failures" for the method. No hand-maintained name maps.

Generated v5preview/opensearchapi

  • clients_gen.go carries the new errors errmask.ErrorMask field and clientInit signature.
  • 22 dispatch files updated: each declares its per-Resp <Wrapper>Failures() helpers + PartialFailures(mask) aggregator, and the dispatch handler delegates to them.

Migration

Existing v4 callers see no behavior change: zero-config Client{} continues to mask all partial-failure categories (preserving pre-bitfield behavior). Callers who were setting ReturnQueryErrors: true (added earlier in this PR's stack -- never shipped to a release) need to switch to Errors: &errmask.Empty.

// Before (deleted from this PR):
//   Errors: errmask.None,  // or ReturnQueryErrors: true

// After: pointer, with explicit "report everything"
mask := errmask.Empty
client, _ := opensearchapi.NewClient(opensearchapi.Config{
    Client: opensearch.Config{Addresses: addrs},
    Errors: &mask,
})

// Or selectively: report everything except bulk-item failures
mask := errmask.BulkItems
client, _ := opensearchapi.NewClient(opensearchapi.Config{
    Client: opensearch.Config{Addresses: addrs},
    Errors: &mask,
})

Env-var override:

# Mask everything except bulk-item errors
export OPENSEARCH_GO_ERROR_MASK="+all,-bulk_items"

# Only mask search-shard failures
export OPENSEARCH_GO_ERROR_MASK="search_shards"

# Reset to "mask everything" (mimics v4 default)
export OPENSEARCH_GO_ERROR_MASK="all"

# Reset to "report everything" (the v5+ default)
export OPENSEARCH_GO_ERROR_MASK="none"

Follow-ups (not in this PR)

  • Spec PR adding x-error-responses upstream so the local patch in opensearch-openapi.yaml can be replaced by a re-bundle.
  • Add hand-written detection for the 10 catalog wrappers without RenderMethod emission yet (NodeFailures, BulkByScrollFailures, TaskFailures, MultiDocItems, SnapshotCreateShardFailures, SnapshotGetShardFailures, SimulateDocFailures, RankEvalFailures, IngestionShardFailures, PitNodeFailures).
  • Teach v5preview type-gen to surface _shards on CreateResp so the Applies guard can stop skipping that op.
  • Enable router by default in v5preview NewClient (separate commit).

Related

@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.46914% with 511 lines in your changes missing coverage. Please review.
✅ Project coverage is 47.20%. Comparing base (f591483) to head (5a4d450).

Files with missing lines Patch % Lines
v5preview/opensearchapi/clear_scroll_gen.go 0.00% 52 Missing ⚠️
v5preview/opensearchapi/cat-recovery_gen.go 0.00% 50 Missing ⚠️
v5preview/opensearchapi/cat-snapshots_gen.go 0.00% 50 Missing ⚠️
v5preview/opensearchapi/delete_by_query_gen.go 0.00% 45 Missing ⚠️
v5preview/opensearchapi/cluster-remote_info_gen.go 0.00% 44 Missing ⚠️
cmd/osgen/emit/frag_dispatch.go 91.48% 22 Missing and 5 partials ⚠️
...ew/opensearchapi/cluster-allocation_explain_gen.go 0.00% 25 Missing ⚠️
v5preview/opensearchapi/api.go 51.06% 20 Missing and 3 partials ⚠️
v5preview/opensearchapi/cat-count_gen.go 16.00% 21 Missing ⚠️
v5preview/opensearchapi/cat-health_gen.go 16.00% 21 Missing ⚠️
... and 24 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #844      +/-   ##
==========================================
- Coverage   52.52%   47.20%   -5.32%     
==========================================
  Files         990     1000      +10     
  Lines       59059    69758   +10699     
==========================================
+ Hits        31020    32929    +1909     
- Misses      24935    33631    +8696     
- Partials     3104     3198      +94     
Flag Coverage Δ
integration 29.99% <56.24%> (-3.66%) ⬇️
unit 41.83% <77.02%> (-4.38%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
cmd/osgen/api_extract.go 80.27% <100.00%> (+0.82%) ⬆️
cmd/osgen/emit/frag_clients.go 91.37% <100.00%> (+0.15%) ⬆️
cmd/osgen/emit/frag_plugin.go 70.68% <100.00%> (-2.75%) ⬇️
cmd/osgen/ir/types.go 0.00% <ø> (ø)
cmd/osgen/naming.go 89.86% <100.00%> (+1.67%) ⬆️
cmd/osgen/spec.go 100.00% <100.00%> (ø)
errmask/errmask.go 100.00% <100.00%> (ø)
internal/envvars/envvars.go 100.00% <100.00%> (ø)
opensearchapi/api_bulk.go 88.23% <100.00%> (+1.56%) ⬆️
opensearchapi/api_document.go 100.00% <100.00%> (ø)
... and 209 more

... and 27 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Jakob3xD Jakob3xD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Thanks for this — the underlying feature (typed partial-failure errors + a version/env-gated on-off migration) is the right direction, and the errmask bitfield itself is cleanly written. The feedback below splits into one design question and a set of verified correctness/doc issues. Inline comments carry findings #2, #3, #4, #7; the rest are below.


Design question: is a 15-bit global ErrorMask the right granularity?

Two things should stay regardless of the answer:

  1. Partial failures as typed errors (PartialBulkError, PartialSearchError, ShardFailureError) + the helpers (errors.As, ToleratePartialFailures, RequireSuccessRate).
  2. The coarse on/off migration gate (env var + v4→v5→v6 default flip).

The concern is only the per-category global mask layer in between:

  • Wrong axis. Tolerance for partial failure is almost always per call-site, not per client (the mask is client-global — verified: no per-request override; every dispatch reads c.errors.Has(...)). The same client does a best-effort analytics search (shard failures tolerable) and a critical bulk index (item failures not). A global mask can't express that without two clients. And the per-call mechanism already exists: always return the typed error, let the caller decide with errors.As / ToleratePartialFailures / RequireSuccessRate. The mask is a second, coarser mechanism for the same goal.

  • It re-introduces the footgun the feature exists to kill. The stated goal was "partial failures become impossible to silently ignore." A global mask that converts them back to (resp, nil) — set once at construction or via env var — does exactly that, invisibly, across every call site. ToleratePartialFailures(err) at the call site keeps the suppression visible in code review.

  • It leaks the spec's internal taxonomy into permanently-stable public API. 15 wire-format wrapper bits each have to stay stable forever and be individually documented/tested.

  • It necessitates a parallel codegen path (errwrap.OperationWrappers + per-bit emission running a second spec walk to decide which bit to emit) — and findings #5/#6 below live in exactly that path.

If a one-time org-wide policy is genuinely needed, a small semantic enum (None / TolerateShardFailures / TolerateAll) would serve it without the four problems above. And making detection a PartialFailure() error method keyed off the already-correctly-generated response struct (instead of a parallel error-wrap walk) would remove the bit-selection class of bug entirely.


Cross-cutting issues (not anchored to a single line)

1. Docs reference an env var that doesn't exist (high confidence). CHANGELOG.md, USER_GUIDE.md, UPGRADING.md, and guides/error_handling.md document OPENSEARCH_GO_PARTIAL_QUERY_ERRORS (and describe it as a boolean). The shipped code defines only OPENSEARCH_GO_ERROR_MASK (a token list, see internal/envvars/envvars.go). A user following the docs gets no effect.

5. v5preview MSearch never emits a partial-failure check (parity gap). v5preview/opensearchapi/msearch_gen.go emits no errmask/partial check at all, while v4 api_msearch.go does. Root cause appears to be in the generator: elementTypeHasShards resolves the Responses element type (MsearchMultiSearchResultResponsesItem), which is a TypeUnion — its shape is in Branches, not Fields, so responseHasField(..., "Shards", ...) returns false and the check is dropped. MSearch shard failures are never surfaced in v5preview regardless of mask. This defeats the "v5preview parity" goal for MSearch.

6. v5preview Create is missing its WriteShards check (parity gap). v5preview/opensearchapi/create_gen.go Create returns (&data, nil) with no shard-failure check, while index_gen.go has if !c.errors.Has(errmask.WriteShards) && data.Shards.Failed > 0 { ... } and v4 api_document.go Create has the check. Likely root cause: cmd/osgen/api_extract.go hardcodes the "200" response, but create returns only 201 — so CreateResp is built without _shards and the check is suppressed.


Additional, lower-confidence observations

  • PartialSearchError.Failures type drift in docs: UPGRADING.md / guides/error_handling.md document []ResponseShardsFailure, but v5preview/opensearchapi/errors.go uses []ShardSearchFailure. The two packages diverge; the docs describe them as identical.
  • DiscoverNodes() semantics changed (now blocks on an in-flight cycle and returns its error, vs. the previous immediate nil no-op). Likely intentional, but not noted in UPGRADING.md.
  • Route interface breaking change: OpID() OperationID was added to the exported opensearchtransport.Route interface — a source break for any external implementer (semver-relevant on a v4 module).
  • v5preview/opensearchapi/errors.go has no test coverage for the new helpers/types (the v4 side does).

🤖 Generated with Claude Code

If this review was useful, react with 👍. Otherwise react with 👎.

Comment thread internal/envvars/envvars.go Outdated
Comment thread opensearchapi/opensearchapi.go Outdated
Comment thread opensearchapi/api_msearch.go Outdated
Comment thread opensearchapi/api_msearch-template.go Outdated
Comment thread internal/errmask/errmask.go Outdated
@sean-
sean- force-pushed the gh-816 branch 9 times, most recently from 6d0f330 to 8918b12 Compare May 30, 2026 20:35
@sean-

sean- commented May 30, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review.

On the design question: per-call vs per-client mask

You're right that tolerance is mostly a per-call-site concern, and errors.As / ToleratePartialFailures / RequireSuccessRate give callers exactly that lever. The mask isn't replacing them: it's there to set the default for whether partial failures surface at all, which is the v4 → v5 → v6 lifecycle gate. Without it we can't ship "v4 preserves silent semantics; v5 surfaces by default" without per-version forks of the dispatch code. The mask is the single knob that toggles it.

The alternative ("always return typed errors; let the caller decide") is what v5+ defaults to (errmask.Empty). v4 defaults to errmask.All so existing v4 code keeps working unchanged. Both surfaces are the same generated code.

To address the "second mechanism for the same goal" concern, this PR also reworks the surface so per-call inspection happens through methods anchored on the typed *Resp: exactly the PartialFailure() error-style approach you suggested. Each *Resp now exposes BulkItemFailures(), SearchShardFailures(), WriteShardFailures(), MultiSearchItemFailures(), plus a PartialFailures(mask) aggregator. The dispatch handler delegates to the aggregator; callers wanting focused inspection at the call site invoke the per-wrapper methods directly without going through the dispatch error. The per-bit code-gen path you noted as the source of comment 5 and comment 6 are now driven by spec-applicable predicates that walk the typed response (the class of bugs you pointed out), and the codegen emits methods not inline conditions.

Cross-cutting

Comment 1 (env var name in docs): Fixed. OPENSEARCH_GO_PARTIAL_QUERY_ERRORS was the original name; it's been renamed to OPENSEARCH_GO_ERROR_MASK (bitfield grammar) and all docs (UPGRADING.md, USER_GUIDE.md, CHANGELOG.md, guides/error_handling.md) updated to match. The internal/envvars/envvars.go comment that listed the old short tokens (bulk, query, write, all, none) is also fixed to the snake-case wrapper names actually accepted by errmask.Parse.

Comment 5 (v5preview MSearch never emits): Fixed. The walker now resolves the Responses element type, detects when it's a TypeUnion/TypeLazyUnion, and the dispatch fragment emits union-branch dispatch (resp.Type() == ...Type + resp.MsearchMultiSearchItem() / resp.ErrorResponseBase()) instead of trying to read a non-existent Shards field on the union itself. v5preview/opensearchapi/msearch_gen.go now has both SearchShardFailures() (shard aggregation across success-shaped sub-responses) and MultiSearchItemFailures() (error-shaped sub-responses) methods plus the PartialFailures(mask) aggregator.

Comment 6 (v5preview Create missing WriteShards): Fixed. cmd/osgen/api_extract.go walked only the "200" response; now it walks every 2xx (200/201/202/204) so CreateResp picks up _shards from the 201 response. v5preview/opensearchapi/create_gen.go now has the WriteShardFailures() method + PartialFailures(mask) aggregator.

Additional observations

PartialSearchError.Failures type drift: Fixed. UPGRADING.md has separate v4 and v5preview tables that document the divergence (ResponseShardsFailure -> ShardSearchFailure); guides/error_handling.md table now also surfaces both types in the Fields column.

DiscoverNodes() semantics change: Now documented in UPGRADING.md under a dedicated "DiscoverNodes() blocking semantics" section, including the user-controlled flow (DiscoverNodesOnStart: &false, DiscoverNodesInterval: 0, then call manually).

Route interface OpID(): Documented as a breaking change in UPGRADING.md under "opensearchtransport.Route interface gained OpID()" with a code sample showing the new method shape. Built-in routes built via NewRouteMux are populated automatically; only hand-written Route implementations need updating.

v5preview errors.go test coverage: Added v5preview/opensearchapi/partial_failure_methods_test.go (table driven TestRespHelperMethods covering Bulk/Search/Index/Msearch helpers + mask gating; TestNilRespHelperMethods for typed-nil safety; TestPackageErrorsHelper).

@Jakob3xD Jakob3xD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review (follow-up)

Re-reviewed at head 3bf7aa9. First: thanks for the thorough rework — I verified every prior finding against the current tree and all are addressed:

  • Env-var name (OPENSEARCH_GO_ERROR_MASK) and the errmask token list in internal/envvars/envvars.go are now correct.
  • The Config.Errors zero-value trap is resolved via the pointer (*errmask.ErrorMask, nil = unset) — exactly the disambiguation needed.
  • v5preview MSearch shard-path and Create WriteShardFailures() parity now generate.
  • The WriteShards doc no longer over-claims Bulk replica handling.
  • The design concern is resolved: the mask is now scoped to the v4→v5→v6 lifecycle gate, and per-call inspection runs through the typed *Resp methods (PartialFailures(mask) + per-wrapper methods). That's the right split.

The rework introduced new code; the items below are found in that new surface.


Found 1 high-confidence bug + 3 medium + several latent.

1. (high) The union decoder routes every error sub-response to the success branch, so MultiSearchItemFailures() / MGet per-doc error detection silently never fires.

The generated union UnmarshalJSON is try-each "first branch that unmarshals without error wins", with the success branch tried first and no discriminator/required-field guard. For MSearch, MSearchMultiSearchItem has no error field and a Status *float64, so an error sub-response {"error":{…},"status":400} unmarshals into it without error (the error key is ignored, 400 fits *float64). The ErrorRespBase branch is therefore unreachable for wire-decoded responses, and MultiSearchItemFailures() (which dispatches on ...ErrorRespBaseType) always returns nil. Net effect: MSearch() / MSearchTemplate() return a nil error even when sub-queries fail, and the MultiSearchItems wrapper is dead end-to-end. The unit test passes only because it builds the union via New…FromErrorRespBase, bypassing UnmarshalJSON. The same pattern breaks per-doc error detection in MGet (GetResult tried before MGetMultiGetError).

Fix: decode the union by discriminator (e.g. presence of the "error" key, or try the error branch first / guard on a required field) rather than first-successful-unmarshal.

Root cause (template):

func (u *{{$t.Name}}) UnmarshalJSON(data []byte) error {
u.raw = append(u.raw[:0], data...)
if len(data) == 0 || bytes.Equal(data, build.NullJSON) {
return nil
}
{{- range $t.Branches}}
{
var v {{qualify .GoType}}
if err := json.Unmarshal(data, &v); err == nil {
u.typ = {{constName $t.Name .Name}}
u.value = v
return nil
}
}

Generated symptoms:

func (u *MSearchMultiSearchResultResponsesItem) UnmarshalJSON(data []byte) error {
u.raw = append(u.raw[:0], data...)
if len(data) == 0 || bytes.Equal(data, build.NullJSON) {
return nil
}
{
var v MSearchMultiSearchItem
if err := json.Unmarshal(data, &v); err == nil {
u.typ = MSearchMultiSearchResultResponsesItemMSearchMultiSearchItemType
u.value = v
return nil
}
}
{
var v ErrorRespBase
if err := json.Unmarshal(data, &v); err == nil {
u.typ = MSearchMultiSearchResultResponsesItemErrorRespBaseType
u.value = v
return nil
}
}
return fmt.Errorf("MSearchMultiSearchResultResponsesItem: no branch matched JSON: %s", data[:min(len(data), 64)])
}

func (r *MSearchResp) MultiSearchItemFailures() *MultiSearchItemError {
if r == nil {
return nil
}
var failed []MultiSearchItemFailure
succeeded := 0
for i, resp := range r.Responses {
if resp.Type() == MSearchMultiSearchResultResponsesItemErrorRespBaseType {
failed = append(failed, MultiSearchItemFailure{
Index: i,
ErrorRespBase: resp.ErrorRespBase(),
})
} else {
succeeded++
}
}
if len(failed) == 0 {
return nil
}
return &MultiSearchItemError{
Items: failed,
SucceededCount: succeeded,
}
}
// PartialFailures returns the partial-failure sub-errors detected on the
// MSearchResp, gated by mask. Mask bits suppress their corresponding

(same union reused in msearch_template_gen.go; same class in mget_gen.go)


2. (medium) NewFromClient / NewFromClientWithErrors bypass the v5preview default-router injection, so a client built via these constructors is behaviorally inconsistent with NewClient (no default router) despite the package's "routing on by default" intent.

func NewFromClient(client *opensearch.Client) *Client {
return clientInit(client, resolveErrorMask(Config{}))
}
// NewFromClientWithErrors creates an api client from an existing
// opensearch.Client with every partial-failure category reported as an error.
func NewFromClientWithErrors(client *opensearch.Client) *Client {
none := errmask.None
return clientInit(client, resolveErrorMask(Config{Errors: &none}))
}

3. (medium) v5preview RequireSuccessRate has no *MultiSearchItemError case, so for an MSearch whose only failures are item-level it skips the threshold and returns the error unchanged (diverges from the v4 helper). Currently masked by bug #1, but becomes live once #1 is fixed.

func RequireSuccessRate(err error, threshold float64) error {
if err == nil {
return nil
}
var succeeded, total int
switch e := err.(type) { //nolint:errorlint // unwrapped switch is intentional; falls through to errors.As below
case *PartialBulkError:
succeeded = e.SucceededCount
total = e.SucceededCount + len(e.FailedItems)
case *PartialSearchError:
succeeded = e.TotalShards - e.FailedShards
total = e.TotalShards
case *ShardFailureError:
succeeded = e.TotalShards - e.FailedShards
total = e.TotalShards
default:
// Try unwrapping -- the partial error may be wrapped.
var bulkErr *PartialBulkError
var searchErr *PartialSearchError
var shardErr *ShardFailureError
switch {
case errors.As(err, &bulkErr):
succeeded = bulkErr.SucceededCount
total = bulkErr.SucceededCount + len(bulkErr.FailedItems)
case errors.As(err, &searchErr):
succeeded = searchErr.TotalShards - searchErr.FailedShards
total = searchErr.TotalShards
case errors.As(err, &shardErr):
succeeded = shardErr.TotalShards - shardErr.FailedShards
total = shardErr.TotalShards
default:
return err
}

4. (latent) collapsePerOpErrors(errs, nil) is emitted for every single-wrapper op; if a future spec change adds a second wrapper to such an op without a PerOpErrType, the len(errs) >= 2 path calls the nil wrap and panics. A defensive if wrap != nil guard would close this.

func collapsePerOpErrors(errs []error, wrap func([]error) error) error {
switch len(errs) {
case 0:
return nil
case 1:
return errs[0]
default:
return wrap(errs)
}
}
// Errors returns the partial-failure sub-errors carried by err,
// flattening any per-op multi-error wrapper (e.g. [MSearchErrors]) into
// a flat slice.
//
// Use this from caller code so a single switch/default block handles
// both the single-sub-error case (collapse rule returned the bare
// sub-error) and the multi-sub-error case (collapse returned a per-op

5. (latent) PluginMethodName does not apply the idiomatic-abbreviation rewriting that now drives type names (*Req/*Resp). A future plugin op whose suffix contains msearch/mget/termvectors/forcemerge would get e.g. type MSearchFooReq but method MsearchFoo — a mismatch. Unreachable today, but the divergence is live.

func PluginMethodName(suffix string) string {
parts := strings.FieldsFunc(suffix, func(r rune) bool {
return r == '_'
})
var sb strings.Builder
for _, p := range parts {
if len(p) > 0 {
sb.WriteString(strings.ToUpper(p[:1]) + p[1:])
}

6. (latent) errwrap fallback catalog for msearch lists only MultiSearchItems, missing SearchShards. Harmless for the core path (spec x-error-responses supplies both), but a plugin/unannotated msearch-like op falling back to errwrap.For("msearch") would silently lose the shard wrapper.

GroupUpdateByQuery = "update_by_query"
GroupDeleteByQuery = "delete_by_query"
GroupMSearch = "msearch"
GroupMSearchTemplate = "msearch_template"
GroupMGet = "mget"

7. (latent) v4 MSearchResp.SearchShardFailures() accumulates Shards.Total from every sub-response unconditionally (the v5 generated version restricts to the success branch). A server response carrying both error and a non-zero _shards.total would over-count the denominator; current OpenSearch doesn't emit that shape.

func (r *MSearchResp) SearchShardFailures() *PartialSearchError {
if r == nil {
return nil
}
var totalShards, failedShards int
var failures []ResponseShardsFailure
for _, resp := range r.Responses {
totalShards += resp.Shards.Total
failedShards += resp.Shards.Failed
failures = append(failures, resp.Shards.Failures...)
}
if failedShards == 0 {
return nil
}
return &PartialSearchError{
FailedShards: failedShards,

8. (nit) Stale doc comments in cmd/osgen/api_extract.go still say "the 200 response body" after the change to walk all 2xx; and the outer-loop continue on a non-JSON 2xx would abandon a path's remaining 2xx codes (no current spec path hits this).

ResponseRef string // schema key for the 200 response body (e.g. "cluster.health___HealthResponseBody")
// ErrorWrappers lists the partial-failure wrapper-schema names this
// operation may surface alongside its primary 2xx response. Populated
// from the x-error-responses extension on the spec operation.
ErrorWrappers []string
// ResponseSchemaRef is the resolved schema for the 200 response body,
// used to walk inline schemas that aren't in Components.Schemas.

🤖 Generated with Claude Code

If this review was useful, react with 👍. Otherwise react with 👎.

@sean-
sean- force-pushed the gh-816 branch 7 times, most recently from af807f8 to 4c00e5d Compare June 2, 2026 02:44
@sean-

sean- commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the re-review, and for verifying the prior round against the tree. All eight items are addressed; details per finding below.

1. (high) Union decoder routed error sub-responses to the success branch

Fixed at the generator template, so it covers msearch, msearch_template, and mget uniformly. The generated union UnmarshalJSON is now a two-pass decode instead of first-successful-unmarshal:

  • Pass 1 only considers branches that declare required (discriminator) keys, gated by a key probe. For this union that means the ErrorRespBase branch is tried first, guarded by build.HasJSONKeys(data, "error", "status").
  • Pass 2 is the permissive fallback (MSearchMultiSearchItem), tried only when no discriminated branch matched.

So {"error":{...},"status":400} now decodes to ErrorRespBase, and MultiSearchItemFailures() (and the MGet per doc equivalent) fire on wire-decoded responses, not just on values built via the New...FromErrorRespBase constructors.

While wiring this up I also found and fixed a latent bug in the probe itself: build.HasJSONKeys originally unmarshalled into a map[string]struct{}, which makes encoding/json error on any scalar value ({"status":400} cannot decode 400 into struct{}), so the probe returned false for every object carrying scalar fields. It now decodes into map[string]json.RawMessage. Added internal/build/union_test.go as regression coverage, and the integration suite (which decodes real wire payloads, the gap you noted) now exercises the discriminated path end to end.

2. (medium) NewFromClient bypassed default-router injection

Resolved by removing NewFromClient / NewFromClientWithErrors entirely. All construction now flows through NewClient, which centrally applies the router-injection rule (inject the default router unless the caller supplied one or OPENSEARCH_GO_ROUTER=false). There is no longer a constructor that can sidestep it.

3. (medium) v5preview RequireSuccessRate had no *MultiSearchItemError case

Added the case, matching the v4 helper:

case errors.As(err, &msearchErr):
    succeeded = msearchErr.SucceededCount
    total = msearchErr.SucceededCount + len(msearchErr.Items)

4. (latent) collapsePerOpErrors(errs, nil) could nil-wrap and panic

Added the defensive guard you suggested; with no PerOpErrType wrapper it falls back to errors.Join:

default:
    if wrap != nil {
        return wrap(errs)
    }
    return errors.Join(errs...)

5. (latent) PluginMethodName skipped the idiomatic-abbreviation rewrite

Removed PluginMethodName. Plugin method names now come from ir.Operation.MethodName, computed in package main via methodNameFromSuffix(pluginGroupSuffix(group)), which ends in applyIdiomaticAbbreviations -> the same rewrite that drives *Req/*Resp type names. Method and type names share one code path, so a msearch/mget/termvectors/forcemerge plugin op cannot diverge.

6. (latent) errwrap fallback for msearch missing SearchShards

GroupMSearch and GroupMSearchTemplate now both map to {WrapperSearchShards, WrapperMultiSearchItems}, so an unannotated/plugin msearch-like op falling back to the catalog keeps the shard wrapper.

7. (latent) v4 MSearchResp.SearchShardFailures() over-counted the denominator

Both v4 MSearchResp and MSearchTemplateResp SearchShardFailures() now skip error-shaped sub-responses before accumulating shard counts, matching the v5 generated behavior:

for _, resp := range r.Responses {
    if resp.Error != nil {
        continue
    }
    totalShards += resp.Shards.Total
    failedShards += resp.Shards.Failed
    failures = append(failures, resp.Shards.Failures...)
}

8. (nit) Stale doc comments + outer-loop continue in api_extract.go

Doc comments now read "JSON 2xx response body". The JSON-content check moved inside the per-code scan, so a non-JSON 2xx (e.g. a text/plain 200) no longer abandons a path's remaining 2xx codes: the loop keeps scanning and only breaks once it finds a JSON 2xx (e.g. a 201), then resolves the schema from that.

All unit suites (three modules), the full integration suite (both opensearchapi packages plus the plugins), and make lint.local are green. Happy to re-check anything against the pushed head.

@sean- sean- self-assigned this Jun 2, 2026

@Jakob3xD Jakob3xD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review (full pass)

I re-verified the prior two rounds against the current head (94d7dfa): all 8 findings from the last review are addressed, and the design rework (per-*Resp methods + PartialFailures(mask) aggregator, mask reduced to the lifecycle gate) is in place. Thanks for that.

This is a fresh full-PR pass; the items below are new and were cross-checked against the existing comment threads (none are already resolved). Most are posted as inline comments — F1 (internal errmask → the mask is unusable by external consumers) and F3 (RequireSuccessRate short-circuit) are the two worth prioritizing. The remaining low/latent items that don't map to a changed line:

  • F7 — perOpErrorTypeName returns MSearchErrors/MSearchTemplateErrors unconditionally for those groups. Correct today (both wrappers are jointly applicable), but if a group ever transitions to a single wrapper while the dispatch still references the per-op type, it's a compile break. The coupling between emittableWrappers and the hardcoded perOpErrorTypeName switch is implicit and untested. (cmd/osgen/emit/frag_dispatch.go, perOpErrorTypeName.)

  • F9 — classifyRefBranch omits VersionAdded. Inline union branches read x-version-added, but $ref branches always get VersionAdded: "", so sortBranchesNewestFirst orders them last regardless of actual version. No current spec union has versioned $ref branches, so no impact today. (cmd/osgen/resp_union.go, classifyRefBranch.)

  • F10 — GoName collision resolver leaves a stale seenGoName entry. After renaming the underscore-prefixed variant, seenGoName[gn] still points at the old name; a three-way _field/field collision in one schema could emit a duplicate Go field name. No such triple in the current spec. (cmd/osgen/resp_walk.go, ~seenGoName loop.)

  • F11 — envvars_test.go "unset" case sets an empty value (t.Setenv(key, "")) instead of truly unsetting, so the LookupEnv ok==false path is never exercised (duplicates the "empty string" row). (internal/envvars/envvars_test.go.)

  • F2 (doc nit) — opensearchapi/errors.go L259 doc example reads c.Msearch(ctx, req); should be c.MSearch after the naming regen.

🤖 Generated with Claude Code

If this review was useful, react with 👍. Otherwise react with 👎.

Comment thread opensearchapi/opensearchapi.go
Comment thread v5preview/opensearchapi/api.go
Comment thread opensearchapi/errors.go
Comment thread opensearchapi/errors.go Outdated
Comment thread cmd/osgen/emit/frag_union.go
Comment thread cmd/osgen/emit/frag_dispatch.go
Comment thread opensearchtransport/discovery.go
Comment thread UPGRADING.md Outdated
Comment thread guides/error_handling.md Outdated
Comment thread CHANGELOG.md Outdated
sean- added 7 commits June 3, 2026 06:58
…v opt-out

v5preview/opensearchapi.NewClient and NewDefaultClient now inject
opensearchtransport.NewDefaultRouter when config.Client.Router is nil,
opting every v5preview client into intelligent request routing
(role-aware dispatch, RTT-based scoring, congestion-window AIMD,
shard-cost weighting) by default.

The OPENSEARCH_GO_ROUTER environment variable acts as a symmetric
override: setting it to a falsy value (false/0) suppresses the
injection so Router stays nil, matching v4's "no router" behavior
without code changes. Unset, truthy, and unparseable values all
proceed with injection. Caller-provided Routers are preserved
unchanged.

The only behavioral divergence from v4's NewClient is the env-unset
row: v4 leaves Router alone, v5preview injects the default. Truthy
and falsy values keep their existing semantics.

internal/envvars: add Falsy(name) helper. Truthy returns false for
both "unset" and "explicitly opted out"; Falsy distinguishes them so
the v5preview rule can be expressed as `!envvars.Falsy(envvars.Router)`
without re-implementing the parse logic.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…ic Go names

The codegen rewrites non-idiomatic substrings produced by pascal-casing
spec names into idiomatic Go forms:

  Msearch       -> MSearch        (multi-search initialism)
  Mget          -> MGet           (multi-get initialism)
  Mtermvectors  -> MTermVectors   (initialism + compound noun)
  Termvectors   -> TermVectors    (compound noun split)
  Forcemerge    -> ForceMerge     (compound verb split)
  Response      -> Resp           (idiomatic short form)

Each rule matches at PascalCase boundaries; lowercase-suffix
variants (Responses, Responsible, ...) are preserved. The
Response -> Resp rule additionally requires the trailing
character to be uppercase, so standalone names like SearchResponse
(spec wrapper) don't collide with the operation-level <Op>Resp
response-body name.

frag_dispatch.go: the BulkItems wrapper template now resolves its
walked element type from the IR (via bulkInnerItemType, which
walks Items[] -> outer wrapper -> first pointer field -> inner
type) instead of hardcoding "BulkRespItem". Future spec or
naming changes propagate automatically.

Renames applied to hand-written types for v4/v5 consistency:
BulkResponseItem -> BulkRespItem, ErrorResponseBase -> ErrorRespBase,
MsearchErrors -> MSearchErrors, MsearchTemplateErrors ->
MSearchTemplateErrors. v4's hand-written errors.go and dispatch
sites updated to match.

Refs opensearch-project#816

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Mechanical output of cmd/osgen against the patched spec, picking
up the idiomatic abbreviations from the parent commit. Touches
every generated type that contained Msearch, Mget, Mtermvectors,
Termvectors, Forcemerge, or a compound *Response* substring.

Refs opensearch-project#816

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…for aggregations

Two classes of try-each discriminated union are replaced with single-pass
strategies. On mget this cuts decode allocations ~2.7x (1000 docs: ~43k -> ~16k
allocs/op) and time ~1.7x (4.2ms -> 2.5ms); the remaining cost is the GetResult
decode itself plus interface boxing, not the union machinery.

- Case A (merged): object unions with one permissive "primary" branch plus
  discriminated branch(es) -- mget, msearch, indices-open. The primary is
  embedded and the common case decodes in a single json.Unmarshal; each
  discriminated branch is detected by the presence of its distinguishing key and
  decoded only when matched. This drops the build.HasJSONKeys map probe (which
  was ~61% of the old allocations) and the per-item raw copy.
- Case B (lazy As<T>()): aggregation/suggest result unions carry no wire
  discriminator (avg/sum/min/max all serialize as {"value":N}, and bucket types
  collide), so they cannot be auto-selected. UnmarshalJSON only retains the raw
  bytes; generated As<ConcreteType>() accessors decode on demand into the type
  the caller requested.
- Unions fitting neither (e.g. reindex bodies, plugin-defined task status) keep
  the existing try-each decoder; the classifier logs once per union name when it
  declines to convert a wrapper-shaped union.
- All union UnmarshalJSON now aliases the owned response buffer (u.raw = data)
  rather than copying it; RawJSON() documents the borrowed-buffer contract
  (valid while the response is reachable, copy to retain).

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Regenerated output of the osgen union changes: merged single-pass decoders for
mget/msearch/indices-open success|error items, As<T>() accessors for aggregation
and suggest result unions, and buffer-aliasing UnmarshalJSON across all unions.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Add integration tests that drive real requests against the cluster and
assert the decoded shape of each union branch, covering the merged
single-pass decode and lazy As<T>() paths end-to-end per server version:

- aggregation: lazy As<T>() accessors (terms, date_histogram, stats,
  avg, sum, min, max, value_count, cardinality)
- mget: merged success|error decode (GetResult found/not-found vs
  MGetMultiGetError)
- msearch: merged success|error decode (MSearchMultiSearchItem vs
  ErrorRespBase), the first-byte-switch SearchHitsMetadataTotal union,
  and the MultiSearchItemError partial-failure surface

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
- github.com/aws/aws-sdk-go-v2/config from 1.32.18 to 1.32.20
- github.com/aws/aws-sdk-go-v2/credentials from 1.19.17 to 1.19.19
- github.com/getkin/kin-openapi from v0.139.0 to v0.140.0

Fixes: opensearch-project#852, opensearch-project#850

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
v5preview/opensearchapi/README.md covers Partial Failure Errors
(Config.Errors, errmask, OPENSEARCH_GO_ERROR_MASK, typed errors,
opensearchapi.Errors helper, per-Resp helpers, helper functions,
operation constants) and Default Router Injection (truth table for
OPENSEARCH_GO_ROUTER, opt-out semantics).

v5preview/opensearchapi/MIGRATING.md captures the v4 -> v5preview
surface delta: import path, Indices -> Index on multi-index Req types,
DocumentID -> ID on IndexReq, optional Params becomes *Params, optional
bool query params become *bool, partial-failure type renames, errmask
default flip, default Router injection. Framed as a one-time cost of
adopting the spec-generated client.

guides/error_handling.md presents Bulk, Search, and Write partial-
failure examples as paired v4/v5preview blocks. New Per-Resp helper
subsection covers BulkItemFailures, SearchShardFailures,
WriteShardFailures, MultiSearchItemFailures, PartialFailures(mask).
Error Type Reference table includes MultiSearchItemError, MSearchErrors,
MSearchTemplateErrors and accurately describes the v4 vs v5preview
internal-field-type divergence.

guides/bulk.md notes the v4/v5preview field-name and BulkResp.Items
shape divergences up front, with paired error-iteration examples for
v4's []map[string]BulkRespItem and v5preview's []BulkItem.

UPGRADING.md keeps the version-history essentials for the >=5.0
partial-failure model and v5preview Router injection and forward-links
to the new package docs.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
@Jakob3xD

Jakob3xD commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Follow-up items for a separate cleanup PR

The library code looks good — this list is non-blocking and intended to be addressed in a separate PR. Findings are grouped doc vs codegen; severities are rough (all > 10). Permalinks pinned to 5a4d450.

Docs (user-facing — examples that don't compile / mislead)

  1. NewMuxRouter() example won't compile. custom, _ := opensearchtransport.NewMuxRouter()NewMuxRouter() Router returns a single value, not (Router, error). Drop the , _.

    custom, _ := opensearchtransport.NewMuxRouter()

  2. replace directive in MIGRATING.md doesn't resolve. …/v5/opensearchapi is a package path (module path stops at …/v5), and the RHS …/v4/v5preview/opensearchapi is not a module (no go.mod there). Pasting it yields go: no module provides …. The forward-compat replace trick can't remap a subpath across module trees as written.

    replace github.com/opensearch-project/opensearch-go/v5/opensearchapi => github.com/opensearch-project/opensearch-go/v4/v5preview/opensearchapi v4.7.0

  3. *string printed with %s in the v5preview samples. In v5preview, ErrorCause.Reason is *string, so item.Error.Reason passed to %s prints a pointer, not the text (and Reason can be nil). Needs a nil-checked deref. (The v4 sample at L70 is fine — there Reason is string.)

    item.Error.Type, item.Index, id, item.Error.Reason)

    log.Printf("sub-query %d failed: %s", item.Index, item.Error.Reason)

  4. Dead switch cases in the MSearch Errors() example. MSearch only yields *PartialSearchError / *MultiSearchItemError; the *PartialBulkError and *ShardFailureError cases can never match for this op.

    case *opensearchapi.PartialBulkError:
    log.Printf("%d items failed", len(e.FailedItems))
    case *opensearchapi.ShardFailureError:
    log.Printf("%s: %d/%d shards failed", e.Operation, e.FailedShards, e.TotalShards)

Code / codegen (low / latent — most not triggered by the current spec)

  1. errDiscoveryInterrupted is unexported. DiscoverNodes returns it to external callers on the interrupt path, but they can't errors.Is it (only string-match). Consider exporting (ErrDiscoveryInterrupted) or a typed sentinel so the "retryable" signal is detectable.

    // errDiscoveryInterrupted is returned to a waiting caller when the discovery

  2. OpOther.IsWrite() == true. OpOther = -1 sets the R/W sign bit, so the unrecognized-op sentinel reports as a write. Routes using Op(OpOther) (e.g. /_snapshot/{repo}/_mount, /{index}/_settings) make external retry-safety middleware treat them as writes. Consider a sentinel that leaves bit 63 clear, or document IsWrite/IsRead as undefined for OpOther.

    const OpOther OperationID = -1

  3. Cross-allOf GoName collision bypasses the resolver (latent). resolveAllOf/collectFields dedupe merged fields by JSON name only; the per-property collision resolver runs per sub-schema, so _score from one allOf member and score from another can both map to Score → duplicate Go field. Not triggered by the committed spec.

    if !seen[f.JSONName] {
    seen[f.JSONName] = true
    t.Fields = append(t.Fields, f)
    }

  4. "int32" is dead in decodeEquivalentGroups, and two tests mislabel it. No codegen path produces the Go type string "int32" (format: int32 maps to "int"), so the "int32" entries are unreachable; the tests named int32 … actually exercise int collapse/dedup.

  5. Version-filtered properties still occupy the name set (latent). A property dropped by the version-range filter still claims its Go name, so a surviving _X sibling gets an unnecessary Raw suffix even though the bare X slot is free.

    // excludedFields collects properties dropped by the version-range

  6. Dep-bump commit message lists stale versions. The "Bump Go dependencies" message says config 1.32.18 → 1.32.20 / credentials → 1.19.19, but the committed bumps are → 1.32.22 / → 1.19.21. Documentary only (matters for CVE/audit tracking).

🤖 Generated with Claude Code

@sean-
sean- merged commit f95f305 into opensearch-project:main Jun 4, 2026
79 of 80 checks passed
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 4, 2026
…pling

perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers'
wrapper-count map are coupled by an unstated invariant: a group has a
per-op aggregator type iff its catalog entry declares 2+ wrappers. Today
both sides match, but nothing checks them, so a future catalog edit can
desync the two without any signal -- the dispatch keeps referencing a
per-op type that's no longer reachable, or worse, emits an empty type
name when a 2+-wrapper group lacks a switch arm.

Add a coupling test that asserts both directions:
  - every group naming a per-op aggregator type has 2+ wrappers in
    OperationWrappers
  - every catalog entry with 2+ wrappers has a non-empty per-op
    aggregator type

Iterates the catalog directly rather than a duplicate list of switch
arms, so a new switch arm or catalog entry is exercised automatically.

Failure messages are actionable: they name the offending group, the
current state, and the remediation (add wrappers, remove switch arm,
or add a hand-written aggregator).

Ref: opensearch-project#844 (review round 3, F7)
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 5, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 6, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 8, 2026
…pling

perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers'
wrapper-count map are coupled by an unstated invariant: a group has a
per-op aggregator type iff its catalog entry declares 2+ wrappers. Today
both sides match, but nothing checks them, so a future catalog edit can
desync the two without any signal -- the dispatch keeps referencing a
per-op type that's no longer reachable, or worse, emits an empty type
name when a 2+-wrapper group lacks a switch arm.

Add a coupling test that asserts both directions:
  - every group naming a per-op aggregator type has 2+ wrappers in
    OperationWrappers
  - every catalog entry with 2+ wrappers has a non-empty per-op
    aggregator type

Iterates the catalog directly rather than a duplicate list of switch
arms, so a new switch arm or catalog entry is exercised automatically.

Failure messages are actionable: they name the offending group, the
current state, and the remediation (add wrappers, remove switch arm,
or add a hand-written aggregator).

Ref: opensearch-project#844 (review round 3, F7)
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 9, 2026
…pling

perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers'
wrapper-count map are coupled by an unstated invariant: a group has a
per-op aggregator type iff its catalog entry declares 2+ wrappers. Today
both sides match, but nothing checks them, so a future catalog edit can
desync the two without any signal -- the dispatch keeps referencing a
per-op type that's no longer reachable, or worse, emits an empty type
name when a 2+-wrapper group lacks a switch arm.

Add a coupling test that asserts both directions:
  - every group naming a per-op aggregator type has 2+ wrappers in
    OperationWrappers
  - every catalog entry with 2+ wrappers has a non-empty per-op
    aggregator type

Iterates the catalog directly rather than a duplicate list of switch
arms, so a new switch arm or catalog entry is exercised automatically.

Failure messages are actionable: they name the offending group, the
current state, and the remediation (add wrappers, remove switch arm,
or add a hand-written aggregator).

Ref: opensearch-project#844 (review round 3, F7)
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 9, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit that referenced this pull request Jun 9, 2026
…pling (#857)

perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers'
wrapper-count map are coupled by an unstated invariant: a group has a
per-op aggregator type iff its catalog entry declares 2+ wrappers. Today
both sides match, but nothing checks them, so a future catalog edit can
desync the two without any signal -- the dispatch keeps referencing a
per-op type that's no longer reachable, or worse, emits an empty type
name when a 2+-wrapper group lacks a switch arm.

Add a coupling test that asserts both directions:
  - every group naming a per-op aggregator type has 2+ wrappers in
    OperationWrappers
  - every catalog entry with 2+ wrappers has a non-empty per-op
    aggregator type

Iterates the catalog directly rather than a duplicate list of switch
arms, so a new switch arm or catalog entry is exercised automatically.

Failure messages are actionable: they name the offending group, the
current state, and the remediation (add wrappers, remove switch arm,
or add a hand-written aggregator).

Ref: #844 (review round 3, F7)

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 9, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 10, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 10, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 10, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit to sean-/opensearch-go that referenced this pull request Jun 16, 2026
…up table

Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
sean- added a commit that referenced this pull request Jun 18, 2026
* fix(opensearchtransport): honor per-request header override over global headers

setReqGlobalHeader compared the existing header *value* against the
header *name* (req.Header.Get(k) != k), which is effectively always
true, so global headers were appended even when the caller had already
set the same key on the request. Compare for presence instead so a
per-request header suppresses the matching global default.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* fix(opensearchtransport): prevent gzip buffer-pool nil poisoning on compress error

When io.Copy or writer.Close failed, compress() returned (nil, err) but
Perform had already armed `defer collectBuffer(buf)` with that nil,
which Put a typed-nil *bytes.Buffer into the sync.Pool. The next
compress() call would Get() the nil and panic on buf.Reset().

Return the buffer on the error path so the deferred collectBuffer
recycles it, and nil-guard collectBuffer for belt-and-suspenders.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* fix(opensearchtransport): surface body-read errors during response buffering

The post-RoundTrip buffering block in Perform declared a fresh `err`
via :=, shadowing the outer return error. On a truncated body or
context cancellation mid-read, the read error was silently dropped
and res.Body was left as the (now closed) original, so callers
received a 2xx Response with an unreadable body and err == nil.

Always replace res.Body with the bytes that were read (so callers see
a partial body rather than a closed reader), and propagate the read
error when no earlier error is pending.

Adjust Client.Do to handle the new (resp != nil, err != nil) case from
Perform: branch on resp == nil for the hard-failure path and wrap the
error in ErrReadBody when a response is available, preserving the
documented contract that TestClientInterfe asserts.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* fix: close error-response body in ParseError and v5preview do()

With Config.DisableResponseBuffering=true, Perform returns the live
http.Response.Body. ParseError() drained it via io.ReadAll but never
called Close(), so the connection was not returned to http.Transport's
idle pool until GC ran the finalizer. Under sustained 4xx/5xx load this
exhausts FDs.

Close the body in ParseError after reading (a no-op NopCloser in the
buffered case), and drain+close in the v5preview do() branch where
dataPointer == nil and ParseError is bypassed entirely. The legacy
opensearchapi and plugins/{ism,security} do() helpers already route the
non-nil-body case through ParseError, so they're covered by the same
change.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* fix(transport): classify Perform body-read errors via sentinel, keep v5preview error body readable

Address review feedback on #859:

- Add opensearchtransport.ErrResponseBodyRead sentinel; Perform wraps
  response-buffering read failures with it. Client.Do now classifies via
  errors.Is so an unrelated transport error returned alongside a response
  (e.g. context cancellation during retry backoff after a retryable status)
  is no longer mislabeled ErrReadBody. errors.Is(err, context.Canceled)
  still holds, but the misleading "failed to read body" prefix is gone.
- v5preview do() no-decode error path reads the body to EOF then re-wraps
  it in a NopCloser instead of discarding it, keeping resp.Body readable and
  consistent with the ParseError path while still freeing the connection
  under DisableResponseBuffering.
- Document the (resp != nil, err != nil) invariant on the Interface contract
  and Perform godoc: callers must treat resp == nil, not err != nil, as the
  signal for a hard transport failure.
- Scope the CHANGELOG body-close claim to non-buffered mode (in the default
  buffered mode Perform already drains and closes the body).
- Add unit tests for the gzip nil-poisoning fix, header-override suppression
  (asserting len == 1, which the prior value-only test could not detect),
  body-read error surfacing, the Do classification split, and the v5preview
  body-readability regression.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* chore(osgen): silence gochecknoglobals on decodeEquivalentGroups lookup table

Pre-existing lint warning introduced in f95f305 (#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* fix(transport,signer): drain bodies on raw RoundTrip paths and close request body on signer read error

Follow-up to the review on #859, addressing the pre-existing instances of the
same "close/drain the body" bug class that Jakob's audit surfaced. These are on
direct RoundTrip paths that lack Perform's response-buffering safety net, so
closing a partially-read body genuinely defeats HTTP keep-alive.

- cluster_health.go stats poller and discovery.go's /_cat/shards,
  /_cluster/state/metadata, and /_nodes paths now drain to EOF before close via
  a deferred drain-then-close. This covers both the non-200 early returns and
  the json.Decode success paths, which stop at the end of the JSON value without
  consuming trailing bytes. The drain/close is inlined at each defer (rather
  than extracted to a helper) so bodyclose can verify the close statically.
- opensearch.Response.String() is now non-consuming: it restores Body with an
  in-memory reader after rendering, so logging a response no longer empties a
  body other code expects to read. Receiver changed to a pointer so the restore
  is visible to callers (Response is always used as *Response).
- The AWS v1 and v2 signers now close the request body on the read-error path
  in hexEncodedSha256OfRequest.
- Add tests: Response.String non-consuming, and both signers closing the
  request body when the read fails.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* refactor(transport): split Perform into Stream + buffering wrapper; drop DisableResponseBuffering

Add (*Client).Stream(req) holding the full request/retry/RoundTrip loop
with seed fallback, returning the raw RoundTrip body. The caller owns
reading and closing res.Body; the (res != nil, err != nil) invariant and
the req.URL.Host rewrite side effect are preserved.

(*Client).Perform becomes a thin buffering wrapper over Stream: io.ReadAll
+ bytes.Reader + NopCloser, with body-read failures surfaced via
ErrResponseBodyRead. Marked Deprecated for removal in v5.

Add (*opensearch.Client).Stream as a passthrough so raw consumers do not
need to type-assert c.Transport. The transport's Stream is exposed via
the new opensearch.Streamer interface; callers reach it through
ErrTransportMissingMethodStream when the configured transport does not
satisfy it. Mark (*opensearch.Client).Perform as deprecated.

Remove the untagged DisableResponseBuffering field from opensearch.Config
and opensearchtransport.Config, the disableResponseBuffering struct field,
and the constructor assignment. Perform now always buffers; Stream never
does. Add a TODO on opensearchtransport.Interface noting v5 should add
Stream and remove Perform.

Replace the two DisableResponseBuffering test sites with table-driven
TestPerformStreamBuffering and TestStreamNilBody covering both entry
points, body lifecycle, and the req.URL.Host rewrite. Preserve
TestPerformSurfacesBodyReadError and TestDoPerformErrorClassification.

Migrate opensearchutil/bulk_indexer_integration_test.go's raw Perform
polling loop to the typed client.Cluster.Health call so the new Perform
deprecation does not trigger staticcheck SA1019.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* build(lint): run golangci-lint across all build-tag combinations

golangci-lint compiles one point in the build-tag space per run, so a single
invocation can never lint every file. Two mutually-exclusive boolean axes
partition the tree:

  - integration vs !integration: every unit *_test.go is `!integration`; every
    integration test is `integration`. Setting `integration` drops all unit
    test files from the type-check; omitting it drops all integration ones.
  - multinode vs !multinode (within integration): the single-node integration
    files are `!multinode`; the multinode ones require `multinode`.

make lint.local and the CI workflow previously passed a single tag set that
included `integration` and `multinode`, so they silently skipped every unit
test file AND every single-node integration file -- those were never linted.

Feature tags (core, plugins, plugin_security, plugin_index_management) are
pure-OR alternatives that union harmlessly into every run, so complete coverage
needs one run per (integration, multinode) combination. Introduce
GOLANGCI_LINT_TAG_SETS enumerating the three runs, loop over it in lint.local
and the Docker `linters` target, and add a matrix to the CI workflow.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* test(lint): resolve issues surfaced by linting all build-tag combinations

Running golangci-lint across every (integration, multinode) build-tag
combination (see the preceding build(lint) commit) compiles source files
that the previous single-tag-set invocation never type-checked -- every
!integration unit test and every !multinode single-node integration test.
That exposed pre-existing lint findings in files that had never been linted.
This commit clears them; it is a test/quality cleanup with no runtime
behavior change.

- testifylint: replace assert.* error assertions with require.* (require-error)
  across opensearchapi, opensearchtransport, opensearchutil, plugins, signer,
  and root tests; switch require.IsType on error values to ErrorAs; convert
  exact float comparisons to require.InDelta (float-compare).
- staticcheck: pass t.Context() instead of a nil context in
  opensearch_integration_test.go (SA1012); drop a dead append (SA4010).
- gci: fix the import grouping in opensearch_integration_test.go.
- thelper: add t.Helper() to table-driven check closures in error_test.go
  and api_indices_response_test.go.
- tparallel: call t.Parallel() in the TestConnectionPoolPromotion subtests.
- gosec: validate PPROF_ADDR and stop echoing the env value into a log call
  in connection_benchmark_test.go (G706).
- gocritic: avoid append-to-different-slice aliasing in discovery_internal_test.go.
- unparam: drop the always-nil roles arg path and unused name param in test
  helpers, and assert WriteTo's byte count so its result is used; add a
  RolePolicy test that exercises a dead, role-bearing connection.
- goconst: replace repeated action/policy-name string literals with constants
  in opensearchutil/bulk_indexer.go and opensearchtransport policy files.
- lll: wrap over-long JSON fixtures and string literals.

No behavior change; test-only.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* refactor(osgen): emit net/http method constants in generated Req tests

The Req-test fragment template hard-coded the HTTP method as a quoted
string literal (e.g. "POST"), so every generated *_gen_test.go pinned
the wantMethod field to a magic string. The dispatch and plugin
fragments already route methods through HTTPMethodConst to produce
http.MethodPost et al.; the test fragment was the lone holdout.

Wire the existing HTTPMethodConst helper into the reqTest template via
a methodExpr func and add net/http to the fragment's import set so the
emitted test files reference http.MethodPost instead of "POST". Unknown
methods still fall through to a quoted literal, preserving today's
behavior for non-standard verbs.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* chore(v5preview): regenerate Req tests to use net/http method constants

Regenerated output of the osgen test fragment change: every wantMethod
literal in v5preview/opensearchapi/*_gen_test.go switches from a quoted
string ("GET", "POST", ...) to the corresponding net/http constant
(http.MethodGet, http.MethodPost, ...), and net/http is added to each
file's import set.

No behavioral change — the emitted constants resolve to the same
strings at runtime; this commit is purely the result of re-running
osgen after the template update.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* remove unused file

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* fix(osgen): make errwrap catalogs functions to drop mutable globals

errwrap.Wrappers and errwrap.OperationWrappers were exported mutable package
vars carrying //nolint:gochecknoglobals directives. Convert both to functions
that return a fresh slice/map. This removes the gochecknoglobals findings
honestly (no suppression), and makes the read-only catalogs immutable from a
caller's perspective -- callers can no longer mutate the shared backing
slice/map. The only consumers are in-package (For, sortedCanonical); update
them to call the functions.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* chore(osgen): drop dead body-drain in plugin do() no-decode path

opensearch.Do routes through the buffered (*opensearchtransport.Client).Perform,
so resp.Body in the plugin do[T] helpers is already an io.NopCloser over
a bytes.Reader -- the connection has been drained and returned to the
pool. The no-decode error-path drain added in PR #859 was only meaningful
when DisableResponseBuffering=true, which has been removed.

Strip the drain from the cmd/osgen plugin client template and the four
hand-written copies (opensearchapi, v5preview/opensearchapi,
plugins/security, plugins/ism). The helper now reads:

    if resp.IsError() {
        if dataPointer != nil {
            return resp, opensearch.ParseError(resp)
        }
        return resp, fmt.Errorf("status: %s", resp.Status())
    }

with a doc comment noting resp.Body has already been buffered and closed
by Perform.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* chore(v5preview): regenerate plugin client_gen.go

Output of `make regen` after the cmd/osgen template change in the
preceding commit. No hand edits.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* docs(buffering): rewrite guide for Do[T] vs Stream

Rewrite guides/response_buffering.md around the two entry points:
opensearch.Do[T] (typed, buffered, default; SDK owns the body) versus
opensearchtransport.Client.Stream (raw, unbuffered, caller owns the body).
Document the reason there is intentionally no typed streaming helper, and
update the proxy example to use client.Stream(req).

CHANGELOG: replace the DisableResponseBuffering "Added" entry with the
Stream/Client.Stream "Added" entry; add a Deprecated entry for Perform on
both opensearchtransport.Client and opensearch.Client (removal in v5);
drop the stale DisableResponseBuffering reference from the #859 Fixed
entry.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* test(benchmarks): fix goroutine leak and stateful body in transport benches

Three correctness/quality fixes on the transport-adjacent benchmarks
surfaced while running locally with PGO collection. No production code
changes.

opensearch_benchmark_test.go (BenchmarkClient/Create client with defaults):
  Each iteration constructed a fresh opensearch.NewClient, which spawns
  the transport's per-client cluster-health and node-stats goroutines.
  Nothing closed them, so goroutines leaked linearly with b.N and at
  high iteration counts the Go runtime starved the bench loop itself.
  Close the underlying *opensearchtransport.Client in each iteration so
  the background goroutines exit.

opensearchtransport/opensearchtransport_benchmark_test.go (BenchmarkTransport):
  - The pre-existing FakeTransport stored a single *http.Response with a
    strings.Reader body and returned the same pointer from every
    RoundTrip. strings.Reader is stateful: after the first Perform
    drained it the next iteration saw EOF, so the bench was measuring
    the EOF-handling path rather than steady-state Perform. Build a
    fresh response (with a fresh body) per RoundTrip.
  - Hoist opensearchtransport.New out of the per-iteration loop. Real
    callers build one transport per process; constructing one per
    iteration both inflates the measurement and (on this branch) leaks
    health-check goroutines. Disable the load-shedding poller via
    NodeStatsInterval = -1 so its tick rate doesn't bleed into the
    measurement.

opensearchtransport/logger_benchmark_test.go (BenchmarkTransportLogger):
  Same construction-per-iteration anti-pattern across all four
  Text/Text-Body/JSON/JSON-Body sub-benches. Collapse the four
  copy-pasted bodies into a single closure, hoist New out of the loop,
  add b.Cleanup to close the transport, and disable the load-shedding
  poller. The Text-Body case had a separate bug: it called
  res.Body.Close() before io.ReadAll(res.Body), so the read always
  returned 0 bytes against a closed body and the len < 13 branch was
  silently flagged. Read first, then close.

Drive-by: switch error format verbs from %s to %q in the touched
Fatalf/Errorf sites for safer rendering of error chains.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

* feat(github/workflows): pin remaining actions to SHA (#882)

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>

---------

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Co-authored-by: Jakob <jakob.hahn@hetzner.com>
@sean-
sean- deleted the gh-816 branch July 13, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Operation classifier + partial failure errors (v4/v5 transition)

2 participants