diff --git a/go.mod b/go.mod index ea02ecf..1587041 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( require ( github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bufbuild/httplb v0.4.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect diff --git a/go.sum b/go.sum index 831b530..6c8dc66 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bufbuild/httplb v0.4.1 h1:f8dMp7tx2aJfMX2UcOId1A58QDiBag7Dv6BA1OtV/YA= +github.com/bufbuild/httplb v0.4.1/go.mod h1:9XDjl/3UvlkOQUKthLlKn92C1/1SuZ3UCiekxZbenck= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/httpclient/httplb.go b/httpclient/httplb.go new file mode 100644 index 0000000..8cb6917 --- /dev/null +++ b/httpclient/httplb.go @@ -0,0 +1,135 @@ +package httpclient + +import ( + "crypto/tls" + "net/http" + "time" + + "github.com/bufbuild/httplb" +) + +const ( + // DefaultRequestTimeout bounds each outbound request through the load-balanced client. + DefaultRequestTimeout = 30 * time.Second + // DefaultIdleConnTimeout controls how long idle pooled connections are retained. + DefaultIdleConnTimeout = 90 * time.Second + // DefaultMaxIdleConns caps idle connections across all hosts. + DefaultMaxIdleConns = 64 + // DefaultMaxIdleConnsPerHost caps idle connections retained per host. + DefaultMaxIdleConnsPerHost = 16 + // DefaultMaxConnsPerHost caps concurrent connections to a single host. + DefaultMaxConnsPerHost = 32 +) + +// LoadBalancedOptions controls EvalOps' default outbound HTTP client. +type LoadBalancedOptions struct { + RequestTimeout time.Duration + IdleConnTimeout time.Duration + MaxIdleConns int + MaxIdleConnsPerHost int + MaxConnsPerHost int + MaxResponseHeaderSize int + TLSClientConfig *tls.Config + TLSHandshakeTimeout time.Duration + DisableCompression bool +} + +// LoadBalancedClient owns an httplb client and exposes its standard *http.Client. +type LoadBalancedClient struct { + *http.Client + + client *httplb.Client +} + +var sharedDefaultClient = NewLoadBalanced(LoadBalancedOptions{}) + +// DefaultClient returns a shared load-balanced client for nil-client fallbacks. +func DefaultClient() *http.Client { + return sharedDefaultClient.Client +} + +// NewLoadBalanced creates an HTTP client with DNS-backed client-side balancing. +func NewLoadBalanced(options LoadBalancedOptions) *LoadBalancedClient { + options = options.withDefaults() + lbOptions := []httplb.ClientOption{ + httplb.WithRequestTimeout(options.RequestTimeout), + httplb.WithIdleConnectionTimeout(options.IdleConnTimeout), + httplb.WithTransport("http", loadBalancedTransport{options: options}), + httplb.WithTransport("https", loadBalancedTransport{options: options}), + } + if options.TLSClientConfig != nil { + lbOptions = append( + lbOptions, + httplb.WithTLSConfig(options.TLSClientConfig, options.TLSHandshakeTimeout), + ) + } + if options.MaxResponseHeaderSize > 0 { + lbOptions = append(lbOptions, httplb.WithMaxResponseHeaderBytes(options.MaxResponseHeaderSize)) + } + if options.DisableCompression { + lbOptions = append(lbOptions, httplb.WithDisableCompression(true)) + } + + client := httplb.NewClient(lbOptions...) + return &LoadBalancedClient{ + Client: client.Client, + client: client, + } +} + +// Close releases resolver and transport resources owned by the httplb client. +func (c *LoadBalancedClient) Close() error { + if c == nil || c.client == nil { + return nil + } + return c.client.Close() +} + +func (o LoadBalancedOptions) withDefaults() LoadBalancedOptions { + if o.RequestTimeout <= 0 { + o.RequestTimeout = DefaultRequestTimeout + } + if o.IdleConnTimeout <= 0 { + o.IdleConnTimeout = DefaultIdleConnTimeout + } + if o.MaxIdleConns <= 0 { + o.MaxIdleConns = DefaultMaxIdleConns + } + if o.MaxIdleConnsPerHost <= 0 { + o.MaxIdleConnsPerHost = DefaultMaxIdleConnsPerHost + } + if o.MaxConnsPerHost <= 0 { + o.MaxConnsPerHost = DefaultMaxConnsPerHost + } + return o +} + +type loadBalancedTransport struct { + options LoadBalancedOptions +} + +func (t loadBalancedTransport) NewRoundTripper( + _ string, + _ string, + config httplb.TransportConfig, +) httplb.RoundTripperResult { + transport := &http.Transport{ + Proxy: config.ProxyFunc, + GetProxyConnectHeader: config.ProxyConnectHeadersFunc, + DialContext: config.DialFunc, + ForceAttemptHTTP2: true, + MaxIdleConns: t.options.MaxIdleConns, + MaxIdleConnsPerHost: t.options.MaxIdleConnsPerHost, + MaxConnsPerHost: t.options.MaxConnsPerHost, + IdleConnTimeout: config.IdleConnTimeout, + TLSHandshakeTimeout: config.TLSHandshakeTimeout, + TLSClientConfig: config.TLSClientConfig, + MaxResponseHeaderBytes: config.MaxResponseHeaderBytes, + ExpectContinueTimeout: time.Second, + DisableCompression: config.DisableCompression, + } + return httplb.RoundTripperResult{ + RoundTripper: transport, + Close: transport.CloseIdleConnections, + } +} diff --git a/httpclient/httplb_test.go b/httpclient/httplb_test.go new file mode 100644 index 0000000..4be5add --- /dev/null +++ b/httpclient/httplb_test.go @@ -0,0 +1,82 @@ +package httpclient + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewLoadBalancedUsesDefaults(t *testing.T) { + t.Parallel() + + client := NewLoadBalanced(LoadBalancedOptions{}) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + + if client.Client == nil { + t.Fatal("expected embedded http client") + } + if client.Transport == nil { + t.Fatal("expected httplb transport") + } + if client.Timeout != 0 { + t.Fatalf("Timeout = %s, want httplb request timeout only", client.Timeout) + } +} + +func TestNewLoadBalancedHonorsCustomOptions(t *testing.T) { + t.Parallel() + + client := NewLoadBalanced(LoadBalancedOptions{ + RequestTimeout: 2 * time.Second, + IdleConnTimeout: time.Second, + MaxIdleConns: 8, + MaxIdleConnsPerHost: 4, + MaxConnsPerHost: 6, + }) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + + if client.Timeout != 0 { + t.Fatalf("Timeout = %s, want httplb request timeout only", client.Timeout) + } +} + +func TestLoadBalancedClientSendsRequests(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Path; got != "/healthz" { + t.Errorf("path = %q, want /healthz", got) + } + _, _ = fmt.Fprint(w, "ok") + })) + defer server.Close() + + client := NewLoadBalanced(LoadBalancedOptions{RequestTimeout: time.Second}) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + + response, err := client.Get(server.URL + "/healthz") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer func() { + _ = response.Body.Close() + }() + + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) + } +} diff --git a/vendor/github.com/bufbuild/httplb/.gitignore b/vendor/github.com/bufbuild/httplb/.gitignore new file mode 100644 index 0000000..987d6a1 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/.gitignore @@ -0,0 +1,4 @@ +/.tmp/ +*.pprof +*.svg +cover.out diff --git a/vendor/github.com/bufbuild/httplb/.golangci.yml b/vendor/github.com/bufbuild/httplb/.golangci.yml new file mode 100644 index 0000000..42285ca --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/.golangci.yml @@ -0,0 +1,81 @@ +linters-settings: + errcheck: + check-type-assertions: true + forbidigo: + forbid: + - '^fmt\.Print' + - '^log\.' + - '^print$' + - '^println$' + - '^panic$' + - '^clocktest\.' + - '^clockwork\.' + godox: + # TODO, OPT, etc. comments are fine to commit. Use FIXME comments for + # temporary hacks, and use godox to prevent committing them. + keywords: [FIXME, NOCOMMIT] + varnamelen: + ignore-decls: + - T any + - i int + - wg sync.WaitGroup + - ok bool + - w http.ResponseWriter + - r *http.Request +linters: + enable-all: true + disable: + - cyclop # covered by gocyclo + - depguard # unnecessary for small libraries + - exhaustruct # not helpful, prevents idiomatic struct literals + - funlen # rely on code review to limit function length + - gocognit # dubious "cognitive overhead" quantification + - gofumpt # prefer standard gofmt + - goimports # rely on gci instead + - inamedparam # convention is not followed + - ireturn # "accept interfaces, return structs" isn't ironclad + - lll # don't want hard limits for line length + - maintidx # covered by gocyclo + - mnd # some unnamed constants are okay + - nlreturn # generous whitespace violates house style + - nonamedreturns # named returns are fine; it's *bare* returns that are bad + - tenv # deprecated in golangci v1.64.0 + - testpackage # internal tests are fine + - wrapcheck # don't _always_ need to wrap errors + - wsl # generous whitespace violates house style +issues: + exclude-dirs-use-default: false + exclude: + # Don't ban use of fmt.Errorf to create new errors, but the remaining + # checks from err113 are useful. + - "do not define dynamic errors.*" + # This gosec error is noisy with false positives. + - "G115: integer overflow conversion" + exclude-rules: + # Needlessly verbose inside of test-only code. + - path: (.+)_test\.go + linters: + - forcetypeassert + # Allow dot imports for testing. + - path: (.+)_test\.go + text: "^dot-imports: should not use dot imports" + linters: + - revive + # Allow clocktest within tests + - path: (.+)_test\.go + text: "^use of `clocktest\\." + linters: + - forbidigo + # Allow clockwork within clocktest + - path: internal/clocktest + text: "^use of `clockwork\\." + linters: + - forbidigo + - path: client_test\.go + linters: + # These tests examine goroutines to make sure all resources + # are cleaned up, which doesn't work when run in parallel. + - paralleltest + # For some reason, this linter is coming up with several + # false positives in this file ¯\_(ツ)_/¯ + - bodyclose diff --git a/vendor/github.com/bufbuild/httplb/LICENSE b/vendor/github.com/bufbuild/httplb/LICENSE new file mode 100644 index 0000000..3e1d480 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023-2025 Buf Technologies, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/bufbuild/httplb/Makefile b/vendor/github.com/bufbuild/httplb/Makefile new file mode 100644 index 0000000..faa23b1 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/Makefile @@ -0,0 +1,78 @@ +# See https://tech.davis-hansson.com/p/make/ +SHELL := bash +.DELETE_ON_ERROR: +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := all +MAKEFLAGS += --warn-undefined-variables +MAKEFLAGS += --no-builtin-rules +MAKEFLAGS += --no-print-directory +BIN := .tmp/bin +export PATH := $(BIN):$(PATH) +export GOBIN := $(abspath $(BIN)) +COPYRIGHT_YEARS := 2023-2025 +LICENSE_IGNORE := --ignore /testdata/ + +.PHONY: help +help: ## Describe useful make targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-30s %s\n", $$1, $$2}' + +.PHONY: all +all: ## Build, test, and lint (default) + $(MAKE) test + $(MAKE) lint + +.PHONY: clean +clean: ## Delete intermediate build artifacts + @# -X only removes untracked files, -d recurses into directories, -f actually removes files/dirs + git clean -Xdf + +.PHONY: test +test: build ## Run unit tests + go test -vet=off -race -cover ./... + +.PHONY: build +build: generate ## Build all packages + go build ./... + +.PHONY: generate +generate: $(BIN)/license-header ## Regenerate code and licenses + go mod tidy + license-header \ + --license-type apache \ + --copyright-holder "Buf Technologies, Inc." \ + --year-range "$(COPYRIGHT_YEARS)" $(LICENSE_IGNORE) + +.PHONY: lint +lint: $(BIN)/golangci-lint $(BIN)/checklocks ## Lint + go vet ./... + #go vet -vettool=$(BIN)/checklocks ./... + golangci-lint run + +.PHONY: lintfix +lintfix: $(BIN)/golangci-lint ## Automatically fix some lint errors + golangci-lint run --fix + +.PHONY: install +install: ## Install all binaries + go install ./... + +.PHONY: upgrade +upgrade: ## Upgrade dependencies + go get -u -t ./... && go mod tidy -v + +.PHONY: checkgenerate +checkgenerate: + @# Used in CI to verify that `make generate` doesn't produce a diff. + test -z "$$(git status --porcelain | tee /dev/stderr)" + +$(BIN)/license-header: Makefile + @mkdir -p $(@D) + go install github.com/bufbuild/buf/private/pkg/licenseheader/cmd/license-header@v1.29.0 + +$(BIN)/golangci-lint: Makefile + @mkdir -p $(@D) + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.7 + +$(BIN)/checklocks: Makefile + @mkdir -p $(@D) + go install gvisor.dev/gvisor/tools/checklocks/cmd/checklocks@v0.0.0-20250313000854-906fb319cc3a diff --git a/vendor/github.com/bufbuild/httplb/README.md b/vendor/github.com/bufbuild/httplb/README.md new file mode 100644 index 0000000..57dc4c0 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/README.md @@ -0,0 +1,100 @@ +# httplb + +[![Build](https://github.com/bufbuild/httplb/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/bufbuild/httplb/actions/workflows/ci.yaml) +[![Report Card](https://goreportcard.com/badge/github.com/bufbuild/httplb)](https://goreportcard.com/report/github.com/bufbuild/httplb) +[![GoDoc](https://pkg.go.dev/badge/github.com/bufbuild/httplb.svg)](https://pkg.go.dev/github.com/bufbuild/httplb) + +[`httplb`](https://pkg.go.dev/github.com/bufbuild/httplb) +provides client-side load balancing for `net/http` clients. By default, +clients are designed for server-to-server and RPC workloads: + +* They support HTTP/1.1, HTTP/2, and [H2C](https://en.wikipedia.org/wiki/HTTP/2#Encryption). +* They periodically re-resolve names using DNS. +* They use a round-robin load balancing policy. + +Random, fewest-pending, and power-of-two load balancing policies are also available. +Clients with more complex needs can customize the underlying transports, name +resolution, and load balancing. They can also add subsetting and active health +checking. + +`httplb` takes care to build all this functionality underneath the standard library's +`*http.Client`, so `httplb` is usable anywhere you're currently using `net/http`. + +## Example + +Here's a quick example of how to get started with `httplb`: + +```go +package main + +import ( + "log" + "net" + "time" + + "github.com/bufbuild/httplb" + "github.com/bufbuild/httplb/picker" +) + +func main() { + client := httplb.NewClient( + // Switch from the default round-robin policy to power-of-two. + httplb.WithPicker(picker.NewPowerOfTwo), + ) + defer client.Close() + resp, err := client.Get("https://example.com") + if err != nil { + log.Fatalln(err) + } + defer resp.Body.Close() + log.Println(resp.Status) +} +``` + +If you're using [Connect](https://github.com/connectrpc/connect-go), you can +use `httplb` for your RPC clients: + +```go +func main() { + client := httplb.NewClient() + defer client.Close() + pingClient := pingv1connect.NewPingServiceClient( + client, + "http://localhost:8080/", + ) + req := connect.NewRequest(&pingv1.PingRequest{ + Number: 42, + }) + res, err := pingClient.Ping(context.Background(), req) + if err != nil { + log.Fatalln(err) + } + log.Println(res.Msg) +} +``` + +If you know the server supports HTTP/2 without TLS (HTTP/2 over cleartext, or H2C for short), use +the `h2c` scheme in your URLs instead of `http`: + +```go + pingClient := pingv1connect.NewPingServiceClient( + client, + "h2c://localhost:8080/", + ) +``` + +For more information on how to use `httplb`, especially for advanced use cases, take +a look at the [full documentation](https://pkg.go.dev/github.com/bufbuild/httplb). + +## Ecosystem + +* [connect-go](https://github.com/bufbuild/connect-go): RPC library, compatible with `httplb`. + +## Status: Alpha + +This project is currently in **alpha**. The API should be considered unstable and likely to change. + +## Legal + +Offered under the [Apache 2 license](LICENSE). + diff --git a/vendor/github.com/bufbuild/httplb/attribute/attribute.go b/vendor/github.com/bufbuild/httplb/attribute/attribute.go new file mode 100644 index 0000000..047dc45 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/attribute/attribute.go @@ -0,0 +1,112 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package attribute provides a type-safe container of custom attributes +// named Values. This can be used to add custom metadata to a resolved +// address. Custom attributes are declared using [NewKey] to create a +// strongly-typed key. The values can then be defined using the key's +// Value method. +// +// The following example declares two custom attributes, a floating point +// "weight", and a string "geographic region". It then constructs a new +// resolved Address that has values for each of them. +// +// var ( +// Weight = attribute.NewKey[float64]() +// GeographicRegion = attribute.NewKey[string]() +// +// Address = resolver.Address{ +// HostPort: "111.222.123.234:5432", +// Attributes: attribute.NewValues( +// Weight.Value(1.25), +// GeographicRegion.Value("us-east1"), +// ), +// } +// ) +// +// Custom [Resolver] implementations can attach any kind of metadata +// to an address this way. This can be combined with a [custom picker] +// that uses the metadata, which can access the properties in a type-safe way +// using the [GetValue] function. +// +// Such metadata can be used to implement regional affinity or to implement +// a weighted round-robin or random selection strategy (where a weight could +// be used to send more traffic to an address that has more available +// resources, such as more compute, memory, or network bandwidth). +// +// [Resolver]: https://pkg.go.dev/github.com/bufbuild/httplb/resolver#Resolver +// [custom picker]: https://pkg.go.dev/github.com/bufbuild/httplb/picker#Picker +package attribute + +// Values is a collection of type-safe custom metadata values. +// It contains a mapping of [Key] to value for any number of +// attribute keys. +type Values struct { + data map[any]any +} + +// NewValues creates a new Values object with the provided values. +// +// Use this function in tandem with [Key.Value], like this: +// +// var testKey = attribute.NewKey[string]() +// ... +// attribute.NewValues(testKey.Value("test")) +func NewValues(values ...Value) Values { + data := make(map[any]any) + for _, attr := range values { + data[attr.key] = attr.value + } + return Values{ + data: data, + } +} + +// Key is an attribute key. Applications should use NewKey to create +// a new key for each distinct attribute. The type T is the type of +// values this attribute can have. +type Key[T any] struct { + // can't be empty or else pointers won't be distinct + _ bool +} + +// NewKey returns a new key that can have values of type T. Each call +// to NewKey results in a distinct attribute key, even if multiple are +// created for the same type. (Keys are identified by their address.) +func NewKey[T any]() *Key[T] { + return new(Key[T]) +} + +// Value constructs a new Attr value, which can be passed to [NewValues]. +func (k *Key[T]) Value(value T) Value { + return Value{key: k, value: value} +} + +// Value is a single custom attribute, composed of a key and +// corresponding value. +type Value struct { + key, value any +} + +// GetValue retrieves a single value from the given Values. If the key is not +// present, the zero value and false will be returned instead. +func GetValue[T any](values Values, key *Key[T]) (value T, ok bool) { + val, ok := values.data[key] + if !ok { + var zero T + return zero, false + } + tval, ok := val.(T) + return tval, ok +} diff --git a/vendor/github.com/bufbuild/httplb/balancer.go b/vendor/github.com/bufbuild/httplb/balancer.go new file mode 100644 index 0000000..4d42e4d --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/balancer.go @@ -0,0 +1,573 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httplb + +import ( + "context" + "errors" + "io" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/bufbuild/httplb/conn" + "github.com/bufbuild/httplb/health" + "github.com/bufbuild/httplb/internal" + "github.com/bufbuild/httplb/internal/conns" + "github.com/bufbuild/httplb/picker" + "github.com/bufbuild/httplb/resolver" + "golang.org/x/sync/errgroup" +) + +var ( + errResolverReturnedNoAddresses = errors.New("resolver returned no addresses") // +checklocksignore: mu is not required, but happens to always be held. + errNoHealthyConnections = errors.New("unavailable: no healthy connections") // +checklocksignore: mu is not required, but happens to always be held. +) + +const ( + reresolveMinInterval = 5 * time.Second + reresolveMinPercent = 50 +) + +func newBalancer( + ctx context.Context, + picker func(prev picker.Picker, allConns conn.Conns) picker.Picker, + checker health.Checker, + pool connPool, + roundTripperMaxLifetime time.Duration, +) *balancer { + ctx, cancel := context.WithCancel(ctx) + balancer := &balancer{ + ctx: ctx, + cancel: cancel, + pool: pool, + newPicker: picker, + healthChecker: checker, + roundTripperMaxLifetime: roundTripperMaxLifetime, + resolverUpdates: make(chan struct{}, 1), + recycleConns: make(chan struct{}, 1), + closed: make(chan struct{}), + connInfo: map[conn.Conn]connInfo{}, + clock: internal.NewRealClock(), + } + balancer.connManager.updateFunc = balancer.updateConns + return balancer +} + +// connPool is an abstraction of *transportPool that makes it easier to test the balancer logic +// (see balancertesting.NewFakeConnPool). +type connPool interface { + NewConn(resolver.Address) (conn.Conn, bool) + RemoveConn(conn.Conn) bool + UpdatePicker(picker.Picker, bool) + ResolveNow() +} + +type balancer struct { + //nolint:containedCtx + ctx context.Context // +checklocksignore: mu is not required, but happens to always be held. + cancel context.CancelFunc + pool connPool + newPicker func(prev picker.Picker, allConns conn.Conns) picker.Picker // +checklocksignore: mu is not required, but happens to always be held. + healthChecker health.Checker // +checklocksignore: mu is not required, but happens to always be held. + connManager connManager + + roundTripperMaxLifetime time.Duration // +checklocksignore: mu is not required, but happens to always be held. + + // NB: only set from tests + updateHook func([]resolver.Address, []conn.Conn) + + closed chan struct{} + // closedErr is written before writing to closed chan, so can only be read + // if closed chan is read first (this pattern is only safe/non-racy for + // situations with a single writer) + closedErr error + + latestAddrs atomic.Pointer[[]resolver.Address] + latestErr atomic.Pointer[error] + resolverUpdates chan struct{} + recycleConns chan struct{} + clock internal.Clock + + mu sync.Mutex + // +checklocks:mu + latestPicker picker.Picker + // +checklocks:mu + latestUsableConns conns.Set + // +checklocks:mu + conns []conn.Conn + // +checklocks:mu + connInfo map[conn.Conn]connInfo + // +checklocks:mu + reresolveLastCall time.Time + // +checklocks:mu + connsToRecycle []conn.Conn +} + +func (b *balancer) UpdateHealthState(connection conn.Conn, state health.State) { + b.mu.Lock() + defer b.mu.Unlock() + info, ok := b.connInfo[connection] + if !ok { + // when closing, we may remove an entry from b.connInfo, but + // associated checker is still closing, so we may get a late + // arriving update that we can ignore + return + } + if info.state == state { + // no change, nothing else to do + return + } + info.state = state + b.connInfo[connection] = info + b.newPickerLocked() +} + +func (b *balancer) warmedUp(connection conn.Conn) { + b.mu.Lock() + defer b.mu.Unlock() + info, ok := b.connInfo[connection] + if !ok { + // when closing, we may remove an entry from b.connInfo, but + // associated warm-up routine could still be running, so we + // may get a late arriving update that we can ignore + return + } + info.warm = true + b.connInfo[connection] = info + b.newPickerLocked() +} + +func (b *balancer) OnResolve(addresses []resolver.Address) { + clone := make([]resolver.Address, len(addresses)) + copy(clone, addresses) + b.latestAddrs.Store(&clone) + select { + case b.resolverUpdates <- struct{}{}: + default: + } +} + +func (b *balancer) OnResolveError(err error) { + b.latestErr.Store(&err) + select { + case b.resolverUpdates <- struct{}{}: + default: + } +} + +func (b *balancer) Close() error { + b.cancel() + + // Don't return until everything is done. + <-b.closed + return b.closedErr +} + +func (b *balancer) start() { + go b.receiveAddrs(b.ctx) +} + +func (b *balancer) receiveAddrs(ctx context.Context) { + defer func() { + // Shutdown conn manager and health check process on the way out. + grp, _ := errgroup.WithContext(context.Background()) + var closeErr atomic.Pointer[error] + doClose := func(closer io.Closer) func() error { + return func() error { + if err := closer.Close(); err != nil { + // We don't return an error since that would cancel all + // of the other outstanding close tasks. We don't really + // need to track all of the errors may happen, so we'll + // just keep track of the last one. + closeErr.CompareAndSwap(nil, &err) + } + return nil + } + } + func() { + b.mu.Lock() + defer b.mu.Unlock() + for key, info := range b.connInfo { + delete(b.connInfo, key) + closer := info.closeChecker + info.cancel() + if closer != nil { + grp.Go(doClose(closer)) + } + } + }() + _ = grp.Wait() + // All done! + errPtr := closeErr.Load() + if errPtr != nil { + b.closedErr = *errPtr + } + close(b.closed) + }() + for { + select { + case <-ctx.Done(): + return + case <-b.recycleConns: + b.mu.Lock() + connsToRecycle := b.connsToRecycle + b.connsToRecycle = nil + b.mu.Unlock() + if len(connsToRecycle) > 0 { + // TODO: In practice, this will often recycle all connections at the same time + // since they are often created at the same time (when we get results + // from the resolver) and they all have the same max lifetime. + // This should be fine in vast majority of cases, but it would pose + // resource-usage issues if, for example, there were 1000s of TLS + // connections (or even 100s of TLS connections that use client certs and + // a computationally expensive private key algorithm like RSA). We might + // consider rate-limiting here the rate at which we recycle connections, + // allowing connections to live a little beyond their max lifetime just + // so we don't recycle everything all at once. + b.connManager.recycleConns(connsToRecycle) + } + + case <-b.resolverUpdates: + addrs := b.latestAddrs.Load() + if addrs == nil { + errPtr := b.latestErr.Load() + if errPtr == nil { + // No addresses and no error? Should not be possible... + // We'll ignore until we see one or the other. + continue + } + // reset error once we've observed it + b.latestErr.CompareAndSwap(errPtr, nil) + resolveErr := *errPtr + if resolveErr == nil { + // OnError(nil) was called, but should not have been + resolveErr = errors.New("internal: resolver failed but did not report error") + } + b.setErrorPicker(resolveErr) + continue + } + // TODO: Look at latestErr and log if non-nil? As is, a resolver could + // provide addresses and then subsequently provide errors, but those + // errors will be effectively ignored and the original addresses + // will continue to be used. + // TODO: If we can get an update that says zero addresses but no error, + // should we respect it, and potentially close all connections? + // For now, we ignore the update, and keep the last known addresses. + if len(*addrs) > 0 { + addrsClone := make([]resolver.Address, len(*addrs)) + copy(addrsClone, *addrs) + b.connManager.reconcileAddresses(addrsClone) + } + } + } +} + +func (b *balancer) updateConns(newAddrs []resolver.Address, removeConns []conn.Conn) []conn.Conn { + if b.updateHook != nil { + b.updateHook(newAddrs, removeConns) + } + numAdded := len(newAddrs) + numRemoved := len(removeConns) + addConns := make([]conn.Conn, 0, numAdded) + for _, addr := range newAddrs { + newConn, ok := b.pool.NewConn(addr) + if ok { + addConns = append(addConns, newConn) + } + } + setToRemove := make(map[conn.Conn]struct{}, numRemoved) + for _, c := range removeConns { + setToRemove[c] = struct{}{} + } + + // we wait until we've created a new picker before actually removing + // the connections from the underlying pool + defer func() { + for _, c := range removeConns { + b.pool.RemoveConn(c) + } + }() + var checkClosers []io.Closer + defer func() { + for _, c := range checkClosers { + _ = c.Close() + } + }() + b.mu.Lock() + defer b.mu.Unlock() + newConns := make([]conn.Conn, 0, len(b.conns)+numAdded-numRemoved) + for _, existing := range b.conns { + if _, ok := setToRemove[existing]; ok { + // close health check process for this connection + // and omit it from newConns + info := b.connInfo[existing] + delete(b.connInfo, existing) + info.cancel() + if info.closeChecker != nil { + checkClosers = append(checkClosers, info.closeChecker) + } + continue + } + newConns = append(newConns, existing) + } + newConns = append(newConns, addConns...) + b.initConnInfoLocked(addConns) + b.conns = newConns + b.newPickerLocked() + return addConns +} + +// +checklocks:b.mu +func (b *balancer) initConnInfoLocked(conns []conn.Conn) { + for i := range conns { + connection := conns[i] + connCtx, connCancel := context.WithCancel(b.ctx) + healthChecker := b.healthChecker.New(connCtx, connection, b) + cancel := connCancel + if b.roundTripperMaxLifetime != 0 { + timer := b.clock.AfterFunc(b.roundTripperMaxLifetime, func() { + b.recycle(connection) + }) + cancel = func() { + connCancel() + timer.Stop() + } + } + b.connInfo[connection] = connInfo{closeChecker: healthChecker, cancel: cancel} + go func() { + if err := connection.Prewarm(connCtx); err == nil { + b.warmedUp(connection) + } + }() + } +} + +// +checklocks:b.mu +func (b *balancer) newPickerLocked() { + usable := b.computeUsableConnsLocked() + if len(usable) == 0 { + addrs := b.latestAddrs.Load() + if addrs == nil || len(*addrs) == 0 { + b.setErrorPickerLocked(errResolverReturnedNoAddresses) + } + // TODO: Should we set the picker to fail? Or should we let the client + // continue with previous picker (which may also fail, but it's + // not guaranteed to fail). Or maybe the client should be able to + // await (up to time limit) connections becoming healthy instead + // of failing fast? + b.setErrorPickerLocked(errNoHealthyConnections) + return + } + usableSet := conns.SetFromSlice(usable) + if !usableSet.Equals(b.latestUsableConns) { + // only recreate picker if the connections actually changed + b.latestPicker = b.newPicker(b.latestPicker, conns.FromSlice(usable)) + b.latestUsableConns = usableSet + } + b.pool.UpdatePicker(b.latestPicker, b.isWarmLocked(usable)) +} + +// +checklocks:b.mu +func (b *balancer) isWarmLocked(conns []conn.Conn) bool { + // TODO: possible future extension: make the definition of warm configurable + for _, connection := range conns { + info := b.connInfo[connection] + if info.warm && info.state == health.StateHealthy { + return true + } + } + return false +} + +// +checklocks:b.mu +func (b *balancer) computeUsableConnsLocked() []conn.Conn { + // TODO: possible future extension: make the strategy for which connections to use configurable + connsByState := map[health.State][]conn.Conn{} + for _, connection := range b.conns { + connState := b.connInfo[connection].state + connsByState[connState] = append(connsByState[connState], connection) + } + // TODO: possible future extension: make these hard-coded values configurable + minConns := 3 + if minPctConns := int(math.Round(float64(len(b.conns)) * 0.25)); minPctConns > minConns { + minConns = minPctConns + } + + var results []conn.Conn + for candidateState := health.StateHealthy; candidateState != health.StateUnhealthy; candidateState++ { + results = append(results, connsByState[candidateState]...) + if len(results) >= minConns { + break + } + } + + // If we have less usable connections than the reresolve threshold, reresolve. + // TODO: make some of these options configurable + numHealthy := len(connsByState[health.StateHealthy]) + numTotal := len(b.conns) + if int(math.Round(reresolveMinPercent*float64(numTotal)/100)) >= numHealthy { + if b.reresolveLastCall.IsZero() || b.clock.Since(b.reresolveLastCall) > reresolveMinInterval { + b.reresolveLastCall = b.clock.Now() + b.pool.ResolveNow() + } + } + + return results +} + +func (b *balancer) setErrorPicker(err error) { + b.mu.Lock() + defer b.mu.Unlock() + b.setErrorPickerLocked(err) +} + +// +checklocks:b.mu +func (b *balancer) setErrorPickerLocked(err error) { + b.pool.UpdatePicker(picker.ErrorPicker(err), false) +} + +func (b *balancer) recycle(c conn.Conn) { + b.mu.Lock() + defer b.mu.Unlock() + b.connsToRecycle = append(b.connsToRecycle, c) + // Notify goroutine that there is a connection to recycle. + select { + case b.recycleConns <- struct{}{}: + default: + } +} + +type connInfo struct { + state health.State + warm bool + + // Cancels any in-progress warm-up and also cancels any timer + // for recycling the connection. Invoked when the connection + // is closed. + cancel context.CancelFunc + closeChecker io.Closer +} + +type connManager struct { + // only used by a single goroutine, so no mutex necessary + connsByAddr map[string][]conn.Conn + + updateFunc func([]resolver.Address, []conn.Conn) []conn.Conn +} + +func (c *connManager) reconcileAddresses(addrs []resolver.Address) { + // TODO: future extension: make connection establishing strategy configurable + // (which would allow more sophisticated connection strategies in the face + // of, for example, layer-4 load balancers) + var newAddrs []resolver.Address + var toRemove []conn.Conn + // We allow subsetter to select the same address more than once. So + // partition addresses by hostPort, to make reconciliation below easier. + desired := make(map[string][]resolver.Address, len(addrs)) + for _, addr := range addrs { + desired[addr.HostPort] = append(desired[addr.HostPort], addr) + } + remaining := make(map[string][]conn.Conn, len(c.connsByAddr)) + + for hostPort, got := range c.connsByAddr { + want := desired[hostPort] + if len(want) > len(got) { + // sync attributes of existing connection with new values from resolver + for i := range got { + got[i].UpdateAttributes(want[i].Attributes) + } + // and schedule new connections to be created + remaining[hostPort] = got + newAddrs = append(newAddrs, want[len(got):]...) + } else { + // sync attributes of existing connection with new values from resolver + for i := range want { + got[i].UpdateAttributes(want[i].Attributes) + } + // schedule extra connections to be removed + remaining[hostPort] = got[:len(want)] + toRemove = append(toRemove, got[len(want):]...) + } + } + for hostPort, want := range desired { + if _, ok := c.connsByAddr[hostPort]; ok { + // already checked in loop above + continue + } + newAddrs = append(newAddrs, want...) + } + + c.connsByAddr = remaining + c.doUpdate(newAddrs, toRemove) +} + +func (c *connManager) doUpdate(newAddrs []resolver.Address, toRemove []conn.Conn) { + // we make a single call to update connections in batch to create a single + // new picker (avoids potential picker churn from making one change at a time) + newConns := c.updateFunc(newAddrs, toRemove) + // add newConns to set of connections + for _, cn := range newConns { + hostPort := cn.Address().HostPort + c.connsByAddr[hostPort] = append(c.connsByAddr[hostPort], cn) + } +} + +func (c *connManager) recycleConns(connsToRecycle []conn.Conn) { + var needToCompact bool + for i, cn := range connsToRecycle { + addr := cn.Address().HostPort + existing := c.connsByAddr[addr] + var found bool + for i, existingConn := range existing { + if existingConn == cn { + found = true + // remove cn from the slice + copy(existing[i:], existing[i+1:]) + existing[len(existing)-1] = nil // don't leak memory + c.connsByAddr[addr] = existing[:len(existing)-1] + break + } + } + if !found { + // this connection has already been closed/removed + connsToRecycle[i] = nil + needToCompact = true + } + } + if needToCompact { + // TODO: when we can rely on Go 1.21, we should change this + // to use new std-lib function slices.DeleteFunc + i := 0 + for _, cn := range connsToRecycle { + if cn != nil { + connsToRecycle[i] = cn + i++ + } + } + if i == 0 { + // nothing to actually recycle + return + } + connsToRecycle = connsToRecycle[:i] + } + newAddrs := make([]resolver.Address, len(connsToRecycle)) + for i := range connsToRecycle { + newAddrs[i] = connsToRecycle[i].Address() + } + + c.doUpdate(newAddrs, connsToRecycle) +} diff --git a/vendor/github.com/bufbuild/httplb/client.go b/vendor/github.com/bufbuild/httplb/client.go new file mode 100644 index 0000000..be8ef83 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/client.go @@ -0,0 +1,448 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httplb + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/bufbuild/httplb/conn" + "github.com/bufbuild/httplb/health" + "github.com/bufbuild/httplb/picker" + "github.com/bufbuild/httplb/resolver" +) + +//nolint:gochecknoglobals +var ( + defaultDialer = &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + defaultNameTTL = 5 * time.Minute + defaultResolver = resolver.NewDNSResolver(net.DefaultResolver, resolver.PreferIPv4, defaultNameTTL) +) + +// Client is an HTTP client that supports configurable client-side load +// balancing, name resolution, and subsetting. It embeds the standard library's +// *[http.Client] and exposes some additional operations. +// +// If working with a library that requires an *[http.Client], use the embedded +// Client. If working with a library that requires an [http.RoundTripper], use +// the embedded client's Transport field. +// +// It is safe to wrap the client's Transport field with middleware. This should +// be done immediately after calling NewClient since mutating the embedded client +// is not safe once the client is being used. +type Client struct { + *http.Client + transport *mainTransport +} + +// NewClient constructs a new HTTP client optimized for server-to-server +// communication. By default, the client re-resolves addresses every 5 minutes +// and uses a round-robin load balancing policy. +func NewClient(options ...ClientOption) *Client { + var opts clientOptions + for _, opt := range options { + opt.applyToClient(&opts) + } + opts.applyDefaults() + transport := newTransport(&opts) + return &Client{ + Client: &http.Client{ + Transport: transport, + CheckRedirect: opts.redirectFunc, + }, + transport: transport, + } +} + +// Close the HTTP client, releasing any resources and stopping any associated +// background goroutines. +func (c *Client) Close() error { + c.transport.close() + return nil +} + +// prewarm pre-warms the HTTP client, making sure that any targets configured +// via WithAllowBackendTarget have been warmed up. This ensures that relevant +// addresses are resolved, any health checks performed, connections eagerly +// established where possible, etc. +// +// The given context should usually have a timeout, so that this step can +// fail if it takes too long. Most warming errors manifest as excessive +// delays vs. outright failure because the background machinery that gets +// transports ready will keep re-trying instead of giving up and failing +// fast. +func (c *Client) prewarm(ctx context.Context) error { + // TODO: Expose prewarm capability from this package and export this? + // For now, this is just used from tests. + return c.transport.prewarm(ctx) +} + +// ClientOption is an option used to customize the behavior of an HTTP client +// that is created via NewClient. +type ClientOption interface { + applyToClient(*clientOptions) +} + +// WithRootContext configures the root context used for any background +// goroutines that an HTTP client may create. If not specified, +// [context.Background] is used. +// +// If the given context is cancelled (or times out), many functions of the +// HTTP client may fail to operate correctly. It should only be cancelled +// after the HTTP client is no longer in use, and may be used to eagerly +// free any associated resources. +func WithRootContext(ctx context.Context) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.rootCtx = ctx + }) +} + +// WithIdleTransportTimeout configures a timeout for how long an idle +// transport will remain available. Transports are created per target +// host. When a transport is closed, all underlying connections are +// also closed. +// +// This differs from WithIdleConnectionTimeout in that it is for +// managing client resources, to prevent the underlying set of transports +// from growing too large if the HTTP client is used for dynamic outbound +// requests. Whereas WithIdleConnectionTimeout is to coordinate with +// servers that close idle connections. +// +// If zero or no WithIdleTransportTimeout option is used, a default of +// 15 minutes will be used. +// +// To prevent transports from being closed due to being idle, set an +// arbitrarily large timeout (i.e. math.MaxInt64) or use WithAllowBackendTarget. +func WithIdleTransportTimeout(duration time.Duration) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.idleTransportTimeout = duration + }) +} + +// WithRoundTripperMaxLifetime configures a limit for how long a single +// round tripper (or "leaf" transport) will be used. If no option is given, +// round trippers are retained indefinitely, until their parent transport +// is closed (which can happen if the transport is idle; see +// WithIdleTransportTimeout). When a round tripper reaches its maximum +// lifetime, a new one is first created to replace it. Any in-progress +// operations are allowed to finish, but no new operations will use it. +// +// This function is mainly useful when the target host is a layer-4 proxy. +// In this situation, it is possible for multiple round trippers to all +// get connected to the same backend host, resulting in poor load +// distribution. With a lifetime limit, a single round tripper will get +// "recycled", and its replacement is likely to be connected to a +// different backend host. So when a transport gets into a scenario where +// it has poor backend diversity, the lifetime limit allows it to self-heal. +func WithRoundTripperMaxLifetime(duration time.Duration) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.roundTripperMaxLifetime = duration + }) +} + +// WithTransport returns an option that uses a custom transport to create +// [http.RoundTripper] instances for the given URL scheme. This allows +// one to override the default implementations for "http", "https", and +// "h2c" schemes and also allows one to support custom URL schemes that +// map to these custom transports. +func WithTransport(scheme string, transport Transport) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + if opts.schemes == nil { + opts.schemes = map[string]Transport{} + } + opts.schemes[scheme] = transport + }) +} + +// WithAllowBackendTarget configures the client to only allow requests to the +// given target (identified by URL scheme and host:port). +// +// When configured this way, connections to the backend target will be kept warm, +// meaning that associated transports will not be closed due to inactivity, +// regardless of the idle transport timeout configuration. +// +// The scheme and host:port given must match those of associated requests. So +// if requests omit the port from the URL (for example), then the hostPort +// given here should also omit the port. +func WithAllowBackendTarget(scheme, hostPort string) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + dest := target{scheme: scheme, hostPort: hostPort} + if dest.scheme == "" { + dest.scheme = "http" + } + opts.allowedTarget = &dest + }) +} + +// WithResolver configures the HTTP client to use the given resolver, which +// is how hostnames are resolved into individual addresses for the underlying +// connections. +// +// If not provided, the default resolver will resolve A and AAAA records +// using net.DefaultResolver. +func WithResolver(res resolver.Resolver) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.resolver = res + }) +} + +// WithPicker configures the HTTP client to use the given function to create +// picker instances. The factory is invoked to create a new picker every time +// the set of usable connections changes for a target. +func WithPicker(newPicker func(prev picker.Picker, allConns conn.Conns) picker.Picker) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.newPicker = newPicker + }) +} + +// WithHealthChecks configures the HTTP client to use the given health +// checker. This provides details about which resolved addresses are +// healthy or not. +// +// If no such option is given, then no health checking is done and all +// connections will be considered healthy. +func WithHealthChecks(checker health.Checker) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.healthChecker = checker + }) +} + +// WithProxy configures how the HTTP client interacts with HTTP proxies for +// reaching remote hosts. +// +// The given proxyFunc returns the URL of a proxy server to use for the +// given HTTP request. If no proxy should be used, it should return nil, nil. +// If an error is returned, the request fails immediately with that error. +// If this function is set to nil or no WithProxy option is provided, +// [http.ProxyFromEnvironment] will be used as the proxyFunc. (Also see +// WithNoProxy.) +// +// The given onProxyConnectFunc, if non-nil, provides a way to examine the +// response from the proxy for a CONNECT request. If the onProxyConnectFunc +// returns an error, the request will fail immediately with that error. +// +// The given proxyConnectHeadersFunc, if non-nil, provides a way to supply +// extra request headers to the proxy for a CONNECT request. The target +// provided to this function is the "host:port" to which to connect. If no +// extra headers should be added to the request, the function should return +// nil, nil. If the function returns an error, the request will fail +// immediately with that error. +func WithProxy( + proxyFunc func(*http.Request) (*url.URL, error), + proxyConnectHeadersFunc func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error), +) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.proxyFunc = proxyFunc + opts.proxyConnectHeadersFunc = proxyConnectHeadersFunc + }) +} + +// WithNoProxy returns an option that disables use of HTTP proxies. +func WithNoProxy() ClientOption { + return WithProxy( + // never use a proxy + func(*http.Request) (*url.URL, error) { return nil, nil }, + nil) +} + +// WithDefaultTimeout limits requests that otherwise have no timeout to +// the given timeout. Unlike WithRequestTimeout, if the request's context +// already has a deadline, then no timeout is applied. Otherwise, the +// given timeout is used and applies to the entire duration of the request, +// from sending the first request byte to receiving the last response byte. +func WithDefaultTimeout(duration time.Duration) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.defaultTimeout = duration + opts.requestTimeout = 0 + }) +} + +// WithRequestTimeout limits all requests to the given timeout. This time +// is the entire duration of the request, including sending the request, +// writing the request body, waiting for a response, and consuming the +// response body. +func WithRequestTimeout(duration time.Duration) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.defaultTimeout = 0 + opts.requestTimeout = duration + }) +} + +// WithDialer configures the HTTP client to use the given function to +// establish network connections. If no WithDialer option is provided, +// a default [net.Dialer] is used that uses a 30-second dial timeout and +// configures the connection to use TCP keep-alive every 30 seconds. +func WithDialer(dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.dialFunc = dialFunc + }) +} + +// WithTLSConfig adds custom TLS configuration to the HTTP client. The +// given config is used when using TLS to communicate with servers. The +// given timeout is applied to the TLS handshake step. If the given timeout +// is zero or no WithTLSConfig option is used, a default timeout of 10 +// seconds will be used. +func WithTLSConfig(config *tls.Config, handshakeTimeout time.Duration) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.tlsClientConfig = config + opts.tlsHandshakeTimeout = handshakeTimeout + }) +} + +// WithMaxResponseHeaderBytes configures the maximum size of response headers +// to consume. If zero or if no WithMaxResponseHeaderBytes option is used, the +// HTTP client will default to a 1 MB limit (2^20 bytes). +func WithMaxResponseHeaderBytes(limit int) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.maxResponseHeaderBytes = int64(limit) + }) +} + +// WithIdleConnectionTimeout configures a timeout for how long an idle +// connection will remain open. If zero or no WithIdleConnectionTimeout +// option is used, idle connections will be left open indefinitely. If +// backend servers or intermediary proxies/load balancers place time +// limits on idle connections, this should be configured to be less +// than that time limit, to prevent the client from trying to use a +// connection could be concurrently closed by a server for being idle +// for too long. +func WithIdleConnectionTimeout(duration time.Duration) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.idleConnTimeout = duration + }) +} + +// WithRedirects configures how the HTTP client handles redirect responses. +// If no such option is provided, the client will not follow any redirects. +func WithRedirects(redirectFunc RedirectFunc) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.redirectFunc = redirectFunc + }) +} + +// WithDisableCompression configures the HTTP client to not automatically +// request compressed responses, and disables automatic response decompression. +func WithDisableCompression(ok bool) ClientOption { + return clientOptionFunc(func(opts *clientOptions) { + opts.disableCompression = ok + }) +} + +// RedirectFunc is a function that advises an HTTP client on whether to +// follow a redirect. The given req is the redirected request, based on +// the server's previous status code and "Location" header, and the given +// via is the set of requests already issued, each resulting in a redirect. +// The via slice is sorted oldest first, so the first element is the always +// the original request and the last element is the latest redirect. +// +// See FollowRedirects. +type RedirectFunc func(req *http.Request, via []*http.Request) error + +// FollowRedirects is a helper to create a RedirectFunc that will follow +// up to the given number of redirects. If a request sequence results in more +// redirects than the given limit, the request will fail. +func FollowRedirects(limit int) RedirectFunc { + return func(_ *http.Request, via []*http.Request) error { + if len(via) > limit { + return fmt.Errorf("too many redirects (> %d)", limit) + } + return nil + } +} + +type clientOptionFunc func(*clientOptions) + +func (f clientOptionFunc) applyToClient(opts *clientOptions) { + f(opts) +} + +type clientOptions struct { + rootCtx context.Context //nolint:containedctx + idleTransportTimeout time.Duration + roundTripperMaxLifetime time.Duration + schemes map[string]Transport + redirectFunc func(req *http.Request, via []*http.Request) error + allowedTarget *target + resolver resolver.Resolver + newPicker func(prev picker.Picker, allConns conn.Conns) picker.Picker + healthChecker health.Checker + dialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + proxyFunc func(*http.Request) (*url.URL, error) + proxyConnectHeadersFunc func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error) + maxResponseHeaderBytes int64 + idleConnTimeout time.Duration + tlsClientConfig *tls.Config + tlsHandshakeTimeout time.Duration + defaultTimeout time.Duration + requestTimeout time.Duration + disableCompression bool +} + +func (opts *clientOptions) applyDefaults() { + if opts.rootCtx == nil { + opts.rootCtx = context.Background() + } + if opts.idleTransportTimeout == 0 { + opts.idleTransportTimeout = 15 * time.Minute + } + if opts.schemes == nil { + opts.schemes = map[string]Transport{} + } + if opts.redirectFunc == nil { + opts.redirectFunc = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + } + // put default factories for http, https, and h2c if necessary + if _, ok := opts.schemes["http"]; !ok { + opts.schemes["http"] = simpleTransport{} + } + if _, ok := opts.schemes["https"]; !ok { + opts.schemes["https"] = simpleTransport{} + } + if _, ok := opts.schemes["h2c"]; !ok { + opts.schemes["h2c"] = h2cTransport{} + } + if opts.resolver == nil { + opts.resolver = defaultResolver + } + if opts.newPicker == nil { + opts.newPicker = picker.NewRoundRobin + } + if opts.healthChecker == nil { + opts.healthChecker = health.NopChecker + } + if opts.dialFunc == nil { + opts.dialFunc = defaultDialer.DialContext + } + if opts.proxyFunc == nil { + opts.proxyFunc = http.ProxyFromEnvironment + } + if opts.maxResponseHeaderBytes == 0 { + opts.maxResponseHeaderBytes = 1 << 20 + } + if opts.tlsHandshakeTimeout == 0 { + opts.tlsHandshakeTimeout = 10 * time.Second + } +} diff --git a/vendor/github.com/bufbuild/httplb/conn/conn.go b/vendor/github.com/bufbuild/httplb/conn/conn.go new file mode 100644 index 0000000..1703c3a --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/conn/conn.go @@ -0,0 +1,57 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package conn provides the representation of a logical connection. +// A connection is the primitive used for load balancing by the +// [github.com/bufbuild/httplb] package. A single connection generally +// wraps a single transport (or [http.RoundTripper]) to a single +// resolved address. +package conn + +import ( + "context" + "net/http" + + "github.com/bufbuild/httplb/attribute" + "github.com/bufbuild/httplb/resolver" +) + +// Conn represents a connection to a resolved address. It is a *logical* +// connection. It may actually be represented by zero or more physical +// connections (i.e. sockets). +type Conn interface { + // RoundTrip sends a request using this connection. This is the same as + // [http.RoundTripper]'s method of the same name except that it accepts + // a callback that, if non-nil, is invoked when the request is completed. + RoundTrip(req *http.Request, whenDone func()) (*http.Response, error) + // Scheme returns the URL scheme to use with this connection. + Scheme() string + // Address is the address to which this value is connected. + Address() resolver.Address + // UpdateAttributes updates the connection's address to have the given attributes. + UpdateAttributes(attributes attribute.Values) + // Prewarm attempts to pre-warm this connection. Not all transports support + // this operation. For those that do not, calling this function is a no-op. + // It returns an error if the given context is cancelled or times out before + // the connection can finish warming up. + Prewarm(context.Context) error +} + +// Conns represents a read-only set of connections. +type Conns interface { + // Len returns the total number of connections in the set. + Len() int + // Get returns the connection at index i. + Get(i int) Conn +} diff --git a/vendor/github.com/bufbuild/httplb/doc.go b/vendor/github.com/bufbuild/httplb/doc.go new file mode 100644 index 0000000..007cf5c --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/doc.go @@ -0,0 +1,128 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package httplb provides http.Client instances that are suitable +// for use for server-to-server communications, like RPC. This adds features +// on top of the standard net/http library for name/address resolution, +// health checking, and load balancing. It also provides more suitable +// defaults and much simpler support for HTTP/2 over plaintext. +// +// To create a new client use the [NewClient] function. This function +// accepts numerous options, many for configuring the behavior of the +// underlying transports. It also provides options for using a custom +// [name resolver] or a custom [load balancing policy] and for enabling +// active [health checking]. +// +// The returned client also has a notion of "closing", via its Close +// method. This step will wait for outstanding requests to complete and +// then close all connections and also teardown any other goroutines that +// it may have started to perform name resolution and health checking. +// The client cannot be used after it has been closed. +// +// # Default Behavior +// +// Without any options, the returned client behaves differently than +// http.DefaultClient in the following key ways: +// +// 1. The client will re-resolve addresses in DNS every 5 minutes. +// The http.DefaultClient does not re-resolve predictably. +// +// 2. The client will route requests in a round-robin fashion to the +// addresses returned by the DNS system, preferring A records if +// present (but using AAAA records if no A records are present), +// even with HTTP/2. +// +// This differs from the http.DefaultClient, which will use only a +// single connection if it can, even if DNS resolves many addresses. +// With HTTP 1.1, an http.DefaultClient will create additional +// connections to handle multiple concurrent requests (since an HTTP +// 1.1 connection can only service one request at a time). But with +// HTTP/2, it likely will *never* use additional connections: it +// only creates another connection if the concurrency limit exceeds +// the server's "max concurrent streams" (which the server provides +// when the connection is initially established and is typically on +// the order of 100). +// +// The above behavior alone should make the [httplb.Client] distribute load +// to backends in a much more appropriate way, especially when using HTTP/2. +// But the real power of a client returned by this package is that it +// can be customized with different name resolution and load balancing +// policies, via the [WithResolver] and [WithPicker] options. Active health +// checking can be enabled via the [WithHealthChecks] option. +// +// Note that the behavior regarding A and AAAA records differs from the +// http.DefaultClient. In http.DefaultClient, the underlying connections use +// net.Dial directly on the provided hostname from the URL, and net.Dial in +// turn implements an RFC 6555 fallback to ensure that connections can be +// established even in the face of broken IPv6 configurations. Meanwhile, +// [httplb.Client] defaults to resolving the name eagerly and treating the +// resolved addresses as individual targets instead. In order to ensure +// maximum compatibility out of the box, the default behavior is to prefer +// IPv4 addresses whenever they are available: if DNS resolution returns +// both IPv4 and IPv6 addresses, only the IPv4 addresses will be used. +// Meanwhile, if DNS resolution only returns IPv6 addresses, those will be +// used instead. To override this behavior, use [WithResolver] and instantiate +// the DNS resolver with a different [resolver.AddressFamilyPolicy] value. +// For example, to prefer IPv6 addresses instead, one could use: +// +// client := httplb.NewClient( +// httplb.WithResolver( +// resolver.NewDNSResolver( +// net.DefaultResolver, +// resolver.PreferIPv6, +// 5 * time.Minute, // TTL value +// ), +// ), +// ) +// +// # Transport Architecture +// +// The clients created by this function use a transport implementation +// that is actually a hierarchy of three layers: +// +// 1. The "root" of the hierarchy (which is the actual Transport field +// value of an http.Client returned by NewClient) serves as a collection +// of transport pools, grouped by URL scheme and hostname:port. For a +// given request, it examines the URL's scheme and hostname:port to +// decide which transport pool should handle the request. +// 2. The next level of the hierarchy is the transport pool. A transport +// pool manages multiple transports that all correspond to the same +// URL scheme and hostname:port. This is the layer in which most of +// the features are implemented, like name resolution and load balancing. +// Each transport pool manages a pool of "leaf" transports, each to a +// potentially different resolved address. +// 3. The bottom of the hierarchy, the "leaf" transports, are just +// http.RoundTripper implementations that each represent a single, +// logical connection to a single resolved address. A leaf transport +// could actually consist of more than one *physical* connection, like +// when using HTTP 1.1 and multiple active requests are made to the same +// leaf transport. This level of transport is often an *http.Transport, +// that handles physical connections to the remote host and implements +// the actual HTTP protocol. +// +// # Custom Transports +// +// One of the options in this package, [WithTransport], allows users to +// implement custom transports and select them using custom URL schemes. +// This could be used, for example, to enable HTTP/3 with a URL such as +// "http3://...". +// +// This package even uses this capability to provide simple use of HTTP/2 +// over plaintext, also called "h2c". In addition to supporting "http" and +// "https" URL schemes, this package also supports "h2c" as a URL scheme. +// +// [name resolver]: https://pkg.go.dev/github.com/bufbuild/httplb/resolver#Resolver +// [load balancing policy]: https://pkg.go.dev/github.com/bufbuild/httplb/picker#Picker +// [health checking]: https://pkg.go.dev/github.com/bufbuild/httplb/health#Checker +package httplb diff --git a/vendor/github.com/bufbuild/httplb/h2c_go123.go b/vendor/github.com/bufbuild/httplb/h2c_go123.go new file mode 100644 index 0000000..974f4e7 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/h2c_go123.go @@ -0,0 +1,46 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !go1.24 + +package httplb + +import ( + "context" + "crypto/tls" + "net" + + "golang.org/x/net/http2" +) + +// h2cTransport provides support for the "h2c" scheme, which is used to force +// HTTP/2 over clear-text (no TLS), aka H2C. Prior to Go 1.24, we must use +// features of the golang.org/x/net/http2 client implementation to make this +// work. +type h2cTransport struct{} + +func (s h2cTransport) NewRoundTripper(_, _ string, opts TransportConfig) RoundTripperResult { + // We can't support all round tripper options with H2C. + transport := &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + return defaultDialer.DialContext(ctx, network, addr) + }, + // We don't bother setting the TLS config, because h2c is plain-text only + //TLSClientConfig: opts.TLSClientConfig, + MaxHeaderListSize: uint32(opts.MaxResponseHeaderBytes), + DisableCompression: opts.DisableCompression, + } + return RoundTripperResult{RoundTripper: transport, Scheme: "http", Close: transport.CloseIdleConnections} +} diff --git a/vendor/github.com/bufbuild/httplb/h2c_go124.go b/vendor/github.com/bufbuild/httplb/h2c_go124.go new file mode 100644 index 0000000..03e8a5b --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/h2c_go124.go @@ -0,0 +1,56 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build go1.24 + +package httplb + +import ( + "net/http" + "time" +) + +// h2cTransport provides support for the "h2c" scheme, which is used to force +// HTTP/2 over clear-text (no TLS), aka H2C. As of Go 1.24, this basically +// looks just like simpleTransport (used for "http" and "https" schemes), +// except that it must adjust the transport's supported protocols (as well as +// transform the "h2c" scheme to "http"). +// +// As of Go 1,24, many more transport config options can be supported with +// H2C. +type h2cTransport struct{} + +func (s h2cTransport) NewRoundTripper(_, _ string, opts TransportConfig) RoundTripperResult { + var protocols http.Protocols + protocols.SetUnencryptedHTTP2(true) + + transport := &http.Transport{ + Proxy: opts.ProxyFunc, + GetProxyConnectHeader: opts.ProxyConnectHeadersFunc, + DialContext: opts.DialFunc, + ForceAttemptHTTP2: true, + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + IdleConnTimeout: opts.IdleConnTimeout, + TLSHandshakeTimeout: opts.TLSHandshakeTimeout, + TLSClientConfig: opts.TLSClientConfig, + MaxResponseHeaderBytes: opts.MaxResponseHeaderBytes, + ExpectContinueTimeout: 1 * time.Second, + DisableCompression: opts.DisableCompression, + Protocols: &protocols, + } + // no way to populate pre-warm function since http.Transport doesn't provide + // any way to do that :( + return RoundTripperResult{RoundTripper: transport, Scheme: "http", Close: transport.CloseIdleConnections} +} diff --git a/vendor/github.com/bufbuild/httplb/health/checker.go b/vendor/github.com/bufbuild/httplb/health/checker.go new file mode 100644 index 0000000..70148ff --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/health/checker.go @@ -0,0 +1,62 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package health + +import ( + "context" + "io" + + "github.com/bufbuild/httplb/conn" +) + +//nolint:gochecknoglobals +var ( + // NopChecker is a checker implementation that does nothing. It assumes + // the state of all connections is healthy. + NopChecker Checker = nopChecker{} +) + +// Checker manages health checks. It creates new checking processes as new +// connections are created. Each process can be independently stopped. +type Checker interface { + // New creates a new health-checking process for the given connection. + // The process should release resources (including stopping any goroutines) + // when the given context is cancelled or the returned value is closed. + // + // The process should use the Tracker to record the results of the + // health checks. It should NOT directly call Tracker from this + // method implementation. If the implementation wants to immediately + // update health state, it must do so from a goroutine. + New(context.Context, conn.Conn, Tracker) io.Closer +} + +// Tracker represents an object that tracks the health state of various connections. +// This is the interface through which a Checker communicates state updates. +type Tracker interface { + UpdateHealthState(conn.Conn, State) +} + +type nopChecker struct{} + +func (n nopChecker) New(_ context.Context, conn conn.Conn, tracker Tracker) io.Closer { + go tracker.UpdateHealthState(conn, StateHealthy) + return nopCloser{} +} + +type nopCloser struct{} + +func (n nopCloser) Close() error { + return nil +} diff --git a/vendor/github.com/bufbuild/httplb/health/doc.go b/vendor/github.com/bufbuild/httplb/health/doc.go new file mode 100644 index 0000000..4fea99b --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/health/doc.go @@ -0,0 +1,29 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package health provides pluggable health checking for an httplb.Client. +// +// This package defines the core types [Checker], which creates health +// check processes for connections. The package also defines the interface +// [Tracker], which is how a health check process communicates results +// back to a httplb.Client. +// +// The [Checker] interface is very general and allows health-checking +// strategies of many shapes, including those that might consult separate +// ("look aside") data sources for health or even those that use a streaming +// RPC to have results pushed from a server. This package also includes a +// default implementation that just does periodic polling using a given +// [Prober]. The prober can use the actual connection being checked to send +// an HTTP request to the address and examine the response. +package health diff --git a/vendor/github.com/bufbuild/httplb/health/polling.go b/vendor/github.com/bufbuild/httplb/health/polling.go new file mode 100644 index 0000000..e69abf5 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/health/polling.go @@ -0,0 +1,228 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package health + +import ( + "context" + "io" + "math/rand/v2" + "net/http" + "net/url" + "time" + + "github.com/bufbuild/httplb/conn" + "github.com/bufbuild/httplb/internal" +) + +// NewPollingChecker creates a new checker that calls a single-shot prober +// on a fixed interval. +func NewPollingChecker(config PollingCheckerConfig, prober Prober) Checker { + if config.PollingInterval == 0 { + config.PollingInterval = 15 * time.Second + } + if config.Timeout == 0 { + config.Timeout = config.PollingInterval + } + if config.UnhealthyThreshold == 0 { + config.UnhealthyThreshold = 1 + } + if config.HealthyThreshold == 0 { + config.HealthyThreshold = 1 + } + return &pollingChecker{ + interval: config.PollingInterval, + scaledJitter: config.Jitter * float64(config.PollingInterval), + timeout: config.Timeout, + healthyThreshold: config.HealthyThreshold, + unhealthyThreshold: config.UnhealthyThreshold, + prober: prober, + clock: internal.NewRealClock(), + } +} + +// PollingCheckerConfig represents the configuration options when calling +// NewPollingChecker. +type PollingCheckerConfig struct { + // How often the probe is performed. Defaults to 15 seconds if zero. + PollingInterval time.Duration + + // How much to jitter the period. This should be a value between 0 and 1. + // Zero means no jitter. One means the jitter could perturb the period up + // to 100% (so the actual period could be between 0 and twice the configured + // period). + Jitter float64 + + // The time limit for a probe. If it takes longer than this, the health + // check is assumed to fail as unhealthy. Defaults to the polling period. + Timeout time.Duration + + // HealthyThreshold specifies the number of successful health checks needed + // to promote an unhealthy, degraded or unknown backend to healthy. This is + // not used for the initial health check, so a connection will be healthy + // immediately if the first health check passes. + // + // Defaults to 1. + HealthyThreshold int + + // UnhealthyThreshold specifies the number of failed health checks needed to + // demote a healthy connection to unhealthy, degraded, or unknown. This can + // reduce flapping: Setting this value to two would prevent a single + // spurious health-check failure from causing a connection to be marked as + // unhealthy. + // + // Defaults to 1. + UnhealthyThreshold int +} + +// A Prober is a type that can perform single-shot healthchecks against a +// connection. +type Prober interface { + Probe(ctx context.Context, conn conn.Conn) State +} + +// NewSimpleProber creates a new prober that performs an HTTP GET request to the +// provided path. If it returns a successful status (status codes from 200-299), +// the connection is considered healthy. Otherwise, it is considered unhealthy. +func NewSimpleProber(path string) Prober { + return proberFunc(func(ctx context.Context, conn conn.Conn) State { + url := url.URL{ + Scheme: conn.Scheme(), + Host: conn.Address().HostPort, + Path: path, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), http.NoBody) + if err != nil { + return StateUnknown + } + resp, err := conn.RoundTrip(req, nil) + if err != nil { + return StateUnhealthy + } + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return StateUnhealthy + } + return StateHealthy + }) +} + +type pollingChecker struct { + interval time.Duration + scaledJitter float64 + timeout time.Duration + + unhealthyThreshold int + healthyThreshold int + + prober Prober + clock internal.Clock +} + +func (r *pollingChecker) New( + ctx context.Context, + conn conn.Conn, + tracker Tracker, +) io.Closer { + ctx, cancel := context.WithCancel(ctx) + task := &pollingCheckerTask{ + cancel: cancel, + doneSignal: make(chan struct{}), + } + + state := StateUnknown + + // Start the counter off at the healthy threshold so that it can transition + // into healthy state in one passed check. The unhealthy threshold is not + // a concern since the initial state (StateUnknown) is already considered to be + // unhealthy. + counter := r.healthyThreshold + + go func() { + defer close(task.doneSignal) + defer cancel() + + ticker := r.clock.NewTicker(r.calcJitter(r.interval)) + defer ticker.Stop() + + for { + func() { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + result := r.prober.Probe(ctx, conn) + + lastState := state + + switch { + case result == StateHealthy && (state == StateUnhealthy || state == StateDegraded || state == StateUnknown): + counter++ + if counter >= r.healthyThreshold { + state = result + counter = 0 + } + + case state == StateHealthy && (result == StateUnhealthy || result == StateDegraded || result == StateUnknown): + counter++ + if counter >= r.unhealthyThreshold { + state = result + counter = 0 + } + + default: + state = result + counter = 0 + } + + if lastState != state { + tracker.UpdateHealthState(conn, result) + } + }() + + select { + case <-ctx.Done(): + return + case <-ticker.Chan(): + ticker.Reset(r.calcJitter(r.interval)) + } + } + }() + return task +} + +func (r *pollingChecker) calcJitter(interval time.Duration) time.Duration { + if r.scaledJitter == 0 { + return interval + } + + // This may lose precision if your interval is longer than ~104 days. + return time.Duration(float64(interval) + ((rand.Float64()*2 - 1) * r.scaledJitter)) //nolint:gosec // does not need to be cryptographically secure +} + +type pollingCheckerTask struct { + cancel context.CancelFunc + doneSignal chan struct{} +} + +func (t *pollingCheckerTask) Close() error { + t.cancel() + <-t.doneSignal + return nil +} + +type proberFunc func(ctx context.Context, conn conn.Conn) State + +func (f proberFunc) Probe(ctx context.Context, conn conn.Conn) State { + return f(ctx, conn) +} diff --git a/vendor/github.com/bufbuild/httplb/health/state.go b/vendor/github.com/bufbuild/httplb/health/state.go new file mode 100644 index 0000000..70c41ea --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/health/state.go @@ -0,0 +1,44 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package health + +import "fmt" + +// State represents the state of a connection. Their natural ordering is +// for "better" states to be before "worse" states. So StateHealthy is the lowest +// value and StateUnhealthy is the highest. +type State int + +const ( + StateHealthy = State(-1) + StateUnknown = State(0) + StateDegraded = State(1) + StateUnhealthy = State(2) +) + +func (s State) String() string { + switch s { + case StateHealthy: + return "healthy" + case StateDegraded: + return "degraded" + case StateUnhealthy: + return "unhealthy" + case StateUnknown: + return "unknown" + default: + return fmt.Sprintf("State(%d)", s) + } +} diff --git a/vendor/github.com/bufbuild/httplb/internal/clock.go b/vendor/github.com/bufbuild/httplb/internal/clock.go new file mode 100644 index 0000000..d9bde84 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/internal/clock.go @@ -0,0 +1,92 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import "time" + +// Clock is an interface that is compatible with the jonboulle/clockwork package. +// The intent is that clockwork package only be a dependency for tests, not for +// non-test code. +type Clock interface { + After(d time.Duration) <-chan time.Time + Sleep(d time.Duration) + Now() time.Time + Since(t time.Time) time.Duration + NewTicker(d time.Duration) Ticker + NewTimer(d time.Duration) Timer + AfterFunc(d time.Duration, f func()) Timer +} + +// Ticker is an interface covering the behavior of a [time.Ticker]. +type Ticker interface { + Chan() <-chan time.Time + Reset(d time.Duration) + Stop() +} + +// Timer is an interface covering the behavior of a [time.Timer]. +type Timer interface { + Chan() <-chan time.Time + Reset(d time.Duration) bool + Stop() bool +} + +// NewRealClock returns a Clock implementation where all methods +// delegate to the corresponding function in the [time] package. +func NewRealClock() Clock { + return realClock{} +} + +type realClock struct{} + +func (realClock) After(d time.Duration) <-chan time.Time { + return time.After(d) +} + +func (realClock) Sleep(d time.Duration) { + time.Sleep(d) +} + +func (realClock) Now() time.Time { + return time.Now() +} + +func (realClock) Since(t time.Time) time.Duration { + return time.Since(t) +} + +func (realClock) NewTicker(d time.Duration) Ticker { + return realTicker{time.NewTicker(d)} +} + +func (realClock) NewTimer(d time.Duration) Timer { + return realTimer{time.NewTimer(d)} +} + +func (realClock) AfterFunc(d time.Duration, f func()) Timer { + return realTimer{time.AfterFunc(d, f)} +} + +type realTicker struct{ *time.Ticker } + +func (r realTicker) Chan() <-chan time.Time { + return r.C +} + +type realTimer struct{ *time.Timer } + +func (r realTimer) Chan() <-chan time.Time { + return r.C +} diff --git a/vendor/github.com/bufbuild/httplb/internal/conns/conns.go b/vendor/github.com/bufbuild/httplb/internal/conns/conns.go new file mode 100644 index 0000000..64e6da9 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/internal/conns/conns.go @@ -0,0 +1,88 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package conns contains internal helpers relating to conn.Conns values. +package conns + +import "github.com/bufbuild/httplb/conn" + +// Set is a set of connections. Since connections are map keys in the +// underlying type, they are unique. Standard map iteration is used to +// enumerate the contents of the set. +type Set map[conn.Conn]struct{} + +// Contains returns true if the set contains the given connection. +func (s Set) Contains(c conn.Conn) bool { + _, ok := s[c] + return ok +} + +// Equals returns true if s has the same connections as other. +func (s Set) Equals(other Set) bool { + if len(s) != len(other) { + return false + } + for c := range s { + _, ok := other[c] + if !ok { + return false + } + } + return true +} + +// ToSet converts a conn.Conns into a conn.Set. +func ToSet(conns conn.Conns) Set { + set := Set{} + for i := range conns.Len() { + set[conns.Get(i)] = struct{}{} + } + return set +} + +// SetFromSlice converts a []conn.Conn into a conn.Set. +func SetFromSlice(conns []conn.Conn) Set { + set := Set{} + for _, c := range conns { + set[c] = struct{}{} + } + return set +} + +// FromSlice returns a conn.Conns that +// represents the given slice. Note that no defensive +// copy is made, so changes to the given slice's contents +// will be reflected in the returned value. +func FromSlice(conns []conn.Conn) conn.Conns { + return connections(conns) +} + +// FromSet converts a conn.Set to a conn.Conns. +func FromSet(set Set) conn.Conns { + sl := make([]conn.Conn, 0, len(set)) + for c := range set { + sl = append(sl, c) + } + return FromSlice(sl) +} + +type connections []conn.Conn + +func (c connections) Len() int { + return len(c) +} + +func (c connections) Get(i int) conn.Conn { + return c[i] +} diff --git a/vendor/github.com/bufbuild/httplb/internal/murmur3.go b/vendor/github.com/bufbuild/httplb/internal/murmur3.go new file mode 100644 index 0000000..3c82102 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/internal/murmur3.go @@ -0,0 +1,132 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "encoding/binary" + "hash" + "math/bits" +) + +const ( + murmurC1 = 0xCC9E2D51 + murmurC2 = 0x1B873593 +) + +type MurmurHash3 struct { + rem, len int + k1, h1 uint32 +} + +func NewMurmurHash3(seed uint32) hash.Hash32 { + return &MurmurHash3{h1: seed} +} + +func MurmurHash3Sum(data []byte, seed uint32) uint32 { + h := MurmurHash3{h1: seed} + _, _ = h.Write(data) + return h.Sum32() +} + +//nolint:varnamelen // names match reference implementation for clarity +func (h *MurmurHash3) Write(data []byte) (int, error) { + dataLen := len(data) + h.len += dataLen + k1, h1 := h.k1, h.h1 + + if h.rem != 0 { + i, j := h.rem, 0 + for ; i < 4 && j < dataLen; i++ { + k1 |= uint32(data[j]) << (i << 3) + j++ + } + data = data[j:] + h.rem = i + h.k1 = k1 + + if h.rem < 4 { + return dataLen, nil + } + + h1 = round(h1, k1) + } + + bodyLen := len(data) &^ 3 + for i, l := 0, bodyLen; i < l; i += 4 { + k1 := uint32(data[i+3])<<24 | + uint32(data[i+2])<<16 | + uint32(data[i+1])<<8 | + uint32(data[i]) + h1 = round(h1, k1) + } + + data = data[bodyLen:] + h.rem = len(data) + k1 = 0 + for i, l := 0, h.rem; i < l; i++ { + k1 |= uint32(data[i]) << (i << 3) + } + + h.k1 = k1 + h.h1 = h1 + + return dataLen, nil +} + +//nolint:varnamelen // names match reference implementation for clarity +func (h *MurmurHash3) Sum32() uint32 { + k1 := h.k1 + h1 := h.h1 + + k1 *= murmurC1 + k1 = bits.RotateLeft32(k1, 15) + k1 *= murmurC2 + h1 ^= k1 + + h1 ^= uint32(h.len) + h1 ^= h1 >> 16 + h1 *= 0x85EBCA6B + h1 ^= h1 >> 13 + h1 *= 0xC2B2AE35 + h1 ^= h1 >> 16 + return h1 +} + +//nolint:varnamelen // names match reference implementation for clarity +func round(h1, k1 uint32) uint32 { + k1 *= murmurC1 + k1 = bits.RotateLeft32(k1, 15) + k1 *= murmurC2 + h1 ^= k1 + h1 = bits.RotateLeft32(h1, 13) + h1 = h1*4 + h1 + 0xE6546B64 + return h1 +} + +func (h *MurmurHash3) Sum(b []byte) []byte { + return binary.LittleEndian.AppendUint32(b, h.Sum32()) +} + +func (h *MurmurHash3) Reset() { + *h = MurmurHash3{} +} + +func (h *MurmurHash3) Size() int { + return 4 +} + +func (h *MurmurHash3) BlockSize() int { + return 4 +} diff --git a/vendor/github.com/bufbuild/httplb/internal/rand.go b/vendor/github.com/bufbuild/httplb/internal/rand.go new file mode 100644 index 0000000..4e6f84c --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/internal/rand.go @@ -0,0 +1,35 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "math/rand" + randv2 "math/rand/v2" +) + +// NewRand returns a properly seeded *rand.Rand. +// +// The returned value is not thread-safe. If you need a thread-safe random +// number generator, use the top-level functions of the "math/rand/v2" +// package. They are not as fast as the Go 1 ("math/rand") generator, but +// they are much faster if access to the Go 1 generator must be guarded +// by a mutex. +func NewRand() *rand.Rand { + // The top-level functions in "math/rand/v2" use the same per-thread, + // lock-free RNGs as the "hash/maphash" package. So we use that to + // generate a seed. Thereafter, we use the Go 1 generator in "math/rand" + // since its RNG is a bit faster than the offerings in "math/rand/v2". + return rand.New(rand.NewSource(randv2.Int64())) //nolint:gosec // don't need cryptographic RNG +} diff --git a/vendor/github.com/bufbuild/httplb/picker/doc.go b/vendor/github.com/bufbuild/httplb/picker/doc.go new file mode 100644 index 0000000..8c6587a --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/picker/doc.go @@ -0,0 +1,34 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package picker provides functionality for picking a connection. +// This is used by an httplb.Client to actually select a connection for +// use with a given request. +// +// This package defines the core interface, [Picker], which is used to +// select a single connection, from multiple connections in a pool. +// +// This package also contains numerous implementations, all in the form +// of various functions whose names start with "New". Each such function +// produces pickers that implement a particular picking algorithm, like +// round-robin, random, or least-loaded. +// +// None of the provided implementations in this package make use of +// custom metadata (attribute.Values) for an address. But custom [Picker] +// implementations could, for example to prefer backends in clusters +// that are geographically closer, or to implement custom affinity +// policies, or even to implement weighted selection algorithms in +// the face of heterogeneous backends (where the name resolution/service +// discovery system has information about a backend's capacity). +package picker diff --git a/vendor/github.com/bufbuild/httplb/picker/leastloaded.go b/vendor/github.com/bufbuild/httplb/picker/leastloaded.go new file mode 100644 index 0000000..3d8cde3 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/picker/leastloaded.go @@ -0,0 +1,235 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package picker + +import ( + "container/heap" + "math/bits" + "math/rand" + "net/http" + "sync" + + "github.com/bufbuild/httplb/conn" + "github.com/bufbuild/httplb/internal" +) + +// NewLeastLoadedRoundRobin creates pickers that pick the connection +// with the fewest in-flight requests. When a tie occurs, tied hosts will +// be picked in an arbitrary but sequential order. +func NewLeastLoadedRoundRobin(prev Picker, allConns conn.Conns) Picker { + if prev, ok := prev.(*leastLoadedRoundRobin); ok { + prev.mu.Lock() + defer prev.mu.Unlock() + + prev.conns.update(allConns) + return prev + } + + return &leastLoadedRoundRobin{ + leastLoadedBase: leastLoadedBase{ + conns: newConnHeap(allConns), + }, + } +} + +// NewLeastLoadedRandom creates pickers that pick the connection with +// the fewest in-flight requests. When a tie occurs, tied hosts will be +// picked at random. +func NewLeastLoadedRandom(prev Picker, allConns conn.Conns) Picker { + if prev, ok := prev.(*leastLoadedRandom); ok { + prev.mu.Lock() + defer prev.mu.Unlock() + + prev.conns.update(allConns) + return prev + } + + return &leastLoadedRandom{ + leastLoadedBase: leastLoadedBase{ + conns: newConnHeap(allConns), + }, + rng: internal.NewRand(), + } +} + +type leastLoadedBase struct { + mu sync.Mutex + // +checklocks:mu + conns *leastLoadedConnHeap +} + +type leastLoadedRoundRobin struct { + leastLoadedBase + // +checklocks:mu + counter uint64 +} + +type leastLoadedRandom struct { + leastLoadedBase + // +checklocks:mu + rng *rand.Rand +} + +//nolint:recvcheck // mix of pointer and non-pointer receiver methods is intentional +type leastLoadedConnHeap []*leastLoadedConnItem + +type leastLoadedConnItem struct { + conn conn.Conn + load uint64 + tieBreak uint64 + index int +} + +// +checklocks:p.mu +func (p *leastLoadedBase) pickLocked(nextTieBreak uint64) (conn conn.Conn, whenDone func(), _ error) { //nolint:unparam + entry := p.conns.acquire(nextTieBreak) + return entry.conn, + func() { + p.mu.Lock() + defer p.mu.Unlock() + p.conns.release(entry) + }, + nil +} + +func (p *leastLoadedRoundRobin) Pick(*http.Request) (conn conn.Conn, whenDone func(), err error) { + p.mu.Lock() + defer p.mu.Unlock() + + p.counter++ + return p.leastLoadedBase.pickLocked(p.counter) +} + +func (p *leastLoadedRandom) Pick(*http.Request) (conn conn.Conn, whenDone func(), err error) { + p.mu.Lock() + defer p.mu.Unlock() + + return p.leastLoadedBase.pickLocked(p.rng.Uint64()) +} + +func newConnHeap(allConns conn.Conns) *leastLoadedConnHeap { + newConns := make([]*leastLoadedConnItem, allConns.Len()) + newHeap := leastLoadedConnHeap(newConns) + for i := range newConns { + newConns[i] = &leastLoadedConnItem{ + conn: allConns.Get(i), + index: i, + } + } + heap.Init(&newHeap) + return &newHeap +} + +func (h *leastLoadedConnHeap) update(allConns conn.Conns) { + newMap := map[conn.Conn]struct{}{} + for i, l := 0, allConns.Len(); i < l; i++ { + newMap[allConns.Get(i)] = struct{}{} + } + j := 0 //nolint:varnamelen + slice := *h + // Remove items from slice that aren't in the new set of conns, + // compacting the slice as we go. + for i, item := range slice { + if _, ok := newMap[item.conn]; ok { + delete(newMap, item.conn) + if i != j { + item.index = j + (*h)[j] = item + } + j++ + } else { + // If there are pending ops with this one, make sure it + // knows it's been evicted. + item.index = -1 + } + } + newLen := j + len(newMap) + if j == len(slice) { + // No items removed, so we haven't broken any heap invariants. + // If we don't have too many items to add, just heap.Push them + // and return. + threshold := newLen / bits.Len(uint(newLen)) + // Push is O(log n). Init (aka heapify) is O(n). So threshold + // is (n / log n). If there are more items than that, it's + // better to fall through below and re-init. + if len(newMap) <= threshold { + for cn := range newMap { + h.Push(&leastLoadedConnItem{conn: cn}) + } + return + } + } else if len(slice) > newLen { + // Make sure we don't leak memory with dangling pointers + // in unused regions of the slice. + for i := range slice[newLen:] { + slice[newLen+i] = nil + } + } + // Now add remaining new connections. + slice = slice[:j] + for cn := range newMap { + slice = append(slice, &leastLoadedConnItem{conn: cn, index: len(slice)}) + } + *h = slice + // Re-heapify + heap.Init(h) +} + +func (h *leastLoadedConnHeap) acquire(nextTieBreak uint64) *leastLoadedConnItem { + entry := (*h)[0] + entry.load++ + entry.tieBreak = nextTieBreak + heap.Fix(h, entry.index) + return entry +} + +func (h *leastLoadedConnHeap) release(entry *leastLoadedConnItem) { + entry.load-- + if entry.index != -1 { + heap.Fix(h, entry.index) + } +} + +func (h leastLoadedConnHeap) Len() int { return len(h) } + +func (h leastLoadedConnHeap) Less(i, j int) bool { + if h[i].load == h[j].load { + return h[i].tieBreak < h[j].tieBreak + } + return h[i].load < h[j].load +} + +func (h leastLoadedConnHeap) Swap(i, j int) { + h[i], h[j] = h[j], h[i] + h[i].index = i + h[j].index = j +} + +func (h *leastLoadedConnHeap) Push(x any) { + n := len(*h) + item := x.(*leastLoadedConnItem) //nolint:forcetypeassert,errcheck + item.index = n + *h = append(*h, item) +} + +func (h *leastLoadedConnHeap) Pop() any { + old := *h + n := len(old) + item := old[n-1] + old[n-1] = nil + item.index = -1 + *h = old[0 : n-1] + return item +} diff --git a/vendor/github.com/bufbuild/httplb/picker/picker.go b/vendor/github.com/bufbuild/httplb/picker/picker.go new file mode 100644 index 0000000..6621b50 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/picker/picker.go @@ -0,0 +1,44 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package picker + +import ( + "net/http" + + "github.com/bufbuild/httplb/conn" +) + +// Picker implements connection selection. For a given request, it returns +// the connection to use. It also returns a callback that, if non-nil, will +// be invoked when the operation is complete. (This happens when the HTTP +// response is returned and its body fully consumed or closed.) Such a +// callback can be used, for example, to track the number of active requests +// for a least-loaded implementation. +type Picker interface { + Pick(req *http.Request) (conn conn.Conn, whenDone func(), err error) +} + +// ErrorPicker returns a picker that always fails with the given error. +func ErrorPicker(err error) Picker { + return pickerFunc(func(*http.Request) (conn.Conn, func(), error) { + return nil, nil, err + }) +} + +type pickerFunc func(*http.Request) (conn conn.Conn, whenDone func(), err error) + +func (f pickerFunc) Pick(req *http.Request) (conn conn.Conn, whenDone func(), err error) { + return f(req) +} diff --git a/vendor/github.com/bufbuild/httplb/picker/poweroftwo.go b/vendor/github.com/bufbuild/httplb/picker/poweroftwo.go new file mode 100644 index 0000000..9cfba2a --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/picker/poweroftwo.go @@ -0,0 +1,81 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package picker + +import ( + "math/rand/v2" + "net/http" + "sync/atomic" + + "github.com/bufbuild/httplb/conn" +) + +// NewPowerOfTwo creates pickers that select two connections at random +// and pick the one with fewer requests. This takes advantage of the +// [power of two random choices], which provides substantial benefits +// over a simple random picker and, unlike the least-loaded policy, doesn't +// need to maintain a heap. +// +// [power of two random choices]: http://www.eecs.harvard.edu/~michaelm/postscripts/handbook2001.pdf +func NewPowerOfTwo(prev Picker, allConns conn.Conns) Picker { + itemMap := map[conn.Conn]*powerOfTwoConnItem{} + + if prev, ok := prev.(*powerOfTwo); ok { + for _, entry := range prev.conns { + itemMap[entry.conn] = entry + } + } + + newConns := make([]*powerOfTwoConnItem, allConns.Len()) + for i := range newConns { + conn := allConns.Get(i) + if item, ok := itemMap[conn]; ok { + newConns[i] = item + } else { + newConns[i] = &powerOfTwoConnItem{conn: conn} + } + } + + return &powerOfTwo{conns: newConns} +} + +type powerOfTwo struct { + conns []*powerOfTwoConnItem +} + +type powerOfTwoConnItem struct { + conn conn.Conn + // +checkatomic + load atomic.Int64 +} + +func (p *powerOfTwo) Pick(*http.Request) (conn conn.Conn, whenDone func(), err error) { + entry1 := p.conns[rand.IntN(len(p.conns))] //nolint:gosec // does not need to be cryptographically secure + entry2 := p.conns[rand.IntN(len(p.conns))] //nolint:gosec // does not need to be cryptographically secure + + var entry *powerOfTwoConnItem + if uint64(entry1.load.Load()) < uint64(entry2.load.Load()) { + entry = entry1 + } else { + entry = entry2 + } + + entry.load.Add(1) + whenDone = func() { + entry.load.Add(-1) + } + + return entry.conn, whenDone, nil +} diff --git a/vendor/github.com/bufbuild/httplb/picker/random.go b/vendor/github.com/bufbuild/httplb/picker/random.go new file mode 100644 index 0000000..2dad8de --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/picker/random.go @@ -0,0 +1,30 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package picker + +import ( + "math/rand/v2" + "net/http" + + "github.com/bufbuild/httplb/conn" +) + +// NewRandom creates pickers that picks a connections at random. +func NewRandom(_ Picker, allConns conn.Conns) Picker { + return pickerFunc(func(*http.Request) (conn conn.Conn, whenDone func(), err error) { + return allConns.Get(rand.IntN(allConns.Len())), //nolint:gosec // does not need to be cryptographically secure + nil, nil + }) +} diff --git a/vendor/github.com/bufbuild/httplb/picker/roundrobin.go b/vendor/github.com/bufbuild/httplb/picker/roundrobin.go new file mode 100644 index 0000000..34f327c --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/picker/roundrobin.go @@ -0,0 +1,52 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package picker + +import ( + "net/http" + "sync/atomic" + + "github.com/bufbuild/httplb/conn" + "github.com/bufbuild/httplb/internal" +) + +// NewRoundRobin creates pickers that pick connections in a "round-robin" +// fashion, that is to say, in sequential order. In order to mitigate the risk +// of a "thundering herd" scenario, the order of connections is randomized +// each time the list of hosts changes. +func NewRoundRobin(_ Picker, allConns conn.Conns) Picker { + rnd := internal.NewRand() + numConns := allConns.Len() + conns := make([]conn.Conn, numConns) + for i := range numConns { + conns[i] = allConns.Get(i) + } + rnd.Shuffle(numConns, func(i, j int) { + conns[i], conns[j] = conns[j], conns[i] + }) + picker := &roundRobin{conns: conns} + picker.counter.Store(-1) + return picker +} + +type roundRobin struct { + conns []conn.Conn + // +checkatomic + counter atomic.Int64 +} + +func (r *roundRobin) Pick(_ *http.Request) (conn conn.Conn, whenDone func(), err error) { + return r.conns[uint64(r.counter.Add(1))%uint64(len(r.conns))], nil, nil +} diff --git a/vendor/github.com/bufbuild/httplb/resolver/doc.go b/vendor/github.com/bufbuild/httplb/resolver/doc.go new file mode 100644 index 0000000..13f0215 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/resolver/doc.go @@ -0,0 +1,55 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package resolver provides functionality for custom name resolution. +// Name resolution is the process of resolving service or domain names +// into one or more addresses -- where an address is a host:port (and +// optionally custom metadata) of a server that provides the service. +// +// It contains the core interface ([Resolver]) that can be implemented +// to create a custom name resolution strategy. The interface is general +// enough that it can support any form of resolver, including ones that +// are backed by push mechanisms (like "watching" nodes in ZooKeeper or +// etcd or "watching" resources in Kubernetes). +// +// # Default Implementation +// +// This package contains a default implementation that uses periodic +// polling via a [ResolveProber]. The one prober implementation included +// uses DNS to query addresses for a name using a [net.Resolver]. +// +// To create a new resolver implementation that uses periodic polling, +// you need only implement the [ResolveProber] interface and use your +// implementation with NewPollingResolver. To create a more sophisticated +// implementation, you would need to implement the [Resolver] interface, +// which creates a new task for each service or domain that a client needs. +// +// # Subsetting +// +// Subsetting can be achieved via a Receiver decorator that intercepts the +// addresses, selects a subset, and sends the subset to the underlying Receiver. +// +// This decorator pattern is implemented by RendezvousHashSubsetter, which +// is a Resolver decorator. When the resolver's New method is called, it wraps +// the given Receiver with a decorator that computes the subset using +// rendezvous-hashing. It then passes that decorated Receiver to the underlying +// Resolver. +// +// For decorators that use resources (such as background goroutines) and need +// to be explicitly shut down to release those resources, the Resolver interface +// should be decorated, just as is done by RendezvousHashSubsetter. That way, +// any tasks created by the Resolver can also be decorated, in order to hook into +// their Close method. When the task for a corresponding Receiver is closed, +// the decorator should shut down and release any resources. +package resolver diff --git a/vendor/github.com/bufbuild/httplb/resolver/min_conns.go b/vendor/github.com/bufbuild/httplb/resolver/min_conns.go new file mode 100644 index 0000000..310964d --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/resolver/min_conns.go @@ -0,0 +1,73 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resolver + +import ( + "context" + "io" +) + +// MinConnections decorates the given resolver so it sends a set of addresses that has at +// least as many entries as the given minimum. If the given resolver provides a smaller +// set of addresses, it replicates those addresses until the give minimum is reached. +// +// This will cause a client to effectively make redundant connections to the same address +// which is particularly useful when the addresses are virtual IPs, which actually have +// multiple servers behind them. This is appropriate for environments like Kubernetes +// (which uses virtual IPs for non-headless services) and for use with services that have +// layer-4 (TCP) proxies/load balancers in front of them. +// +// To avoid "hot spotting", where one backend address gets more load than others, this +// always fully replicates the set. So it will always report at least minAddresses, but +// could report nearly twice as many: in the case where the set from the underlying +// resolver has minAddresses-1 entries, this will provide (minAddresses-1)*2 entries. +func MinConnections(other Resolver, minAddresses int) Resolver { + return &minConnsResolver{res: other, min: minAddresses} +} + +type minConnsResolver struct { + res Resolver + min int +} + +func (m *minConnsResolver) New(ctx context.Context, scheme, hostPort string, receiver Receiver, refresh <-chan struct{}) io.Closer { + return m.res.New(ctx, scheme, hostPort, &minConnsReceiver{rcvr: receiver, min: m.min}, refresh) +} + +type minConnsReceiver struct { + rcvr Receiver + min int +} + +func (m *minConnsReceiver) OnResolve(addresses []Address) { + if len(addresses) >= m.min || len(addresses) == 0 { + // Already enough addresses; OR zero addresses, in which case, no amount of replication can help. + m.rcvr.OnResolve(addresses) + return + } + multiplier := m.min / len(addresses) + if len(addresses)*multiplier < m.min { + multiplier++ // div rounded down + } + scaledAddrs := make([]Address, 0, len(addresses)*multiplier) + for range multiplier { + scaledAddrs = append(scaledAddrs, addresses...) + } + m.rcvr.OnResolve(scaledAddrs) +} + +func (m *minConnsReceiver) OnResolveError(err error) { + m.rcvr.OnResolveError(err) +} diff --git a/vendor/github.com/bufbuild/httplb/resolver/rendezvous.go b/vendor/github.com/bufbuild/httplb/resolver/rendezvous.go new file mode 100644 index 0000000..d286f60 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/resolver/rendezvous.go @@ -0,0 +1,171 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resolver + +import ( + "container/heap" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "hash" + "io" + + "github.com/bufbuild/httplb/internal" +) + +// RendezvousHashSubsetter returns a Resolver that creates tasks that use +// rendezvous hashing to pick a randomly-distributed but consistent subset of k +// hosts. When provided the same selection key and k value, it will return the +// same addresses. When an address is removed, all of the requests that would +// have been directed to it will be distributed randomly to other addresses. +func RendezvousHashSubsetter(resolver Resolver, options RendezvousConfig) (Resolver, error) { + if options.SelectionKey == "" { + randomKey, err := randomKey() + if err != nil { + return nil, err + } + options.SelectionKey = randomKey + } + if options.NumBackends == 0 { + return nil, errors.New("NumBackends must be set") + } + if options.Hash == nil { + options.Hash = internal.NewMurmurHash3(0) + } + return &rendezvousSubsetResolver{ + resolver: resolver, + key: []byte(options.SelectionKey), + k: options.NumBackends, + hash: options.Hash, + }, nil +} + +// RendezvousConfig represents the configuration options for use with NewRendezvous. +type RendezvousConfig struct { + // NumBackends specifies the number of backends to select out of the set of + // available hosts. This option is required. + NumBackends int + + // SelectionKey specifies the key used to uniquely select hosts. This value + // controls which hosts get selected, thus typically you set a unique value + // for each program instance, using e.g. the machine host name. If not set, + // a random string will be used. + SelectionKey string + + // Hash provides a hash function to use. If unspecified, an implementation + // of MurmurHash3 will be used. + Hash hash.Hash32 +} + +type rendezvousSubsetResolver struct { + resolver Resolver + key []byte + k int + hash hash.Hash32 +} + +func (s *rendezvousSubsetResolver) New( + ctx context.Context, + scheme, hostPort string, + receiver Receiver, + refresh <-chan struct{}, +) io.Closer { + rcv := &rendezvousSubsetReceiver{ + Receiver: receiver, + key: s.key, + k: s.k, + hash: s.hash, + } + return s.resolver.New(ctx, scheme, hostPort, rcv, refresh) +} + +type rendezvousSubsetReceiver struct { + Receiver + key []byte + k int + hash hash.Hash32 +} + +func (s *rendezvousSubsetReceiver) OnResolve(addrs []Address) { + s.Receiver.OnResolve(s.computeSubset(addrs)) +} + +func (s *rendezvousSubsetReceiver) computeSubset(addrs []Address) []Address { + if len(addrs) <= s.k { + return addrs + } + n, k := len(addrs), s.k + addrHeap := newAddressHeap(addrs[:s.k], s.key, s.hash) + for i := k; i < n; i++ { + rank := addrHeap.rank(addrs[i]) + if rank > addrHeap.ranks[0] { + addrHeap.addrs[0] = addrs[i] + addrHeap.ranks[0] = rank + heap.Fix(addrHeap, 0) + } + } + return addrHeap.addrs +} + +type addressHeap struct { + addrs []Address + ranks []uint32 + key []byte + hash hash.Hash32 +} + +func newAddressHeap(addrs []Address, key []byte, hash hash.Hash32) *addressHeap { + addrHeap := &addressHeap{ + addrs: addrs, + ranks: make([]uint32, len(addrs)), + key: key, + hash: hash, + } + for i := range addrHeap.ranks { + addrHeap.ranks[i] = addrHeap.rank(addrHeap.addrs[i]) + } + heap.Init(addrHeap) + return addrHeap +} + +func (h *addressHeap) rank(addr Address) uint32 { + h.hash.Reset() + _, _ = h.hash.Write(h.key) + _, _ = h.hash.Write([]byte(addr.HostPort)) + return h.hash.Sum32() +} + +func (h *addressHeap) Len() int { return len(h.addrs) } + +func (h *addressHeap) Less(i, j int) bool { + return h.ranks[i] < h.ranks[j] +} + +func (h *addressHeap) Swap(i, j int) { + h.addrs[i], h.addrs[j] = h.addrs[j], h.addrs[i] + h.ranks[i], h.ranks[j] = h.ranks[j], h.ranks[i] +} + +func (h *addressHeap) Push(any) { panic("Push should not be called") } //nolint:forbidigo // inaccessible code +func (h *addressHeap) Pop() any { panic("Pop should not be called") } //nolint:forbidigo // inaccessible code + +func randomKey() (string, error) { + data := [16]byte{} + if _, err := rand.Read(data[:]); err != nil { + return "", err + } + return hex.EncodeToString(data[:]), nil +} diff --git a/vendor/github.com/bufbuild/httplb/resolver/resolver.go b/vendor/github.com/bufbuild/httplb/resolver/resolver.go new file mode 100644 index 0000000..288e13b --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/resolver/resolver.go @@ -0,0 +1,303 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resolver + +import ( + "context" + "io" + "net" + "net/netip" + "time" + + "github.com/bufbuild/httplb/attribute" + "github.com/bufbuild/httplb/internal" +) + +// AddressFamilyPolicy is an option that allows control over the preference +// for which addresses to consider when resolving, based on their address +// family. +type AddressFamilyPolicy int + +const ( + // PreferIPv4 will result in only IPv4 addresses being used, if any + // IPv4 addresses are present. If no IPv4 addresses are resolved, then + // all addresses will be used. + PreferIPv4 AddressFamilyPolicy = iota + + // RequireIPv4 will result in only IPv4 addresses being used. If no IPv4 + // addresses are present, no addresses will be resolved. + RequireIPv4 + + // PreferIPv6 will result in only IPv6 addresses being used, if any + // IPv6 addresses are present. If no IPv6 addresses are resolved, then + // all addresses will be used. + PreferIPv6 + + // RequireIPv6 will result in only IPv6 addresses being used. If no IPv6 + // addresses are present, no addresses will be resolved. + RequireIPv6 + + // PreferIPv6 will result in only IPv6 addresses being used, if any + // UseBothIPv4AndIPv6 will result in all addresses being used, regardless of + // their address family. + UseBothIPv4AndIPv6 +) + +// Resolver is an interface for continuous name resolution. +type Resolver interface { + // New creates a continuous resolver task for the given target name. When + // the target is resolved into backend addresses, they are provided to the + // given callback. + // + // As new result sets arrive (since the set of addresses may change over + // time), the callback may be called repeatedly. Each time, the entire set + // of addresses should be supplied. + // + // The resolver may report errors in addition to or instead of addresses, + // but it should keep trying to resolve (and watch for changes), even in + // the face of errors, until it is closed or the given context is cancelled. + // + // The refresh channel will receive signals from the client hinting that it + // may need new results. For example, if the client runs out of healthy + // hosts, it may call this method in order to try to find more healthy + // hosts. This is particularly likely to happen during e.g. a rolling + // deployment, wherein the entire pool of hosts could disappear within the + // span of a TTL. This may be a no-op. The refresh channel will not be + // closed until after Close() returns. + // + // The Close method on the return value should stop all goroutines and free + // any resources before returning. After close returns, there should be no + // subsequent calls to callbacks. + New( + ctx context.Context, + scheme, hostPort string, + receiver Receiver, + refresh <-chan struct{}, + ) io.Closer +} + +// Receiver is a client of a resolver and receives the resolved addresses. +type Receiver interface { + // OnResolve is called when the set of addresses is resolved. It may be called + // repeatedly as the set of addresses changes over time. Each call must always + // supply the full set of resolved addresses (no deltas). + OnResolve([]Address) + // OnResolveError is called when resolution encounters an error. This can + // happen at any time, including after addresses are initially resolved. But + // the errors may be ignored after initial resolution. + OnResolveError(error) +} + +// ResolveProber is an interface for types that provide single-shot name +// resolution. +type ResolveProber interface { + // ResolveOnce resolves the given target name once, returning a slice of + // addresses corresponding to the provided scheme and hostname. + // The second return value specifies the TTL of the result, or 0 if there + // is no known TTL value. + // + // The resolved addresses should have ports if it is needed for the expected + // target network. For example, in the common case of TCP, if the provided + // hostPort string does not contain a port, a default port should be added + // based on the scheme. + ResolveOnce( + ctx context.Context, + scheme, + hostPort string, + ) ( + results []Address, + ttl time.Duration, + err error, + ) +} + +// Address contains a resolved address to a host, and any attributes that may be +// associated with a host/address. +type Address struct { + // HostPort stores the host:port pair of the resolved address. + HostPort string + + // Attributes is a collection of arbitrary key/value pairs. + Attributes attribute.Values +} + +// NewDNSResolver creates a new resolver that resolves DNS names. The specified +// address family policy value can be used to require or prefer either IPv4 or +// IPv6 addresses. Note that because net.Resolver does not expose the record +// TTL values, this resolver uses the fixed TTL provided in the ttl parameter. +func NewDNSResolver( + resolver *net.Resolver, + policy AddressFamilyPolicy, + ttl time.Duration, +) Resolver { + return NewPollingResolver( + &dnsResolveProber{ + resolver: resolver, + policy: policy, + }, + ttl, + ) +} + +// NewPollingResolver creates a new resolver that polls an underlying +// single-shot resolver whenever the result-set TTL expires. If the underlying +// resolver does not return a TTL with the result-set, defaultTTL is used. +func NewPollingResolver( + prober ResolveProber, + defaultTTL time.Duration, +) Resolver { + return &pollingResolver{ + prober: prober, + defaultTTL: defaultTTL, + clock: internal.NewRealClock(), + } +} + +type dnsResolveProber struct { + resolver *net.Resolver + policy AddressFamilyPolicy +} + +func (r *dnsResolveProber) ResolveOnce( + ctx context.Context, + scheme, hostPort string, +) ([]Address, time.Duration, error) { + host, port, err := net.SplitHostPort(hostPort) + if err != nil { + // Assume this is not a host:port pair. + // There is no possible better heuristic for this, unfortunately. + host = hostPort + switch scheme { + case "https": + port = "443" + default: + port = "80" + } + } + network := networkForAddressFamilyPolicy(r.policy) + addresses, err := r.resolver.LookupNetIP(ctx, network, host) + if err != nil { + return nil, 0, err + } + addresses = applyAddressFamilyPolicy(addresses, r.policy) + result := make([]Address, len(addresses)) + for i, address := range addresses { + result[i].HostPort = net.JoinHostPort(address.Unmap().String(), port) + } + return result, 0, nil +} + +type pollingResolver struct { + prober ResolveProber + defaultTTL time.Duration + clock internal.Clock +} + +func (pr *pollingResolver) New( + ctx context.Context, + scheme, hostPort string, + receiver Receiver, + refresh <-chan struct{}, +) io.Closer { + ctx, cancel := context.WithCancel(ctx) + res := &pollingResolverTask{ + cancel: cancel, + doneSignal: make(chan struct{}), + refreshCh: refresh, + resolver: pr, + } + go res.run(ctx, scheme, hostPort, receiver) + return res +} + +type pollingResolverTask struct { + cancel context.CancelFunc + doneSignal chan struct{} + refreshCh <-chan struct{} + resolver *pollingResolver +} + +func (task *pollingResolverTask) Close() error { + task.cancel() + <-task.doneSignal + return nil +} + +func (task *pollingResolverTask) run(ctx context.Context, scheme, hostPort string, receiver Receiver) { + defer close(task.doneSignal) + defer task.cancel() + + timer := task.resolver.clock.NewTimer(0) + + for { + addresses, ttl, err := task.resolver.prober.ResolveOnce(ctx, scheme, hostPort) + if err != nil { + receiver.OnResolveError(err) + } else { + receiver.OnResolve(addresses) + } + // TODO: exponential backoff on error + // TODO: should exponential backoff override ResolveNow? + + if ttl == 0 { + ttl = task.resolver.defaultTTL + } + timer.Reset(ttl) + + select { + case <-ctx.Done(): + return + case <-task.refreshCh: + // Continue. + case <-timer.Chan(): + // Continue. + } + } +} + +func networkForAddressFamilyPolicy(policy AddressFamilyPolicy) string { + switch policy { + case PreferIPv4, PreferIPv6, UseBothIPv4AndIPv6: + return "ip" + case RequireIPv4: + return "ip4" + case RequireIPv6: + return "ip6" + } + return "" +} + +func applyAddressFamilyPolicy(addresses []netip.Addr, policy AddressFamilyPolicy) []netip.Addr { + var check func(netip.Addr) bool + required := policy == RequireIPv4 || policy == RequireIPv6 + switch policy { + case PreferIPv4, RequireIPv4: + check = func(address netip.Addr) bool { return address.Is4() || address.Is4In6() } + case PreferIPv6, RequireIPv6: + check = func(address netip.Addr) bool { return address.Is6() && !address.Is4In6() } + case UseBothIPv4AndIPv6: + return addresses + } + matchingAddresses := addresses[:0] + for _, address := range addresses { + if check(address) { + matchingAddresses = append(matchingAddresses, address) + } + } + if required || len(matchingAddresses) > 0 { + addresses = matchingAddresses + } + return addresses +} diff --git a/vendor/github.com/bufbuild/httplb/transport.go b/vendor/github.com/bufbuild/httplb/transport.go new file mode 100644 index 0000000..ab16647 --- /dev/null +++ b/vendor/github.com/bufbuild/httplb/transport.go @@ -0,0 +1,933 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httplb + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "math" + "net" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" + + "github.com/bufbuild/httplb/attribute" + "github.com/bufbuild/httplb/conn" + "github.com/bufbuild/httplb/health" + "github.com/bufbuild/httplb/internal" + "github.com/bufbuild/httplb/picker" + "github.com/bufbuild/httplb/resolver" + "golang.org/x/sync/errgroup" +) + +var ( + errTransportIsClosed = errors.New("transport is closed") + errTryAgain = errors.New("internal: leaf transport closed; try again") +) + +//nolint:gochecknoglobals +var requestPool = sync.Pool{ + New: func() any { + return &http.Request{URL: &url.URL{}} + }, +} + +// Transport is used to create round trippers that handle requests to a single +// resolved address. +type Transport interface { + // NewRoundTripper creates a new [http.RoundTripper] for requests using the + // given scheme to the given host, per the given config. + NewRoundTripper(scheme, target string, config TransportConfig) RoundTripperResult +} + +// RoundTripperResult is the result type created by a Transport. The contained +// RoundTripper represents a "leaf" transport used for sending requests. +type RoundTripperResult struct { + // RoundTripper is the actual round-tripper that handles requests. + RoundTripper http.RoundTripper + // Scheme, if non-empty, is the scheme to use for requests to RoundTripper. This + // replaces the request's original scheme. This is useful when a custom scheme + // is used to trigger a custom transport, but the underlying RoundTripper still + // expects a non-custom scheme, such as "http" or "https". + Scheme string + // Close is an optional function that will be called (if non-nil) when this + // round-tripper is no longer needed. + Close func() + + // prewarm is an optional function that will be called (if non-nil) to + // eagerly establish connections and perform any other checks so that there + // are no delays or unexpected errors incurred by the first HTTP request. + prewarm func(ctx context.Context, scheme, addr string) error + // TODO: expose warm-up capability from this package and export this? +} + +// TransportConfig defines the options used to create a round-tripper. +type TransportConfig struct { + // DialFunc should be used by the round-tripper establish network connections. + DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + // ProxyFunc should be used to control HTTP proxying behavior. If the function + // returns a non-nil URL for a given request, that URL represents the HTTP proxy + // that should be used. + ProxyFunc func(*http.Request) (*url.URL, error) + // ProxyConnectHeadersFunc should be called, if non-nil, before sending a CONNECT + // request, to query for headers to add to that request. If it returns an + // error, the round-trip operation should fail immediately with that error. + ProxyConnectHeadersFunc func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error) + // MaxResponseHeaderBytes configures the maximum size of the response status + // line and response headers. + MaxResponseHeaderBytes int64 + // IdleConnTimeout, if non-zero, is used to expire idle network connections. + IdleConnTimeout time.Duration + // TLSClientConfig, is present, provides custom TLS configuration for use + // with secure ("https") servers. + TLSClientConfig *tls.Config + // TLSHandshakeTimeout configures the maximum time allowed for a TLS handshake + // to complete. + TLSHandshakeTimeout time.Duration + // KeepWarm indicates that the round-tripper should try to keep a ready + // network connection open to reduce any delays in processing a request. + KeepWarm bool + // DisableCompression is used by the round-tripper to disable automatically + // requesting compressed content and decompressing transparently. + DisableCompression bool +} + +func transportConfigFromOptions(opts *clientOptions) TransportConfig { + return TransportConfig{ + DialFunc: opts.dialFunc, + ProxyFunc: opts.proxyFunc, + ProxyConnectHeadersFunc: opts.proxyConnectHeadersFunc, + MaxResponseHeaderBytes: opts.maxResponseHeaderBytes, + IdleConnTimeout: opts.idleConnTimeout, + TLSClientConfig: opts.tlsClientConfig, + TLSHandshakeTimeout: opts.tlsHandshakeTimeout, + DisableCompression: opts.disableCompression, + } +} + +type simpleTransport struct{} + +func (s simpleTransport) NewRoundTripper(_, _ string, opts TransportConfig) RoundTripperResult { + transport := &http.Transport{ + Proxy: opts.ProxyFunc, + GetProxyConnectHeader: opts.ProxyConnectHeadersFunc, + DialContext: opts.DialFunc, + ForceAttemptHTTP2: true, + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + IdleConnTimeout: opts.IdleConnTimeout, + TLSHandshakeTimeout: opts.TLSHandshakeTimeout, + TLSClientConfig: opts.TLSClientConfig, + MaxResponseHeaderBytes: opts.MaxResponseHeaderBytes, + ExpectContinueTimeout: 1 * time.Second, + DisableCompression: opts.DisableCompression, + } + // no way to populate pre-warm function since http.Transport doesn't provide + // any way to do that :( + return RoundTripperResult{RoundTripper: transport, Close: transport.CloseIdleConnections} +} + +// mainTransport is the root of the transport hierarchy. For each target +// backend (scheme + host:port), it maintains a transportPool. It +// implements http.RoundTripper and is used as the transport for clients +// created via NewClient. +// +// Its implementation of RoundTrip delegates to a transportPool that +// corresponds to the scheme + host:port in the request URL. +type mainTransport struct { + rootCtx context.Context //nolint:containedctx + cancel context.CancelFunc + idleTransportTimeout time.Duration + clientOptions *clientOptions + clock internal.Clock + + runningPools sync.WaitGroup + + mu sync.RWMutex + // +checklocks:mu + pools map[target]transportPoolEntry + // +checklocks:mu + closed bool +} + +func newTransport(opts *clientOptions) *mainTransport { + ctx, cancel := context.WithCancel(opts.rootCtx) + transport := &mainTransport{ + rootCtx: ctx, + cancel: cancel, + clientOptions: opts, + clock: internal.NewRealClock(), + idleTransportTimeout: opts.idleTransportTimeout, + pools: map[target]transportPoolEntry{}, + } + go func() { + // close transport immediately if context is cancelled + <-transport.rootCtx.Done() + transport.close() + }() + return transport +} + +func (m *mainTransport) close() { + m.mu.Lock() + alreadyClosed := m.closed + m.closed = true + m.mu.Unlock() + if !alreadyClosed { + m.cancel() + m.closeKeepWarmPools() + } + // Don't return until everything is cleaned up + m.runningPools.Wait() +} + +func (m *mainTransport) closeKeepWarmPools() { + // Pools that are not kept warm will close automatically thanks to the + // idle timeout goroutine, which will notice the context cancellation. + // But we have to explicitly close pools that we keep warm. + var pools []*transportPool + func() { + m.mu.Lock() + defer m.mu.Unlock() + pools = make([]*transportPool, 0, len(m.pools)) + for dest, entry := range m.pools { + if !entry.pool.transportConfig.KeepWarm { + continue + } + pools = append(pools, entry.pool) + delete(m.pools, dest) + } + }() + grp, _ := errgroup.WithContext(context.Background()) + for _, pool := range pools { + grp.Go(func() error { + pool.close() + return nil + }) + } + _ = grp.Wait() +} + +func (m *mainTransport) RoundTrip(request *http.Request) (*http.Response, error) { + dest := targetFromURL(request.URL) + for { + pool, err := m.getOrCreatePool(dest) + if err != nil { + return nil, err + } + resp, err := pool.RoundTrip(request) + if errors.Is(err, errTryAgain) { + continue + } + return resp, err + } +} + +// CloseIdleConnections is exported so that the method of the same name +// on *[http.Client] works as expected. +func (m *mainTransport) CloseIdleConnections() { + var pools []*transportPool + func() { + m.mu.RLock() + defer m.mu.RUnlock() + pools = make([]*transportPool, 0, len(m.pools)) + for _, entry := range m.pools { + pools = append(pools, entry.pool) + } + }() + for _, pool := range pools { + pool.CloseIdleConnections() + } +} + +// getOrCreatePool gets the transport pool for the given dest, creating one if +// none exists. However, this refuses to create a pool, and will return nil, if +// the transport is closed. +func (m *mainTransport) getOrCreatePool(dest target) (*transportPool, error) { + m.mu.RLock() + closed := m.closed + pool := m.getPoolLocked(dest) + m.mu.RUnlock() + + if closed { + return nil, errTransportIsClosed + } + if pool != nil { + return pool, nil + } + + m.mu.Lock() + defer m.mu.Unlock() + // double-check in case things changed while upgrading lock + if m.closed { + return nil, errTransportIsClosed + } + pool = m.getPoolLocked(dest) + if pool != nil { + return pool, nil + } + + schemeConf, ok := m.clientOptions.schemes[dest.scheme] + if !ok { + return nil, fmt.Errorf("unsupported URL scheme %q", dest.scheme) + } + + if m.clientOptions.allowedTarget != nil && *m.clientOptions.allowedTarget != dest { + return nil, fmt.Errorf("client does not allow requests to target %s, only to %s", dest, *m.clientOptions.allowedTarget) + } + var applyTimeout func(ctx context.Context) (context.Context, context.CancelFunc) + if m.clientOptions.requestTimeout > 0 { + applyTimeout = func(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, m.clientOptions.requestTimeout) + } + } else if m.clientOptions.defaultTimeout > 0 { + applyTimeout = func(ctx context.Context) (context.Context, context.CancelFunc) { + _, ok := ctx.Deadline() + if !ok { + // no existing deadline, so set one + return context.WithTimeout(ctx, m.clientOptions.defaultTimeout) + } + return ctx, func() {} + } + } + + opts := transportConfigFromOptions(m.clientOptions) + // explicitly configured targets are kept warm + opts.KeepWarm = m.clientOptions.allowedTarget != nil + + opts.TLSClientConfig = opts.TLSClientConfig.Clone() + if opts.TLSClientConfig == nil { + opts.TLSClientConfig = new(tls.Config) + } + if opts.TLSClientConfig.ServerName == "" { + host, _, err := net.SplitHostPort(dest.hostPort) + if err != nil { + host = dest.hostPort + } + opts.TLSClientConfig.ServerName = host + } + + m.runningPools.Add(1) + pool = newTransportPool( + m.rootCtx, + m.clientOptions.resolver, + m.clientOptions.newPicker, + m.clientOptions.healthChecker, + m.clientOptions.roundTripperMaxLifetime, + dest, + applyTimeout, + schemeConf, + opts, + m.runningPools.Done, + ) + var activity chan struct{} + if !opts.KeepWarm { + activity = make(chan struct{}, 1) + go m.closeWhenIdle(m.rootCtx, dest, pool, activity) + } + m.pools[dest] = transportPoolEntry{pool: pool, activity: activity} + return pool, nil +} + +// +checklocksread:m.mu +func (m *mainTransport) getPoolLocked(dest target) *transportPool { + entry := m.pools[dest] + if entry.activity != nil { + // Update activity while lock is held (should be okay since + // it's usually a read-lock, and this is a non-blocking write). + // Doing this while locked avoids race condition with idle timer + // that might be trying to concurrently close this transport. + select { + case entry.activity <- struct{}{}: + default: + } + } + return entry.pool +} + +func (m *mainTransport) closeWhenIdle(ctx context.Context, dest target, pool *transportPool, activity <-chan struct{}) { + timer := m.clock.NewTimer(m.idleTransportTimeout) + for { + select { + case <-timer.Chan(): + if m.tryRemovePool(dest, activity) { + pool.close() + return + } + // If we couldn't close pool, it's due to concurrent activity, + // so reset timer and try again. + timer.Reset(m.idleTransportTimeout) + case <-ctx.Done(): + m.removePool(dest) + pool.close() + return + case <-activity: + // bump idle timer whenever there's activity + timer.Reset(m.idleTransportTimeout) + } + } +} + +func (m *mainTransport) tryRemovePool(dest target, activity <-chan struct{}) bool { + m.mu.Lock() + defer m.mu.Unlock() + // need to check activity after lock acquired to make + // sure we aren't racing with use of this pool + select { + case <-activity: + // another goroutine is now using it + return false + default: + } + delete(m.pools, dest) + return true +} + +func (m *mainTransport) removePool(dest target) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.pools, dest) +} + +func (m *mainTransport) prewarm(ctx context.Context) error { + if m.clientOptions.allowedTarget == nil { + return nil + } + pool, _ := m.getOrCreatePool(*m.clientOptions.allowedTarget) + if pool != nil { + return pool.prewarm(ctx) + } + return nil +} + +type target struct { + scheme string + hostPort string +} + +func targetFromURL(dest *url.URL) target { + t := target{scheme: dest.Scheme, hostPort: dest.Host} + if t.scheme == "" { + t.scheme = "http" + } + return t +} + +func (t target) String() string { + return t.scheme + "://" + t.hostPort +} + +type transportPoolEntry struct { + pool *transportPool + activity chan<- struct{} +} + +// transportPool is a round tripper that is actually a pool +// of other transports. The other transports, or "connections", +// are managed by a balancer. Particular transports are +// selected for a request by a [picker.Picker]. +type transportPool struct { + dest target // +checklocksignore: mu is not required, it just happens to be held always. + applyRequestTimeout func(ctx context.Context) (context.Context, context.CancelFunc) + transport Transport // +checklocksignore: mu is not required, it just happens to be held always. + transportConfig TransportConfig // +checklocksignore: mu is not required, it just happens to be held always. + pickerInitialized chan struct{} + resolver io.Closer + reresolve chan<- struct{} + balancer *balancer + closeComplete chan struct{} + onClose func() + + picker atomic.Pointer[picker.Picker] + + mu sync.RWMutex + // +checklocks:mu + isWarm bool + // +checklocks:mu + warmCond *sync.Cond + // +checklocks:mu + conns []*connection // active + // +checklocks:mu + removedConns []*connection // pending closure + // +checklocks:mu + closed bool +} + +func newTransportPool( + ctx context.Context, + res resolver.Resolver, + newPicker func(prev picker.Picker, allConns conn.Conns) picker.Picker, + checker health.Checker, + roundTripperMaxLifetime time.Duration, + dest target, + applyTimeout func(ctx context.Context) (context.Context, context.CancelFunc), + transport Transport, + transportConfig TransportConfig, + onClose func(), +) *transportPool { + pickerInitialized := make(chan struct{}) + reresolve := make(chan struct{}, 1) + pool := &transportPool{ + dest: dest, + applyRequestTimeout: applyTimeout, + transport: transport, + transportConfig: transportConfig, + pickerInitialized: pickerInitialized, + closeComplete: make(chan struct{}), + reresolve: reresolve, + onClose: onClose, + } + pool.warmCond = sync.NewCond(&pool.mu) + pool.balancer = newBalancer(ctx, newPicker, checker, pool, roundTripperMaxLifetime) + pool.resolver = res.New(ctx, dest.scheme, dest.hostPort, pool.balancer, reresolve) + pool.balancer.start() + return pool +} + +func (t *transportPool) NewConn(address resolver.Address) (conn.Conn, bool) { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return nil, false + } + + // NOTE: When using ForceAttemptHTTP2, Go can mutate the TLSClientConfig + // without first making a defensive copy. This is intended, though not + // documented. + // https://github.com/golang/go/issues/14275 + // TODO: Possibly move to the transport impl, since that's where ForceAttemptHTTP2 is + // actually set? + opts := t.transportConfig + opts.TLSClientConfig = opts.TLSClientConfig.Clone() + + result := t.transport.NewRoundTripper(t.dest.scheme, address.HostPort, opts) + newConn := &connection{ + scheme: result.Scheme, + addr: address.HostPort, + conn: result.RoundTripper, + doPrewarm: result.prewarm, + closed: make(chan struct{}), + } + if newConn.scheme == "" { + newConn.scheme = t.dest.scheme + } + newConn.doClose = func() { + if result.Close != nil { + result.Close() + } + t.connClosed(newConn) + } + + newConn.UpdateAttributes(address.Attributes) + + // make copy of t.conns that has newConn at the end + length := len(t.conns) + newConns := make([]*connection, length+1) + copy(newConns, t.conns) + newConns[length] = newConn + t.conns = newConns + + return newConn, true +} + +func (t *transportPool) RemoveConn(toRemove conn.Conn) bool { + t.mu.Lock() + defer t.mu.Unlock() + // make copy of t.conns that has toRemove omitted + newLen := len(t.conns) - 1 + if newLen < 0 { + newLen = 0 + } + newConns := make([]*connection, 0, newLen) + found := false + for _, connection := range t.conns { + if connection == toRemove { + found = true + continue + } + newConns = append(newConns, connection) + } + t.conns = newConns + if !found { + return false + } + //nolint:forcetypeassert,errcheck // if must be this type or else found could not be true + c := toRemove.(*connection) + t.removedConns = append(t.removedConns, c) + go c.close() + return true +} + +func (t *transportPool) connClosed(closedConn conn.Conn) { + t.mu.Lock() + defer t.mu.Unlock() + // make copy of t.removedConns that has closedConn omitted + newLen := len(t.removedConns) - 1 + if newLen < 0 { + newLen = 0 + } + newRemovedConns := make([]*connection, 0, newLen) + for _, connection := range t.conns { + if connection == closedConn { + continue + } + newRemovedConns = append(newRemovedConns, connection) + } + t.removedConns = newRemovedConns +} + +func (t *transportPool) UpdatePicker(picker picker.Picker, isWarm bool) { + if t.picker.CompareAndSwap(nil, &picker) { + close(t.pickerInitialized) + } else { + t.picker.Store(&picker) + } + t.mu.Lock() + defer t.mu.Unlock() + t.isWarm = isWarm + if isWarm { + t.warmCond.Broadcast() + } +} + +func (t *transportPool) ResolveNow() { + select { + case t.reresolve <- struct{}{}: + default: + } +} + +func (t *transportPool) RoundTrip(request *http.Request) (*http.Response, error) { + chosen, whenDone, err := t.getConnection(request) + if err != nil { + return nil, err + } + var cancel context.CancelFunc + if t.applyRequestTimeout != nil { + var ctx context.Context + ctx, cancel = t.applyRequestTimeout(request.Context()) + request = request.WithContext(ctx) + } + + // rewrite request if necessary + var requestClone *http.Request + chosenScheme, chosenAddr := chosen.Scheme(), chosen.Address().HostPort + if (chosenScheme != "" && request.URL.Scheme != chosenScheme) || request.URL.Host != chosenAddr || request.Host == "" { + // Don't use request.Clone: We only need to clone the base Request and + // URL. The requestPool gives us requests with a new URL, so we need to + // restore the URL pointer after doing a shallow copy. + requestClone = requestPool.Get().(*http.Request) //nolint:errcheck,forcetypeassert // guaranteed to be *http.Request + requestURL := requestClone.URL + *requestURL = *request.URL + *requestClone = *request + requestClone.URL = requestURL + request = requestClone + if chosenScheme != "" { + request.URL.Scheme = chosenScheme + } + if request.URL.Host != chosenAddr { + request.URL.Host = chosenAddr + } + if request.Host == "" { + request.Host = request.URL.Host + } + } + + return chosen.RoundTrip(request, func() { + if cancel != nil { + cancel() + } + if whenDone != nil { + whenDone() + } + if requestClone != nil { + requestPool.Put(requestClone) + } + }) +} + +func (t *transportPool) getConnection(request *http.Request) (conn.Conn, func(), error) { + pickerPtr := t.picker.Load() + + if pickerPtr == nil { + <-t.pickerInitialized + pickerPtr = t.picker.Load() + } + + if pickerPtr == nil { + // should not be possible + return nil, nil, errors.New("internal: picker not initialized") + } + return (*pickerPtr).Pick(request) +} + +func (t *transportPool) prewarm(ctx context.Context) error { + if !t.transportConfig.KeepWarm { + // not keeping this one warm... + return nil + } + t.mu.RLock() + warm, closed := t.isWarm, t.closed + t.mu.RUnlock() + if warm || closed { + return nil + } + + // We must await the balancer indicating that connections + // are warmed up. + + // TODO: This stinks, but we do it because sync.Cond does not + // respect context :( + returned := make(chan struct{}) + defer close(returned) + go func() { + select { + case <-returned: + return + case <-ctx.Done(): + // break the loop below out of waiting when the + // context closes by broadcasting on the condition + t.mu.Lock() + defer t.mu.Unlock() + t.warmCond.Broadcast() + return + } + }() + + t.mu.Lock() + defer t.mu.Unlock() + for { + if t.isWarm { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + t.warmCond.Wait() + } +} + +func (t *transportPool) CloseIdleConnections() { + t.mu.RLock() + conns, alreadyClosed := t.conns, t.closed + t.mu.RUnlock() + if alreadyClosed { + return + } + for _, leafTransport := range conns { + type closeIdler interface { + CloseIdleConnections() + } + if closer, ok := leafTransport.conn.(closeIdler); ok { + closer.CloseIdleConnections() + } + } +} + +func (t *transportPool) close() { + t.mu.Lock() + conns, removedConns, alreadyClosed := t.conns, t.removedConns, t.closed + t.closed = true + t.mu.Unlock() + if alreadyClosed { + <-t.closeComplete + return + } + // Close resolver first. This will stop any new addresses from + // being sent to the balancer. + _ = t.resolver.Close() + // Then close the balancer. This will stop calls to create and + // remove connections. + _ = t.balancer.Close() + // Now we can stop all the connections in the pool. We do this + // for removed connections, too, to make sure we wait for all + // activity to complete before returning. There could still be + // in-progress operations on removed connections that we need + // to wait for. + grp, _ := errgroup.WithContext(context.Background()) + for _, connSlice := range [][]*connection{conns, removedConns} { + for _, current := range connSlice { + grp.Go(func() error { + current.close() + return nil + }) + } + } + _ = grp.Wait() + close(t.closeComplete) + if t.onClose != nil { + t.onClose() + } +} + +type connection struct { + scheme string + addr string + conn http.RoundTripper + attrs atomic.Pointer[attribute.Values] + + doClose func() + doPrewarm func(context.Context, string, string) error + + closed chan struct{} + // +checkatomic + outstandingRequests atomic.Int64 // negative value means closing, no more requests +} + +func (c *connection) Scheme() string { + return c.scheme +} + +func (c *connection) Address() resolver.Address { + addr := resolver.Address{HostPort: c.addr} + if attr := c.attrs.Load(); attr != nil { + addr.Attributes = *attr + } + return addr +} + +func (c *connection) UpdateAttributes(values attribute.Values) { + c.attrs.Store(&values) +} + +func (c *connection) RoundTrip(req *http.Request, whenDone func()) (*http.Response, error) { + if !c.startRequest() { + if whenDone != nil { + whenDone() + } + return nil, errTryAgain + } + onFinish := func() { + if whenDone != nil { + whenDone() + } + c.endRequest() + } + resp, err := c.conn.RoundTrip(req) + if err != nil { + onFinish() + return nil, err + } + addCompletionHook(resp, onFinish) + return resp, nil +} + +func (c *connection) Prewarm(ctx context.Context) error { + if c.doPrewarm == nil { + return nil + } + return c.doPrewarm(ctx, c.scheme, c.addr) +} + +func (c *connection) startRequest() bool { + result := c.outstandingRequests.Add(1) + if result < 0 { + // marked as closed, abort the request we started + c.outstandingRequests.Add(-1) + return false + } + return true +} + +func (c *connection) endRequest() { + result := c.outstandingRequests.Add(-1) + if result == math.MinInt64 { + close(c.closed) + } +} + +func (c *connection) close() { + // Mark closed. + for { + reqs := c.outstandingRequests.Load() + if reqs < 0 { + // already marked + break + } + markedValue := reqs - math.MinInt64 + if c.outstandingRequests.CompareAndSwap(reqs, markedValue) { + // marked! + if markedValue == math.MinInt64 { + // there were no active requests, so we can close + close(c.closed) + } + break + } + // another thread updated outstandingRequests, try again + } + + // Wait for requests to acquiesce. + <-c.closed + + // Finally, close the underlying round-tripper. + if c.doClose != nil { + c.doClose() + } +} + +type hookReadCloser struct { + io.ReadCloser + hook func() + + // +checkatomic + closed atomic.Bool +} + +func (h *hookReadCloser) done() { + if h.closed.CompareAndSwap(false, true) { + h.hook() + } +} + +func (h *hookReadCloser) Read(p []byte) (n int, err error) { + n, err = h.ReadCloser.Read(p) + if err != nil { + h.done() + } + return n, err +} + +func (h *hookReadCloser) Close() error { + err := h.ReadCloser.Close() + h.done() + return err +} + +type hookReadWriteCloser struct { + hookReadCloser + io.Writer +} + +var _ io.ReadWriteCloser = (*hookReadWriteCloser)(nil) + +func addCompletionHook( + resp *http.Response, + whenComplete func(), +) { + bodyWriter, isWriter := resp.Body.(io.Writer) + if isWriter { + resp.Body = &hookReadWriteCloser{ + hookReadCloser: hookReadCloser{ReadCloser: resp.Body, hook: whenComplete}, + Writer: bodyWriter, + } + } else { + resp.Body = &hookReadCloser{ReadCloser: resp.Body, hook: whenComplete} + } +} diff --git a/vendor/golang.org/x/sync/errgroup/errgroup.go b/vendor/golang.org/x/sync/errgroup/errgroup.go new file mode 100644 index 0000000..f69fd75 --- /dev/null +++ b/vendor/golang.org/x/sync/errgroup/errgroup.go @@ -0,0 +1,151 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package errgroup provides synchronization, error propagation, and Context +// cancellation for groups of goroutines working on subtasks of a common task. +// +// [errgroup.Group] is related to [sync.WaitGroup] but adds handling of tasks +// returning errors. +package errgroup + +import ( + "context" + "fmt" + "sync" +) + +type token struct{} + +// A Group is a collection of goroutines working on subtasks that are part of +// the same overall task. A Group should not be reused for different tasks. +// +// A zero Group is valid, has no limit on the number of active goroutines, +// and does not cancel on error. +type Group struct { + cancel func(error) + + wg sync.WaitGroup + + sem chan token + + errOnce sync.Once + err error +} + +func (g *Group) done() { + if g.sem != nil { + <-g.sem + } + g.wg.Done() +} + +// WithContext returns a new Group and an associated Context derived from ctx. +// +// The derived Context is canceled the first time a function passed to Go +// returns a non-nil error or the first time Wait returns, whichever occurs +// first. +func WithContext(ctx context.Context) (*Group, context.Context) { + ctx, cancel := context.WithCancelCause(ctx) + return &Group{cancel: cancel}, ctx +} + +// Wait blocks until all function calls from the Go method have returned, then +// returns the first non-nil error (if any) from them. +func (g *Group) Wait() error { + g.wg.Wait() + if g.cancel != nil { + g.cancel(g.err) + } + return g.err +} + +// Go calls the given function in a new goroutine. +// +// The first call to Go must happen before a Wait. +// It blocks until the new goroutine can be added without the number of +// goroutines in the group exceeding the configured limit. +// +// The first goroutine in the group that returns a non-nil error will +// cancel the associated Context, if any. The error will be returned +// by Wait. +func (g *Group) Go(f func() error) { + if g.sem != nil { + g.sem <- token{} + } + + g.wg.Add(1) + go func() { + defer g.done() + + // It is tempting to propagate panics from f() + // up to the goroutine that calls Wait, but + // it creates more problems than it solves: + // - it delays panics arbitrarily, + // making bugs harder to detect; + // - it turns f's panic stack into a mere value, + // hiding it from crash-monitoring tools; + // - it risks deadlocks that hide the panic entirely, + // if f's panic leaves the program in a state + // that prevents the Wait call from being reached. + // See #53757, #74275, #74304, #74306. + + if err := f(); err != nil { + g.errOnce.Do(func() { + g.err = err + if g.cancel != nil { + g.cancel(g.err) + } + }) + } + }() +} + +// TryGo calls the given function in a new goroutine only if the number of +// active goroutines in the group is currently below the configured limit. +// +// The return value reports whether the goroutine was started. +func (g *Group) TryGo(f func() error) bool { + if g.sem != nil { + select { + case g.sem <- token{}: + // Note: this allows barging iff channels in general allow barging. + default: + return false + } + } + + g.wg.Add(1) + go func() { + defer g.done() + + if err := f(); err != nil { + g.errOnce.Do(func() { + g.err = err + if g.cancel != nil { + g.cancel(g.err) + } + }) + } + }() + return true +} + +// SetLimit limits the number of active goroutines in this group to at most n. +// A negative value indicates no limit. +// A limit of zero will prevent any new goroutines from being added. +// +// Any subsequent call to the Go method will block until it can add an active +// goroutine without exceeding the configured limit. +// +// The limit must not be modified while any goroutines in the group are active. +func (g *Group) SetLimit(n int) { + if n < 0 { + g.sem = nil + return + } + if active := len(g.sem); active != 0 { + panic(fmt.Errorf("errgroup: modify limit while %v goroutines in the group are still active", active)) + } + g.sem = make(chan token, n) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 25c611f..61e5fc9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -26,6 +26,16 @@ github.com/antithesishq/antithesis-sdk-go/internal # github.com/beorn7/perks v1.0.1 ## explicit; go 1.11 github.com/beorn7/perks/quantile +# github.com/bufbuild/httplb v0.4.1 +## explicit; go 1.23.0 +github.com/bufbuild/httplb +github.com/bufbuild/httplb/attribute +github.com/bufbuild/httplb/conn +github.com/bufbuild/httplb/health +github.com/bufbuild/httplb/internal +github.com/bufbuild/httplb/internal/conns +github.com/bufbuild/httplb/picker +github.com/bufbuild/httplb/resolver # github.com/cenkalti/backoff/v5 v5.0.3 ## explicit; go 1.23 github.com/cenkalti/backoff/v5 @@ -301,6 +311,7 @@ golang.org/x/net/internal/timeseries golang.org/x/net/trace # golang.org/x/sync v0.20.0 ## explicit; go 1.25.0 +golang.org/x/sync/errgroup golang.org/x/sync/semaphore # golang.org/x/sys v0.42.0 ## explicit; go 1.25.0