github opensearch-project/opensearch-go v4.8.0
V4.8.0

3 hours ago

opensearch-go v4.8.0

CRITICAL UPGRADE RELEASE NOTE - v5 has substantially expanded error handling capabilities compared to v4.

v5.0.0 is out, and v4 is now in maintenance mode. v4.8.0 is a maintenance release: it backports fixes from main and does not add features. The v4 -> v5 upgrade changes runtime error-handling behavior and is NOT a drop-in replacement across major versions. If you use this client, read this now - even if you are only upgrading to v4.8.0.

v4 only returns transport-level errors; partial failures (e.g. failed bulk items, failed shards, unconfirmed replica writes) are reported inside the response body, not as Go error values. In v5, those partial failures are returned as errors by default. Code that compiles and passes against v4 can behave differently against v5 without any code change.

Upgrading between v4 releases (any <v4.8.0 to any v4.X.X) does not change the default error-handling semantics. See Error handling below for how to adopt the v5 behavior on v4 before you upgrade, and the v4 to v5 migration guide for the full API delta.

The v4 line covers development from December 2025 through September 2026 (v4.6.0 -> v4.8.0). Two themes dominate it: a reworked error-handling model that surfaces partial failures as typed Go errors, and a rewritten transport layer with an optional client-side router.

v4.8.0 is a maintenance release on top of v4.7.3. It backports transport fixes from main, removes the v5preview/opensearchapi package now that v5 has shipped, and raises the minimum Go version to 1.26.8. Three changes need a look before upgrading: the v5preview removal, transport error strings that now carry the request method and URL, and Perform/Stream no longer rewriting your *http.Request after the first attempt. Each is covered in Breaking and behavior changes in v4.8.0.

Full Changelog: v4.7.3...v4.8.0

4.8.0 Fixes

  • fix(opensearchtransport): a data race between the retry loop and net/http's HTTP/2 header encoder, which reads the request's URL and Header on its own goroutine. Retries now work on a copy of the request. By @sean- in #1153, fixing #1121
  • fix(opensearchtransport): timeout retries kept reusing a stalled HTTP/2 connection, which is what happens after an Amazon OpenSearch Service blue/green cutover. A client-generated timeout now retires the connection through Request.Close. Backport of @iprithv's #1134 by @sean- in #1153, fixing #1121 (reported by @ymhiroki)
  • fix(opensearchtransport): DisableRetry was ignored for io.EOF, io.ErrUnexpectedEOF, and HTTP/2 RST_STREAM, so a write that had already landed could be retried and duplicated. Backport of @iprithv's #1135 in #1159
  • fix(opensearchtransport): background health-check, discovery, and node-stats requests skipped the configured Signer and Config.Header, so on Amazon OpenSearch Service with IAM-only access every poller got a 403. Backport of @iprithv's #1063 in #1086, fixing #1062 (reported by @mifisignal)
  • fix(opensearchtransport): Stream leaked the caller's request body after snapshotting it, which matters for bodies that own a resource such as an *os.File. Backport of @iprithv's #1150 in #1158
  • fix(opensearchtransport): wrap stream errors with the request method and a credential-redacted URL. Backport of @magic-peach's #1119 in #1160
  • fix(opensearchtransport): rendezvous ranking sorted the live connection list in place when shard placement was unknown, racing discovery. Backport of @iprithv's #1090 in #1100
  • fix(opensearchtransport): ConnectionMetric.String() printed dead_since/overloaded_since in the host's local time with no zone marker; both now render in UTC. Backport of @magic-peach's #1155 in #1157
  • fix(opensearchutil): a serialize error left an orphan NDJSON action line in the BulkIndexer buffer, and every later result paired with the wrong item. Backport of @iprithv's #1105 in #1161
  • fix(osgen): the git-root check rejected every output directory on Windows. Backport of @MohammedAlkindi's #1122 in #1163

1. Error handling

Background

OpenSearch returns HTTP 200 for many operations that only partially succeed: bulk requests where some items fail, searches where some shards error, and writes where a replica fails to confirm. A 2xx status code does not mean the whole operation succeeded.

Before v4.7.0, only transport errors were returned as errors and any partial or shard-level failure required inspecting response fields by hand after every call. v4.7.0 added a model that turns partial failures into typed Go errors:

Error type Returned by
*PartialBulkError Bulk
*PartialSearchError Search, MSearch, SearchTemplate, Scroll.Get
*ShardFailureError Index, Document.Create, Document.Delete, Update
*MultiSearchItemError MSearch, MSearchTemplate (per sub-response)

Which categories are returned as errors is controlled by a per-category mask on Config.Errors. When a category is masked, the operation returns its response with a nil error even though the response body records failures, and the caller is responsible for inspecting it. When a category is not masked (the v5 default), the same partial failure is returned as one of the typed errors above, and the response is still fully populated alongside the error.

v4 -> v5 Migration Path

The default mask in v4 masks all errors, preserving the existing v4 behavior of only returning transport errors.

Surface Config.Errors == nil means Effect
v4 errmask.All every category masked: partial failures are not returned as errors (preserves pre-4.7 behavior)
v5+ errmask.Empty no category masked: every partial failure is returned as an error

In other words: a v4 program that never sets Config.Errors sees the same silent behavior it always has. The identical program compiled against v5 will begin receiving partial failures as error values. You can adopt the v5 behavior on v4 first so the upgrade holds no surprises.

Transitioning to v5 error handling

Through the use of environment variables, callers can change the runtime behavior of v4 code to test and migrate to v5's error handling semantics.

# Report every partial-failure category as an error (the v5 default)
export OPENSEARCH_GO_ERROR_MASK=empty

# Report everything except bulk-item failures
export OPENSEARCH_GO_ERROR_MASK="+all,-bulk_items"

# Mask everything (the v4 default, stated explicitly)
export OPENSEARCH_GO_ERROR_MASK=all

Error masks are comma-separated, lowercase, snake_case category names (e.g. bulk_items, search_shards, write_shards, multi_search_items) with +/- prefixes (default mask operator is + if omitted). The special tokens all (mask every category) and empty (mask none) set the whole mask at once; category tokens adjust individual bits from there. Unknown tokens are ignored for forward compatibility.

In code:

// Adopt the v5 default (report every category) on v4:
client, err := opensearchapi.NewClient(opensearchapi.Config{
    Client: opensearch.Config{Addresses: []string{"https://localhost:9200"}},
    Errors: errmask.New(),
})

In v4, the default error mask is errmask.New(errmask.All), which masks everything (i.e. preserves the existing v4 behavior). A custom mask such as errmask.New(errmask.SearchShards | errmask.MultiSearchItems) masks specific categories. In v5 the default error mask is errmask.New(errmask.Empty).

Idiomatic OpenSearch Error Handling

The recommended call-site pattern is a for/switch over opensearchapi.Errors(err), which flattens single- and multi-wrapper error shapes into a uniform slice:

resp, err := client.Bulk(ctx, opensearchapi.BulkReq{Body: body})
for _, sub := range opensearchapi.Errors(err) {
    switch e := sub.(type) {
    case *opensearchapi.PartialBulkError:
        // resp is fully populated -- inspect individual items
        log.Printf("%d/%d items failed",
            len(e.FailedItems),
            e.SucceededCount+len(e.FailedItems))
        for _, item := range e.FailedItems {
            log.Printf("  %s %s/%s: %s",
                item.Error.Type, item.Index, item.ID, item.Error.Reason)
        }
    default:
        return err // transport or HTTP error
    }
}

Helpers IsPartialFailure, ToleratePartialFailures, and RequireSuccessRate support threshold-based tolerance.

See guides/error_handling.md for the full category reference and the rationale for the type switch over errors.As, and the v5 error handling guide for the v5 side.

2. Rewritten transport

The transport layer was reworked for thread safety, correctness, and lower per-request overhead.

Thread-safety and deadlock fixes

The transport migrated to a struct-embedded mutex pattern with atomic counters for hot-path state (#779, fixing #775). This work resolved three deadlocks:

  • Connection resurrection deadlock - scheduleResurrect re-acquired a lock it already held; fixed by passing deadSince as a parameter so the value is read once before the lock is released.
  • Connection-pool test deadlocks - connection_internal_test.go released and re-acquired locks in an order that could deadlock under -race; fixed by extracting state before releasing the lock.
  • Bulk-indexer Close deadlock - the implicit bulk-indexer client could deadlock on Close when the flusher had already exited; the flusher now stops via context cancellation instead of a done channel, so Close no longer races it (#932).

v4.8.0 adds one more: HTTP/2 request race - stream() reused one *http.Request across attempts and rewrote its URL and Header in place, while net/http's HTTP/2 encoder could still be reading them after a cancelled attempt. Each retry now works on a copy (#1153, fixing #1121).

Correctness fixes

  • Bulk-indexer data corruption (present since 2021): _id and routing values containing <, >, or & were HTML-escaped before transmission, so OpenSearch stored the escaped form. This caused duplicate documents, unreachable data on read-by-ID, and shard-routing mismatches. Fixed by disabling HTML escaping in the bulk meta encoder. #824
  • Goroutine leaks and permanently-dead connections - pool replacement during node discovery orphaned resurrection goroutines, leaving connections dead with no health checker; multi-to-single pool demotion leaked goroutines. Both are resolved with proper context cancellation. #786, #830
  • Seed fallback masked by unreachable discovered nodes - in NAT'd or Kubernetes clusters, discovery could insert unverified, unroutable nodes that took the request stream and prevented the seed-URL fallback from firing. A node is now routable only once confirmed reachable. #952, #956
  • BulkIndexerStats.NumAdded overcounting items rejected on context cancellation; adds a BulkAddFailCount counter. #783
  • gzip buffer-pool nil poisoning on compress error, dropped response-body read errors, and several response-body lifecycle fixes that had defeated HTTP keep-alive. #859
  • Double-slash URL paths across 74 GetRequest methods, where an empty segment produced // that http.NewRequest misparsed as an authority separator, replaced with typed path builders that reject empty required segments. #804
  • Stalled HTTP/2 connection reused on timeout retries (v4.8.0) - RequestTimeout reset the stream but left the connection pooled, so with EnableRetryOnTimeout every retry landed on the same possibly black-holed connection. A client-generated timeout now marks the node, and the next request to it carries Request.Close, so net/http stops offering that connection and closes it once its open streams finish. Recovering within one call needs MaxRetries of at least 2; the default of 6 covers it. See guides/retry_backoff.md. #1153
  • DisableRetry honored for EOF and HTTP/2 stream resets (v4.8.0). #1159
  • Background requests signed (v4.8.0) - health-check, discovery, and node-stats pollers now apply Signer, basic auth, Config.Header, and HealthCheckRequestModifier the same way user requests do. #1086
  • Compatibility fixes for OpenSearch 3.1 through 3.6. CI now tests against OpenSearch 2.19.x and 3.6.0.

New capabilities

  • opensearch.Do[T]() enforces pointer response types at compile time, preventing a class of bugs where a non-pointer value failed to decode at runtime. An opensearch.NoBody marker covers calls that return no body. Client.Do() remains available; staticcheck now directs callers toward Do[T].
  • Client.Close() on opensearch.Client and opensearchapi.Client releases node-discovery, health/stats polling, and DNS-refresh goroutines along with idle connections. opensearchutil.NewBulkIndexer closes the client it creates implicitly.
  • Stream() provides raw byte forwarding for proxy and streaming use cases. RequestTimeout bounds each attempt to prevent hangs on stalled connections.
  • Lock-free metrics - per-request counters and connection dead/overloaded timestamps moved to atomics, removing the primary metrics-snapshot lock contention. Per-request counters are now collected regardless of EnableMetrics.
  • InsecureSkipVerify config option disables TLS verification without a custom http.Transport, retaining connection pooling, HTTP/2, and timeout defaults.
  • Reduced per-request allocations - direct *http.Request construction and sync.Pool-backed buffers reduce typical operations from 8 allocations / 2930 B to 2 allocations / 472 B.
  • Optional client-side router - role-, shard-, and congestion-aware node selection in place of round-robin. Off by default in v4 and on by default in v5; enable it on v4 with OPENSEARCH_GO_ROUTER=true. See guides/routing.md and the v4.7.0 release notes.

Breaking and behavior changes in v4.8.0

  • v5preview/opensearchapi removed. The package and its plugins/ subpackages are gone. The API it previewed shipped as github.com/opensearch-project/opensearch-go/v5/opensearchapi in v5.0.0 and has drifted from the preview since, so switching takes more than an import-path edit (v5 multi-index Req types use Indices where v5preview used Index). Move to v5, go back to v4's hand-written opensearchapi, or pin v4.7.3. See UPGRADING.md. #1171
  • Go 1.26.8 or newer required. The go directive in the root module and cmd/osgen goes from 1.26.0 to 1.26.8 to pick up the standard-library security fixes released since 1.26.0. #1171
  • Transport errors include the request method and URL, for example "GET" "https://host:9200/_search": context deadline exceeded. Userinfo and the query string are stripped, since either can hold credentials. The wrap uses %w, so errors.Is/errors.As are unaffected; only err.Error() text changes. Monitoring that matches the exact message or a start-anchored pattern needs updating. See UPGRADING.md. #1160
  • Perform and Stream rewrite the caller's *http.Request on the first attempt only. Your request now reflects the first node tried, not the last, and a connection base path no longer stacks on retries (/prefix/prefix/_search). If you read the rewritten request to find which node served a call, use the router observer's OnRoute event instead. See UPGRADING.md. #1153

Dependency bumps: github.com/aws/aws-sdk-go-v2/config 1.33.5, github.com/tidwall/gjson 1.19.0, github.com/tidwall/match 1.2.0; in cmd/osgen, golang.org/x/text 0.42.0 and github.com/go-openapi/jsonpointer 1.0.1; plus routine Dependabot updates.

Upgrade guides and documentation

Contributors

Thanks to everyone who contributed to this release: @iprithv, @magic-peach, @MohammedAlkindi, @sean-, and @ryanyuan.

Don't miss a new opensearch-go release

NewReleases is sending notifications on new releases.