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
errorvalues. 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.0to anyv4.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
URLandHeaderon 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):
DisableRetrywas ignored forio.EOF,io.ErrUnexpectedEOF, and HTTP/2RST_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
SignerandConfig.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):
Streamleaked 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()printeddead_since/overloaded_sincein 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=allError 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 -
scheduleResurrectre-acquired a lock it already held; fixed by passingdeadSinceas a parameter so the value is read once before the lock is released. - Connection-pool test deadlocks -
connection_internal_test.goreleased and re-acquired locks in an order that could deadlock under-race; fixed by extracting state before releasing the lock. - Bulk-indexer
Closedeadlock - the implicit bulk-indexer client could deadlock onClosewhen the flusher had already exited; the flusher now stops via context cancellation instead of a done channel, soCloseno 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):
_idandroutingvalues 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.NumAddedovercounting items rejected on context cancellation; adds aBulkAddFailCountcounter. #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
GetRequestmethods, where an empty segment produced//thathttp.NewRequestmisparsed 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) -
RequestTimeoutreset the stream but left the connection pooled, so withEnableRetryOnTimeoutevery retry landed on the same possibly black-holed connection. A client-generated timeout now marks the node, and the next request to it carriesRequest.Close, so net/http stops offering that connection and closes it once its open streams finish. Recovering within one call needsMaxRetriesof at least 2; the default of 6 covers it. Seeguides/retry_backoff.md. #1153 DisableRetryhonored 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, andHealthCheckRequestModifierthe 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. Anopensearch.NoBodymarker covers calls that return no body.Client.Do()remains available;staticchecknow directs callers towardDo[T].Client.Close()onopensearch.Clientandopensearchapi.Clientreleases node-discovery, health/stats polling, and DNS-refresh goroutines along with idle connections.opensearchutil.NewBulkIndexercloses the client it creates implicitly.Stream()provides raw byte forwarding for proxy and streaming use cases.RequestTimeoutbounds 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. InsecureSkipVerifyconfig option disables TLS verification without a customhttp.Transport, retaining connection pooling, HTTP/2, and timeout defaults.- Reduced per-request allocations - direct
*http.Requestconstruction andsync.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. Seeguides/routing.mdand the v4.7.0 release notes.
Breaking and behavior changes in v4.8.0
v5preview/opensearchapiremoved. The package and itsplugins/subpackages are gone. The API it previewed shipped asgithub.com/opensearch-project/opensearch-go/v5/opensearchapiin v5.0.0 and has drifted from the preview since, so switching takes more than an import-path edit (v5 multi-indexReqtypes useIndiceswhere v5preview usedIndex). Move to v5, go back to v4's hand-writtenopensearchapi, or pin v4.7.3. SeeUPGRADING.md. #1171- Go 1.26.8 or newer required. The
godirective in the root module andcmd/osgengoes 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, soerrors.Is/errors.Asare unaffected; onlyerr.Error()text changes. Monitoring that matches the exact message or a start-anchored pattern needs updating. SeeUPGRADING.md. #1160 PerformandStreamrewrite the caller's*http.Requeston 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'sOnRouteevent instead. SeeUPGRADING.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
UPGRADING.md- version-by-version migration notes- v4 to v5 migration guide - the v4 -> v5 API delta
guides/error_handling.md- partial-failure errors in v4guides/retry_backoff.md- retries, timeouts, and HTTP/2 connection retirementguides/routing.md- request routing and environment-variable referenceguides/cluster_health_checking.md- thecluster:monitorpermissions the router needs on a secured clusterguides/metrics.md- client-side metrics and connection/policy/router snapshotsUSER_GUIDE.md- general usage and AWS signer migration
Contributors
Thanks to everyone who contributed to this release: @iprithv, @magic-peach, @MohammedAlkindi, @sean-, and @ryanyuan.