diff --git a/sdk/data/azcosmos/CHANGELOG.md b/sdk/data/azcosmos/CHANGELOG.md index 2e1208900046..1cd7fc94e1e6 100644 --- a/sdk/data/azcosmos/CHANGELOG.md +++ b/sdk/data/azcosmos/CHANGELOG.md @@ -10,6 +10,8 @@ ### Other Changes +* Tightened the default HTTP client: 5s dial timeout (down from azcore's 30s), 65s `http.Client.Timeout` wall-clock cap per HTTP attempt (was unbounded), larger idle connection pool (1000 total / 100 per host, up from azcore's 100 / 10), and faster HTTP/2 health checks. Caller-supplied `Transport` and shorter `context` deadlines are unaffected. See [PR 26856](https://github.com/Azure/azure-sdk-for-go/pull/26856). + ## 1.5.0-beta.6 (2026-05-15) ### Features Added diff --git a/sdk/data/azcosmos/cosmos_client.go b/sdk/data/azcosmos/cosmos_client.go index a160924e08c5..a0ee923db525 100644 --- a/sdk/data/azcosmos/cosmos_client.go +++ b/sdk/data/azcosmos/cosmos_client.go @@ -85,6 +85,7 @@ func NewClientWithKey(endpoint string, cred KeyCredential, o *ClientOptions) (*C if o != nil { preferredRegions = o.PreferredRegions } + o = withDefaultTransport(o) gem, err := newGlobalEndpointManager(endpoint, newInternalPipeline(newSharedKeyCredPolicy(cred), o), preferredRegions, 0, enableCrossRegionRetries) if err != nil { @@ -132,6 +133,7 @@ func NewClient(endpoint string, cred azcore.TokenCredential, o *ClientOptions) ( if o != nil { preferredRegions = o.PreferredRegions } + o = withDefaultTransport(o) gem, err := newGlobalEndpointManager(endpoint, newInternalPipeline(newCosmosBearerTokenPolicy(cred, scope, nil), o), preferredRegions, 0, enableCrossRegionRetries) if err != nil { return nil, err @@ -220,6 +222,29 @@ func newInternalPipeline(authPolicy policy.Policy, options *ClientOptions) azrun &options.ClientOptions) } +// withDefaultTransport returns a *ClientOptions whose Transport is set to the +// Cosmos default HTTP client when the caller has not supplied one. Callers +// of NewClient*/NewClientFromConnectionString invoke this exactly once and +// reuse the result for both the user-facing and the global-endpoint-manager +// pipelines so options are normalized in a single place. The returned value +// is always non-nil; only the top-level Transport field is set on the clone, +// so the caller's *ClientOptions struct is never mutated, but slice fields +// such as PreferredRegions or PerCallPolicies still share backing arrays +// with the caller's struct and should not be mutated in place. +func withDefaultTransport(options *ClientOptions) *ClientOptions { + if options == nil { + return &ClientOptions{ + ClientOptions: azcore.ClientOptions{Transport: defaultCosmosHTTPClient}, + } + } + if options.Transport != nil { + return options + } + clone := *options + clone.Transport = defaultCosmosHTTPClient + return &clone +} + func createScopeFromEndpoint(endpoint *url.URL) ([]string, error) { return []string{fmt.Sprintf("%s://%s/.default", endpoint.Scheme, endpoint.Hostname())}, nil } diff --git a/sdk/data/azcosmos/cosmos_default_http_client.go b/sdk/data/azcosmos/cosmos_default_http_client.go new file mode 100644 index 000000000000..abfd1057dc2f --- /dev/null +++ b/sdk/data/azcosmos/cosmos_default_http_client.go @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azcosmos + +import ( + "crypto/tls" + "net" + "net/http" + "time" + + "golang.org/x/net/http2" +) + +const ( + // defaultConnectTimeout is the default timeout for establishing a new TCP + // connection to the Cosmos service. Cosmos accounts are expected to be + // reachable from the configured/preferred region within a few hundred + // milliseconds; failing fast at the dial layer lets the global endpoint + // manager retry against the next preferred region instead of blocking on a + // dead endpoint for the azcore default of 30 seconds. + defaultConnectTimeout = 5 * time.Second + + // defaultHTTPRoundTripTimeout is the default wall-clock cap that the + // underlying *http.Client applies to a single HTTP attempt, covering + // connection setup, request write, response header read, and response body + // read. It is intentionally set as http.Client.Timeout (not as a per-try + // retry timeout) so it survives custom per-call policies and acts as a + // hard backstop against runaway requests. + // + // Trade-offs to understand before changing this value: + // + // * http.Client.Timeout is a wall-clock cap on the entire round-trip, + // including streaming the response body. A caller-supplied + // context.WithTimeout that is *longer* than this value will be + // truncated by the HTTP client; a *shorter* caller context still wins. + // This is the intended safety property: no Cosmos request should hang + // for an unbounded amount of time even if the caller forgot to set a + // deadline. + // + // * The azcore retry policy is layered above the transport, so the + // 65-second cap applies per HTTP attempt; the policy can still issue + // additional retries when one attempt exceeds the cap. + // + // * 65 seconds was chosen to exceed the Cosmos gateway's own server-side + // request budget (~60s) by a small margin so the server gets a chance + // to return a structured error (which the retry policy can interpret) + // before the client gives up locally. + // + // Callers that legitimately need to drain very large query/change-feed + // pages that take longer than this can override by supplying their own + // Transport via ClientOptions. + defaultHTTPRoundTripTimeout = 65 * time.Second +) + +// defaultCosmosHTTPClient is the http.Client used by Cosmos clients when the +// caller does not provide a custom Transport via ClientOptions. It mirrors the +// azcore default transport but uses Cosmos-specific connect and request +// timeouts. +var defaultCosmosHTTPClient *http.Client + +func init() { + defaultCosmosHTTPClient = newDefaultCosmosHTTPClient() +} + +func newDefaultCosmosHTTPClient() *http.Client { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: defaultCosmosTransportDialContext(&net.Dialer{ + Timeout: defaultConnectTimeout, + KeepAlive: 30 * time.Second, + }), + ForceAttemptHTTP2: true, + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Renegotiation: tls.RenegotiateFreelyAsClient, + }, + } + if http2Transport, err := http2.ConfigureTransports(transport); err == nil { + http2Transport.ReadIdleTimeout = 2 * time.Second + http2Transport.PingTimeout = 1 * time.Second + } + return &http.Client{ + Transport: transport, + Timeout: defaultHTTPRoundTripTimeout, + } +} diff --git a/sdk/data/azcosmos/cosmos_default_http_client_dialer_other.go b/sdk/data/azcosmos/cosmos_default_http_client_dialer_other.go new file mode 100644 index 000000000000..7824fb8c35ac --- /dev/null +++ b/sdk/data/azcosmos/cosmos_default_http_client_dialer_other.go @@ -0,0 +1,18 @@ +//go:build !wasm + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azcosmos + +import ( + "context" + "net" +) + +// defaultCosmosTransportDialContext mirrors azcore's defaultTransportDialContext +// so the Cosmos default HTTP transport behaves consistently with azcore across +// build targets. On non-WASM platforms it returns the dialer's DialContext. +func defaultCosmosTransportDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + return dialer.DialContext +} diff --git a/sdk/data/azcosmos/cosmos_default_http_client_dialer_wasm.go b/sdk/data/azcosmos/cosmos_default_http_client_dialer_wasm.go new file mode 100644 index 000000000000..27c8bc398026 --- /dev/null +++ b/sdk/data/azcosmos/cosmos_default_http_client_dialer_wasm.go @@ -0,0 +1,19 @@ +//go:build (js && wasm) || wasip1 + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azcosmos + +import ( + "context" + "net" +) + +// defaultCosmosTransportDialContext mirrors azcore's defaultTransportDialContext +// so the Cosmos default HTTP transport behaves consistently with azcore across +// build targets. On WASM/wasip1 it returns nil, which lets the runtime use the +// platform-specific HTTP transport instead of a net.Dialer-based DialContext. +func defaultCosmosTransportDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + return nil +} diff --git a/sdk/data/azcosmos/cosmos_default_http_client_test.go b/sdk/data/azcosmos/cosmos_default_http_client_test.go new file mode 100644 index 000000000000..3f97e8cd9cc8 --- /dev/null +++ b/sdk/data/azcosmos/cosmos_default_http_client_test.go @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azcosmos + +import ( + "net/http" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/internal/mock" + "github.com/stretchr/testify/require" +) + +func TestDefaultCosmosHTTPClient_Timeouts(t *testing.T) { + require.Equal(t, 5*time.Second, defaultConnectTimeout) + require.Equal(t, 65*time.Second, defaultHTTPRoundTripTimeout) + + c := newDefaultCosmosHTTPClient() + require.NotNil(t, c) + require.Equal(t, defaultHTTPRoundTripTimeout, c.Timeout) + + transport, ok := c.Transport.(*http.Transport) + require.True(t, ok, "expected *http.Transport, got %T", c.Transport) + require.NotNil(t, transport.DialContext) +} + +func TestWithDefaultTransport_NilOptions(t *testing.T) { + got := withDefaultTransport(nil) + require.NotNil(t, got) + require.Same(t, defaultCosmosHTTPClient, got.Transport) +} + +func TestWithDefaultTransport_NoTransportSet(t *testing.T) { + in := &ClientOptions{ + EnableContentResponseOnWrite: true, + } + got := withDefaultTransport(in) + require.NotSame(t, in, got, "expected withDefaultTransport to clone options") + require.Nil(t, in.Transport, "caller-supplied options must not be mutated") + require.Same(t, defaultCosmosHTTPClient, got.Transport) + require.True(t, got.EnableContentResponseOnWrite) +} + +func TestWithDefaultTransport_PreservesCallerTransport(t *testing.T) { + srv, close := mock.NewServer() + defer close() + + in := &ClientOptions{ + ClientOptions: azcore.ClientOptions{Transport: srv}, + } + got := withDefaultTransport(in) + require.Same(t, in, got, "expected withDefaultTransport to return same options when Transport is provided") + require.Same(t, srv, got.Transport) +} diff --git a/sdk/data/azcosmos/go.mod b/sdk/data/azcosmos/go.mod index b67edc4b41a5..398a58d16f98 100644 --- a/sdk/data/azcosmos/go.mod +++ b/sdk/data/azcosmos/go.mod @@ -7,6 +7,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.52.0 ) require ( @@ -18,7 +19,6 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect