From 6b70df152135c2d64a1cf26c7547f9d92cf804a4 Mon Sep 17 00:00:00 2001 From: Jochen Rauschenbusch <2499843+jrauschenbusch@users.noreply.github.com> Date: Fri, 12 Jan 2024 18:06:15 +0100 Subject: [PATCH] Http compression support (#335) * feat: gzip compression * fix brotli content-encoding header * fix brotli encoding-header test * fix h2 tests --- cmd/flags/grpc_test.go | 3 +- cmd/flags/http.go | 10 ++-- cmd/flags/http_test.go | 13 ++--- docs/about/getting-started.md | 1 + go.mod | 1 + go.sum | 2 + internal/pkg/http/client.go | 18 +++---- internal/pkg/http/client_test.go | 19 ++++--- internal/pkg/http/utils.go | 85 +++++++++++++++++++++++++++++--- internal/pkg/http/utils_test.go | 80 +++++++++++++++++++++++++----- internal/pkg/warmup/target.go | 4 +- internal/pkg/warmup/warmup.go | 8 ++- 12 files changed, 196 insertions(+), 48 deletions(-) diff --git a/cmd/flags/grpc_test.go b/cmd/flags/grpc_test.go index e5181773..dcf0b232 100644 --- a/cmd/flags/grpc_test.go +++ b/cmd/flags/grpc_test.go @@ -15,9 +15,10 @@ package flags import ( + "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestGrpc_ToGrpcRequests(t *testing.T) { diff --git a/cmd/flags/http.go b/cmd/flags/http.go index 2b18bcc3..539d7578 100644 --- a/cmd/flags/http.go +++ b/cmd/flags/http.go @@ -34,7 +34,8 @@ var allowedHTTPMethods = map[string]interface{}{ // HTTP stores flags related to HTTP requests. type HTTP struct { - Requests stringArray + Requests stringArray + Compression string } func (h *HTTP) String() string { @@ -43,16 +44,17 @@ func (h *HTTP) String() string { func (h *HTTP) initFlags() { flag.Var(&h.Requests, "http-requests", `HTTP request to be sent. Request is in ':[:body]' format. E.g. post:/ping:{"key":"value"}`) + flag.StringVar(&h.Compression, "http-requests-compression", "", "Compression is disabled by default. Allows compression of Http body either with `gzip`, `deflate` or `brotli`. Using one of the compression algorithms also the according `Content-Encoding` header is added.") } func (h *HTTP) getWarmupHTTPRequests() ([]http.Request, error) { - return toHTTPRequests(h.Requests) + return toHTTPRequests(h.Requests, http.CompressionType(h.Compression)) } -func toHTTPRequests(requestsFlag []string) ([]http.Request, error) { +func toHTTPRequests(requestsFlag []string, compression http.CompressionType) ([]http.Request, error) { var requests []http.Request for _, requestFlag := range requestsFlag { - request, err := http.ToHTTPRequest(requestFlag) + request, err := http.ToHTTPRequest(requestFlag, compression) if err != nil { return nil, err } diff --git a/cmd/flags/http_test.go b/cmd/flags/http_test.go index d6bddc04..38075a0d 100644 --- a/cmd/flags/http_test.go +++ b/cmd/flags/http_test.go @@ -15,10 +15,11 @@ package flags import ( - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "mittens/internal/pkg/http" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHttp_ToHttpRequests(t *testing.T) { @@ -27,7 +28,7 @@ func TestHttp_ToHttpRequests(t *testing.T) { "get:/ping", } - requests, err := toHTTPRequests(requestFlags) + requests, err := toHTTPRequests(requestFlags, http.COMPRESSION_NONE) require.NoError(t, err) require.Equal(t, 2, len(requests)) @@ -41,7 +42,7 @@ func TestHttp_ToHttpRequestsInvalidFormat(t *testing.T) { "get/health", } - requests, err := toHTTPRequests(requestFlags) + requests, err := toHTTPRequests(requestFlags, http.COMPRESSION_NONE) var expected []http.Request require.Error(t, err) @@ -55,7 +56,7 @@ func TestHttp_ToHttpRequestsInvalidMethod(t *testing.T) { "invalidMethod:/health", } - requests, err := toHTTPRequests(requestFlags) + requests, err := toHTTPRequests(requestFlags, http.COMPRESSION_NONE) var expected []http.Request require.Error(t, err) @@ -69,7 +70,7 @@ func TestHttp_ToHttpRequestsInvalidBody(t *testing.T) { "get:/test:file:test", } - requests, err := toHTTPRequests(requestFlags) + requests, err := toHTTPRequests(requestFlags, http.COMPRESSION_NONE) var expected []http.Request require.Error(t, err) diff --git a/docs/about/getting-started.md b/docs/about/getting-started.md index 41b1ffc5..01a88ce3 100644 --- a/docs/about/getting-started.md +++ b/docs/about/getting-started.md @@ -18,6 +18,7 @@ The application receives a number of command-line flags including the requests t | -http-headers | strings | N/A | Http headers to be sent with warm up requests. To send multiple headers define this flag for each header | | -grpc-requests | strings | N/A | gRPC requests to be sent. Request is in '\\\[:message\]' format. E.g. health/ping:{"key": "value"}. To send multiple requests, simply repeat this flag for each request. Use the notation `:file/xyz.json` if you want to use an external file for the request body. | | -http-requests | string | N/A | Http request to be sent. Request is in `:[:body]` format. E.g. `post:/ping:{"key": "value"}`. To send multiple requests, simply repeat this flag for each request. Use the notation `:file/xyz.json` if you want to use an external file for the request body. | +| -http-requests-compression | string | N/A | Compression is disabled by default. Allows compression of Http body either with `gzip`, `deflate` or `brotli`. Using one of the compression algorithms also the according `Content-Encoding` header is added. | | -fail-readiness | bool | false | If set to true readiness will fail if the target did not became ready in time | | -file-probe-enabled | bool | true | If set to true writes files that can be used as readiness/liveness probes. a file with the name `alive` is created when Mittens starts and a file named `ready` is created when the warmup completes | | -file-probe-liveness-path | string | alive | File to be used for liveness probe | diff --git a/go.mod b/go.mod index f5cb727b..073b5d95 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module mittens require ( + github.com/andybalholm/brotli v1.0.6 github.com/fullstorydev/grpcurl v1.8.9 github.com/golang/protobuf v1.5.3 github.com/jhump/protoreflect v1.15.4 diff --git a/go.sum b/go.sum index eb545cf1..de864aa0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/bufbuild/protocompile v0.7.1 h1:Kd8fb6EshOHXNNRtYAmLAwy/PotlyFoN0iMbuwGNh0M= github.com/bufbuild/protocompile v0.7.1/go.mod h1:+Etjg4guZoAqzVk2czwEQP12yaxLJ8DxuqCJ9qHdH94= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/pkg/http/client.go b/internal/pkg/http/client.go index bee0b751..d79b7a98 100644 --- a/internal/pkg/http/client.go +++ b/internal/pkg/http/client.go @@ -15,7 +15,6 @@ package http import ( - "bytes" "crypto/tls" "fmt" "io" @@ -23,7 +22,6 @@ import ( "log" "mittens/internal/pkg/placeholders" "mittens/internal/pkg/response" - "mittens/internal/pkg/util" "net" "net/http" "strings" @@ -75,12 +73,8 @@ func NewClient(host string, insecure bool, timeoutMilliseconds int, protocol Pro } // SendRequest sends a request to the HTTP server and wraps useful information into a Response object. -func (c Client) SendRequest(method, path string, headers []string, requestBody *string) response.Response { +func (c Client) SendRequest(method, path string, headers map[string]string, body io.Reader) response.Response { const respType = "http" - var body io.Reader - if requestBody != nil { - body = bytes.NewBufferString(*requestBody) - } url := fmt.Sprintf("%s/%s", c.host, strings.TrimLeft(path, "/")) req, err := http.NewRequest(method, url, body) @@ -90,8 +84,7 @@ func (c Client) SendRequest(method, path string, headers []string, requestBody * return response.Response{Duration: time.Duration(0), Err: err, Type: respType} } - headersMap := util.ToHeaders(headers) - for k, v := range headersMap { + for k, v := range headers { if strings.EqualFold(k, "Host") { req.Host = v } @@ -101,9 +94,14 @@ func (c Client) SendRequest(method, path string, headers []string, requestBody * req.Header.Add(k, interpolatedHeaderValue) } - + if err != nil { + defer req.Body.Close() + } startTime := time.Now() resp, err := c.httpClient.Do(req) + if resp != nil { + defer resp.Body.Close() + } endTime := time.Now() if err != nil { return response.Response{Duration: endTime.Sub(startTime), Err: err, Type: respType} diff --git a/internal/pkg/http/client_test.go b/internal/pkg/http/client_test.go index 312e9e5a..64315f45 100644 --- a/internal/pkg/http/client_test.go +++ b/internal/pkg/http/client_test.go @@ -19,6 +19,7 @@ import ( "fmt" "mittens/fixture" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -39,21 +40,24 @@ func TestMain(m *testing.M) { func TestRequestSuccessHTTP1(t *testing.T) { c := NewClient(serverUrl, false, 10000, HTTP1) reqBody := "" - resp := c.SendRequest("GET", WorkingPath, []string{}, &reqBody) + reader := strings.NewReader(reqBody) + resp := c.SendRequest("GET", WorkingPath, make(map[string]string), reader) assert.Nil(t, resp.Err) } func TestRequestSuccessH2C(t *testing.T) { c := NewClient(serverUrl, false, 10000, H2C) reqBody := "" - resp := c.SendRequest("GET", WorkingPath, []string{}, &reqBody) + reader := strings.NewReader(reqBody) + resp := c.SendRequest("GET", WorkingPath, make(map[string]string), reader) assert.Nil(t, resp.Err) } func TestHttpErrorHTTP1(t *testing.T) { c := NewClient(serverUrl, false, 10000, HTTP1) reqBody := "" - resp := c.SendRequest("GET", "/", []string{}, &reqBody) + reader := strings.NewReader(reqBody) + resp := c.SendRequest("GET", "/", make(map[string]string), reader) assert.Nil(t, resp.Err) assert.Equal(t, resp.StatusCode, 404) } @@ -61,7 +65,8 @@ func TestHttpErrorHTTP1(t *testing.T) { func TestHttpErrorH2C(t *testing.T) { c := NewClient(serverUrl, false, 10000, H2C) reqBody := "" - resp := c.SendRequest("GET", "/", []string{}, &reqBody) + reader := strings.NewReader(reqBody) + resp := c.SendRequest("GET", "/", make(map[string]string), reader) assert.Nil(t, resp.Err) assert.Equal(t, resp.StatusCode, 404) } @@ -69,14 +74,16 @@ func TestHttpErrorH2C(t *testing.T) { func TestConnectionErrorHTTP1(t *testing.T) { c := NewClient("http://localhost:9999", false, 10000, HTTP1) reqBody := "" - resp := c.SendRequest("GET", "/potato", []string{}, &reqBody) + reader := strings.NewReader(reqBody) + resp := c.SendRequest("GET", "/potato", make(map[string]string), reader) assert.NotNil(t, resp.Err) } func TestConnectionErrorH2C(t *testing.T) { c := NewClient("http://localhost:9999", false, 10000, H2C) reqBody := "" - resp := c.SendRequest("GET", "/potato", []string{}, &reqBody) + reader := strings.NewReader(reqBody) + resp := c.SendRequest("GET", "/potato", make(map[string]string), reader) assert.NotNil(t, resp.Err) } diff --git a/internal/pkg/http/utils.go b/internal/pkg/http/utils.go index 4136d488..7386c1e6 100644 --- a/internal/pkg/http/utils.go +++ b/internal/pkg/http/utils.go @@ -15,18 +15,34 @@ package http import ( + "bytes" + "compress/flate" + "compress/gzip" "fmt" + "io" "mittens/internal/pkg/placeholders" "strings" + + "github.com/andybalholm/brotli" ) // Request represents an HTTP request. type Request struct { - Method string - Path string - Body *string + Method string + Headers map[string]string + Path string + Body io.Reader } +type CompressionType string + +const ( + COMPRESSION_NONE CompressionType = "" + COMPRESSION_GZIP CompressionType = "gzip" + COMPRESSION_BROTLI CompressionType = "brotli" + COMPRESSION_DEFLATE CompressionType = "deflate" +) + var allowedHTTPMethods = map[string]interface{}{ "GET": nil, "HEAD": nil, @@ -39,9 +55,8 @@ var allowedHTTPMethods = map[string]interface{}{ "TRACE": nil, } -// // ToHTTPRequest parses an HTTP request which is in a string format and stores it in a struct. -func ToHTTPRequest(requestString string) (Request, error) { +func ToHTTPRequest(requestString string, compression CompressionType) (Request, error) { parts := strings.SplitN(requestString, ":", 3) if len(parts) < 2 { return Request{}, fmt.Errorf("invalid request flag: %s, expected format :[:body]", requestString) @@ -72,9 +87,63 @@ func ToHTTPRequest(requestString string) (Request, error) { } var body = placeholders.InterpolatePlaceholders(*rawBody) + var reader io.Reader + switch compression { + case COMPRESSION_GZIP: + reader = compressGzip([]byte(body)) + case COMPRESSION_BROTLI: + reader = compressBrotli([]byte(body)) + case COMPRESSION_DEFLATE: + reader = compressFlate([]byte(body)) + default: + reader = bytes.NewBufferString(body) + } + + headers := make(map[string]string) + if compression != COMPRESSION_NONE { + encoding := "" + switch compression { + case COMPRESSION_GZIP: + encoding = "gzip" + case COMPRESSION_BROTLI: + encoding = "br" + case COMPRESSION_DEFLATE: + encoding = "deflate" + } + headers["Content-Encoding"] = encoding + } + return Request{ - Method: method, - Path: path, - Body: &body, + Method: method, + Headers: headers, + Path: path, + Body: reader, }, nil } + +func compressGzip(data []byte) io.Reader { + pr, pw := io.Pipe() + go func() { + gz := gzip.NewWriter(pw) + _, err := gz.Write(data) + gz.Close() + pw.CloseWithError(err) + }() + return pr +} + +func compressFlate(data []byte) *bytes.Buffer { + var b bytes.Buffer + w, _ := flate.NewWriter(&b, 9) + w.Write(data) + w.Close() + return &b +} + +func compressBrotli(data []byte) *bytes.Buffer { + var b bytes.Buffer + w := brotli.NewWriterLevel(&b, brotli.BestCompression) + w.Write(data) + w.Close() + return &b +} diff --git a/internal/pkg/http/utils_test.go b/internal/pkg/http/utils_test.go index faa49fe7..5ea3e144 100644 --- a/internal/pkg/http/utils_test.go +++ b/internal/pkg/http/utils_test.go @@ -15,6 +15,7 @@ package http import ( + "bytes" "net/http" "os" "regexp" @@ -28,12 +29,63 @@ import ( func TestHttp_FlagToHttpRequest(t *testing.T) { requestFlag := `post:/db:{"db": "true"}` - request, err := ToHTTPRequest(requestFlag) + request, err := ToHTTPRequest(requestFlag, COMPRESSION_NONE) require.NoError(t, err) assert.Equal(t, http.MethodPost, request.Method) assert.Equal(t, "/db", request.Path) - assert.Equal(t, `{"db": "true"}`, *request.Body) + body := new(bytes.Buffer) + body.ReadFrom(request.Body) + assert.Equal(t, `{"db": "true"}`, body.String()) +} + +func TestHttp_CompressGzip(t *testing.T) { + requestFlag := `post:/db:{"db": "true"}` + request, err := ToHTTPRequest(requestFlag, COMPRESSION_GZIP) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "/db", request.Path) + + assert.Equal(t, map[string]string{"Content-Encoding": "gzip"}, request.Headers) + + body := new(bytes.Buffer) + body.ReadFrom(request.Body) + expected := &bytes.Buffer{} + expected.Write([]byte{0x1f, 0x8b, 0x8, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xff, 0xaa, 0x56, 0x4a, 0x49, 0x52, 0xb2, 0x52, 0x50, 0x2a, 0x29, 0x2a, 0x4d, 0x55, 0xaa, 0x5, 0x4, 0x0, 0x0, 0xff, 0xff, 0xa1, 0x4a, 0x9b, 0x5d, 0xe, 0x0, 0x0, 0x0}) + assert.Equal(t, expected, body) +} + +func TestHttp_CompressBrotli(t *testing.T) { + requestFlag := `post:/db:{"db": "true"}` + request, err := ToHTTPRequest(requestFlag, COMPRESSION_BROTLI) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "/db", request.Path) + + assert.Equal(t, map[string]string{"Content-Encoding": "br"}, request.Headers) + + body := new(bytes.Buffer) + body.ReadFrom(request.Body) + expected := &bytes.Buffer{} + expected.Write([]byte{0x8b, 0x6, 0x80, 0x7b, 0x22, 0x64, 0x62, 0x22, 0x3a, 0x20, 0x22, 0x74, 0x72, 0x75, 0x65, 0x22, 0x7d, 0x3}) + assert.Equal(t, expected, body) +} + +func TestHttp_CompressDeflate(t *testing.T) { + requestFlag := `post:/db:{"db": "true"}` + request, err := ToHTTPRequest(requestFlag, COMPRESSION_DEFLATE) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "/db", request.Path) + assert.Equal(t, map[string]string{"Content-Encoding": "deflate"}, request.Headers) + body := new(bytes.Buffer) + body.ReadFrom(request.Body) + expected := &bytes.Buffer{} + expected.Write([]byte{0xaa, 0x56, 0x4a, 0x49, 0x52, 0xb2, 0x52, 0x50, 0x2a, 0x29, 0x2a, 0x4d, 0x55, 0xaa, 0x5, 0x4, 0x0, 0x0, 0xff, 0xff}) + assert.Equal(t, expected, body) } func TestBodyFromFile(t *testing.T) { @@ -43,17 +95,19 @@ func TestBodyFromFile(t *testing.T) { defer os.Remove(file) requestFlag := `post:/db:file:` + file - request, err := ToHTTPRequest(requestFlag) + request, err := ToHTTPRequest(requestFlag, COMPRESSION_NONE) require.NoError(t, err) assert.Equal(t, http.MethodPost, request.Method) assert.Equal(t, "/db", request.Path) - assert.Equal(t, `{"foo": "bar"}`, *request.Body) + buf := new(bytes.Buffer) + buf.ReadFrom(request.Body) + assert.Equal(t, `{"foo": "bar"}`, buf.String()) } func TestHttp_FlagWithoutBodyToHttpRequest(t *testing.T) { requestFlag := `get:ping` - request, err := ToHTTPRequest(requestFlag) + request, err := ToHTTPRequest(requestFlag, COMPRESSION_NONE) require.NoError(t, err) assert.Equal(t, http.MethodGet, request.Method) @@ -63,30 +117,32 @@ func TestHttp_FlagWithoutBodyToHttpRequest(t *testing.T) { func TestHttp_FlagWithInvalidMethodToHttpRequest(t *testing.T) { requestFlag := `hmm:/ping:all=true` - _, err := ToHTTPRequest(requestFlag) + _, err := ToHTTPRequest(requestFlag, COMPRESSION_NONE) require.Error(t, err) } func TestHttp_TimestampInterpolation(t *testing.T) { requestFlag := `post:/path_{$currentTimestamp}:{"body": "{$currentTimestamp}"}` - request, err := ToHTTPRequest(requestFlag) + request, err := ToHTTPRequest(requestFlag, COMPRESSION_NONE) require.NoError(t, err) assert.Equal(t, http.MethodPost, request.Method) var numbersRegex = regexp.MustCompile("\\d+") matchPath := numbersRegex.MatchString(request.Path) - matchBody := numbersRegex.MatchString(*request.Body) + body := new(bytes.Buffer) + body.ReadFrom(request.Body) + matchBody := numbersRegex.MatchString(body.String()) assert.True(t, matchPath) assert.True(t, matchBody) assert.Equal(t, len(request.Path), 19) // "path_ + 13 numbers for timestamp - assert.Equal(t, len(*request.Body), 25) // { "body": 13 numbers for timestamp + assert.Equal(t, len(body.String()), 25) // { "body": 13 numbers for timestamp } func TestHttp_Interpolation(t *testing.T) { requestFlag := `post:/path_{$range|min=1,max=2}_{$random|foo,bar}:{"body": "{$random|foo,bar} {$range|min=1,max=2}"}` - request, err := ToHTTPRequest(requestFlag) + request, err := ToHTTPRequest(requestFlag, COMPRESSION_NONE) require.NoError(t, err) assert.Equal(t, http.MethodPost, request.Method) @@ -95,7 +151,9 @@ func TestHttp_Interpolation(t *testing.T) { matchPath := pathRegex.MatchString(request.Path) var bodyRegex = regexp.MustCompile("{\"body\": \"(foo|bar) \\d\"}") - matchBody := bodyRegex.MatchString(*request.Body) + body := new(bytes.Buffer) + body.ReadFrom(request.Body) + matchBody := bodyRegex.MatchString(body.String()) assert.True(t, matchPath) assert.True(t, matchBody) diff --git a/internal/pkg/warmup/target.go b/internal/pkg/warmup/target.go index 4c80fcc6..e28942a2 100644 --- a/internal/pkg/warmup/target.go +++ b/internal/pkg/warmup/target.go @@ -19,6 +19,7 @@ import ( "log" "mittens/internal/pkg/grpc" whttp "mittens/internal/pkg/http" + "mittens/internal/pkg/util" "net/http" "time" ) @@ -59,6 +60,7 @@ func (t Target) WaitForReadinessProbe(maxReadinessWaitDurationInSeconds int, hea log.Printf("Waiting for %s target to be ready for a max of %ds", t.options.ReadinessProtocol, maxReadinessWaitDurationInSeconds) timeout := time.After(time.Duration(maxReadinessWaitDurationInSeconds) * time.Second) + headersMap := util.ToHeaders(headers) for { select { @@ -70,7 +72,7 @@ func (t Target) WaitForReadinessProbe(maxReadinessWaitDurationInSeconds int, hea if t.options.ReadinessProtocol == "http" { // error if error in the response or status code not in the 200 range - if resp := t.readinessHTTPClient.SendRequest(http.MethodGet, t.options.ReadinessHTTPPath, headers, nil); resp.Err != nil || resp.StatusCode/100 != 2 { + if resp := t.readinessHTTPClient.SendRequest(http.MethodGet, t.options.ReadinessHTTPPath, headersMap, nil); resp.Err != nil || resp.StatusCode/100 != 2 { log.Printf("HTTP target not ready yet...") continue } diff --git a/internal/pkg/warmup/warmup.go b/internal/pkg/warmup/warmup.go index 9fc989c5..0633ae6f 100644 --- a/internal/pkg/warmup/warmup.go +++ b/internal/pkg/warmup/warmup.go @@ -16,10 +16,12 @@ package warmup import ( "log" + "maps" "math/rand" "mittens/internal/pkg/grpc" "mittens/internal/pkg/http" "mittens/internal/pkg/safe" + "mittens/internal/pkg/util" "sync" "time" @@ -132,7 +134,11 @@ func (w Warmup) HTTPWarmupWorker(wg *sync.WaitGroup, requests <-chan http.Reques for request := range requests { time.Sleep(time.Duration(requestDelayMilliseconds) * time.Millisecond) - resp := w.Target.httpClient.SendRequest(request.Method, request.Path, headers, request.Body) + headersMap := util.ToHeaders(headers) + // Overwrite Content-Encoding header if required + maps.Copy(headersMap, request.Headers) + + resp := w.Target.httpClient.SendRequest(request.Method, request.Path, headersMap, request.Body) if resp.Err != nil { log.Printf("🔴 Error in request for %s: %v", request.Path, resp.Err)