Repository: avenga/couper
Pull request #116 (2021-02-15 10:49:19)

Make transport reusable


Patch:
diff --git a/handler/proxy.go b/handler/proxy.go
index 46e6397f..5e222558 100644
--- a/handler/proxy.go
+++ b/handler/proxy.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"compress/gzip"
 	"context"
-	"crypto/tls"
 	"encoding/base64"
 	"errors"
 	"fmt"
@@ -16,13 +15,11 @@ import (
 	"regexp"
 	"strconv"
 	"strings"
-	"sync"
 	"time"
 
 	"github.com/hashicorp/hcl/v2"
 	"github.com/sirupsen/logrus"
 	"golang.org/x/net/http/httpguts"
-	"golang.org/x/net/http/httpproxy"
 
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/body"
@@ -48,8 +45,6 @@ var (
 	_ http.Handler   = &Proxy{}
 	_ server.Context = &Proxy{}
 
-	transports sync.Map
-
 	// headerBlacklist lists all header keys which will be removed after
 	// context variable evaluation to ensure to not pass them upstream.
 	headerBlacklist = []string{"Authorization", "Cookie"}
@@ -118,9 +113,13 @@ func (c *CORSOptions) AllowsOrigin(origin string) bool {
 }
 
 func NewProxy(options *ProxyOptions, log *logrus.Entry, srvOpts *server.Options, evalCtx *hcl.EvalContext) (http.Handler, error) {
+	if options.Transport == nil {
+		options.Transport = &TransportConfig{} // For test cases
+	}
+
 	logConf := *logging.DefaultConfig
 	logConf.TypeFieldKey = "couper_backend"
-	logConf.NoProxyFromEnv = options.NoProxyFromEnv
+	logConf.NoProxyFromEnv = options.Transport.NoProxyFromEnv
 	env.DecodeWithPrefix(&logConf, "BACKEND_")
 
 	var apiValidation eval.BufferOption
@@ -141,70 +140,6 @@ func NewProxy(options *ProxyOptions, log *logrus.Entry, srvOpts *server.Options,
 	return proxy, nil
 }
 
-func (p *Proxy) getTransport(scheme, origin, hostname string) *http.Transport {
-	key := scheme + "|" + origin + "|" + hostname + "|" + p.optionsHash
-	transport, ok := transports.Load(key)
-	if !ok {
-		tlsConf := &tls.Config{
-			InsecureSkipVerify: p.options.DisableCertValidation,
-		}
-		if origin != hostname {
-			tlsConf.ServerName = hostname
-		}
-
-		d := &net.Dialer{
-			KeepAlive: 60 * time.Second,
-			Timeout:   p.options.ConnectTimeout,
-		}
-
-		var proxyFunc func(req *http.Request) (*url.URL, error)
-		if p.options.Proxy != "" {
-			proxyFunc = func(req *http.Request) (*url.URL, error) {
-				c := &httpproxy.Config{
-					HTTPProxy:  p.options.Proxy,
-					HTTPSProxy: p.options.Proxy,
-				}
-
-				return c.ProxyFunc()(req.URL)
-			}
-		} else if !p.options.NoProxyFromEnv {
-			proxyFunc = http.ProxyFromEnvironment
-		}
-
-		// This is the documented way to disable http2. However if a custom tls.Config or
-		// DialContext is used h2 will also be disabled. To enable h2 the transport must be
-		// explicitly configured, this can be done with the 'ForceAttemptHTTP2' below.
-		var nextProto map[string]func(authority string, c *tls.Conn) http.RoundTripper
-		if !p.options.HTTP2 {
-			nextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
-		}
-
-		transport = &http.Transport{
-			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
-				conn, err := d.DialContext(ctx, network, addr)
-				if err != nil {
-					return nil, fmt.Errorf("connecting to %s %q failed: %w", p.options.BackendName, addr, err)
-				}
-				return conn, nil
-			},
-			DisableCompression:    true,
-			DisableKeepAlives:     p.options.DisableConnectionReuse,
-			ForceAttemptHTTP2:     p.options.HTTP2,
-			MaxConnsPerHost:       p.options.MaxConnections,
-			Proxy:                 proxyFunc,
-			ResponseHeaderTimeout: p.options.TTFBTimeout,
-			TLSClientConfig:       tlsConf,
-			TLSNextProto:          nextProto,
-		}
-
-		transports.Store(key, transport)
-	}
-	if t, ok := transport.(*http.Transport); ok {
-		return t
-	}
-	return nil
-}
-
 func (p *Proxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 	startTime := time.Now()
 
@@ -214,14 +149,14 @@ func (p *Proxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 		return
 	}
 
-	*req = *req.Clone(context.WithValue(req.Context(), request.BackendName, p.options.BackendName))
+	*req = *req.Clone(context.WithValue(req.Context(), request.BackendName, p.options.Transport.BackendName))
 	p.upstreamLog.ServeHTTP(rw, req, logging.RoundtripHandlerFunc(p.roundtrip), startTime)
 }
 
 func (p *Proxy) roundtrip(rw http.ResponseWriter, req *http.Request) {
 	ctx := req.Context()
-	if p.options.Timeout > 0 {
-		deadline := time.Now().Add(p.options.Timeout)
+	if p.options.Transport.Timeout > 0 {
+		deadline := time.Now().Add(p.options.Transport.Timeout)
 		c, cancelFn := context.WithDeadline(req.Context(), deadline)
 		ctx = c
 		defer cancelFn()
@@ -282,7 +217,14 @@ func (p *Proxy) roundtrip(rw http.ResponseWriter, req *http.Request) {
 		}
 	}
 
-	res, err := p.getTransport(outreq.URL.Scheme, outreq.URL.Host, outreq.Host).RoundTrip(outreq)
+	conf := p.options.Transport
+	conf.Hash = p.optionsHash
+	conf.Hostname = outreq.Host
+	conf.Origin = outreq.URL.Host
+	conf.Scheme = outreq.URL.Scheme
+
+	res, err := getTransport(conf).RoundTrip(outreq)
+
 	roundtripInfo.BeReq, roundtripInfo.BeResp = outreq, res
 	if err != nil {
 		roundtripInfo.Err = err
diff --git a/handler/proxy_options.go b/handler/proxy_options.go
index 61992faf..b2628d39 100644
--- a/handler/proxy_options.go
+++ b/handler/proxy_options.go
@@ -13,21 +13,14 @@ import (
 )
 
 type ProxyOptions struct {
-	BasicAuth                            string
-	ConnectTimeout, Timeout, TTFBTimeout time.Duration
-	Context                              hcl.Body
-	BackendName                          string
-	CORS                                 *CORSOptions
-	NoProxyFromEnv                       bool
-	DisableCertValidation                bool
-	DisableConnectionReuse               bool
-	HTTP2                                bool
-	MaxConnections                       int
-	OpenAPI                              *OpenAPIValidatorOptions
-	ErrorTemplate                        *errors.Template
-	Kind                                 string
-	Proxy                                string
-	RequestBodyLimit                     int64
+	BasicAuth        string
+	Context          hcl.Body
+	CORS             *CORSOptions
+	ErrorTemplate    *errors.Template
+	Kind             string
+	OpenAPI          *OpenAPIValidatorOptions
+	RequestBodyLimit int64
+	Transport        *TransportConfig
 }
 
 func NewProxyOptions(
@@ -61,23 +54,25 @@ func NewProxyOptions(
 	}
 
 	return &ProxyOptions{
-		BasicAuth:              conf.BasicAuth,
-		BackendName:            conf.Name,
-		CORS:                   cors,
-		Context:                conf.Remain,
-		ConnectTimeout:         connTimeout,
-		DisableCertValidation:  conf.DisableCertValidation,
-		DisableConnectionReuse: conf.DisableConnectionReuse,
-		HTTP2:                  conf.HTTP2,
-		MaxConnections:         conf.MaxConnections,
-		NoProxyFromEnv:         noProxyFromEnv,
-		OpenAPI:                openAPIValidatorOptions,
-		ErrorTemplate:          errTpl,
-		Kind:                   kind,
-		Proxy:                  conf.Proxy,
-		RequestBodyLimit:       bodyLimit,
-		TTFBTimeout:            ttfbTimeout,
-		Timeout:                timeout,
+		BasicAuth:        conf.BasicAuth,
+		CORS:             cors,
+		Context:          conf.Remain,
+		OpenAPI:          openAPIValidatorOptions,
+		ErrorTemplate:    errTpl,
+		Kind:             kind,
+		RequestBodyLimit: bodyLimit,
+		Transport: &TransportConfig{
+			BackendName:            conf.Name,
+			ConnectTimeout:         connTimeout,
+			DisableCertValidation:  conf.DisableCertValidation,
+			DisableConnectionReuse: conf.DisableConnectionReuse,
+			HTTP2:                  conf.HTTP2,
+			MaxConnections:         conf.MaxConnections,
+			NoProxyFromEnv:         noProxyFromEnv,
+			Proxy:                  conf.Proxy,
+			TTFBTimeout:            ttfbTimeout,
+			Timeout:                timeout,
+		},
 	}, nil
 }
 
diff --git a/handler/transport.go b/handler/transport.go
new file mode 100644
index 00000000..b278f843
--- /dev/null
+++ b/handler/transport.go
@@ -0,0 +1,97 @@
+package handler
+
+import (
+	"context"
+	"crypto/tls"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"sync"
+	"time"
+
+	"golang.org/x/net/http/httpproxy"
+)
+
+var transports sync.Map
+
+// TransportConfig represents the <TransportConfig> object.
+type TransportConfig struct {
+	BackendName            string
+	ConnectTimeout         time.Duration
+	DisableCertValidation  bool
+	DisableConnectionReuse bool
+	Hash                   string
+	Hostname               string
+	HTTP2                  bool
+	MaxConnections         int
+	NoProxyFromEnv         bool
+	Origin                 string
+	Proxy                  string
+	Scheme                 string
+	TTFBTimeout            time.Duration
+	Timeout                time.Duration
+}
+
+func getTransport(conf *TransportConfig) *http.Transport {
+	key := conf.Scheme + "|" + conf.Origin + "|" + conf.Hostname + "|" + conf.Hash
+
+	transport, ok := transports.Load(key)
+	if !ok {
+		tlsConf := &tls.Config{
+			InsecureSkipVerify: conf.DisableCertValidation,
+		}
+		if conf.Origin != conf.Hostname {
+			tlsConf.ServerName = conf.Hostname
+		}
+
+		d := &net.Dialer{Timeout: conf.ConnectTimeout}
+
+		var proxyFunc func(req *http.Request) (*url.URL, error)
+		if conf.Proxy != "" {
+			proxyFunc = func(req *http.Request) (*url.URL, error) {
+				config := &httpproxy.Config{
+					HTTPProxy:  conf.Proxy,
+					HTTPSProxy: conf.Proxy,
+				}
+
+				return config.ProxyFunc()(req.URL)
+			}
+		} else if !conf.NoProxyFromEnv {
+			proxyFunc = http.ProxyFromEnvironment
+		}
+
+		var nextProto map[string]func(authority string, c *tls.Conn) http.RoundTripper
+		if !conf.HTTP2 {
+			nextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
+		}
+
+		transport = &http.Transport{
+			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+				conn, err := d.DialContext(ctx, network, addr)
+				if err != nil {
+					return nil, fmt.Errorf("connecting to %s %q failed: %w", conf.BackendName, addr, err)
+				}
+				return conn, nil
+			},
+			Dial: (&net.Dialer{
+				KeepAlive: 60 * time.Second,
+			}).Dial,
+			DisableCompression:    true,
+			DisableKeepAlives:     conf.DisableConnectionReuse,
+			MaxConnsPerHost:       conf.MaxConnections,
+			Proxy:                 proxyFunc,
+			ResponseHeaderTimeout: conf.TTFBTimeout,
+			TLSClientConfig:       tlsConf,
+			TLSNextProto:          nextProto,
+		}
+
+		transports.Store(key, transport)
+	}
+
+	if t, ok := transport.(*http.Transport); ok {
+		return t
+	}
+
+	return nil
+}


Test patch:
diff --git a/handler/proxy_test.go b/handler/proxy_test.go
index bd158827..4eeda1c7 100644
--- a/handler/proxy_test.go
+++ b/handler/proxy_test.go
@@ -49,9 +49,9 @@ func TestProxy_ServeHTTP_Timings(t *testing.T) {
 		expectedStatus int
 	}{
 		{"with zero timings", &handler.ProxyOptions{Context: test.NewRemainContext("origin", origin.URL), ErrorTemplate: tpl}, httptest.NewRequest(http.MethodGet, "http://1.2.3.4/", nil), http.StatusNoContent},
-		{"with overall timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://1.2.3.4/"), ErrorTemplate: tpl, Timeout: time.Second}, httptest.NewRequest(http.MethodGet, "http://1.2.3.5/", nil), http.StatusBadGateway},
-		{"with connect timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://blackhole.webpagetest.org/"), ErrorTemplate: tpl, ConnectTimeout: time.Second}, httptest.NewRequest(http.MethodGet, "http://1.2.3.6/", nil), http.StatusBadGateway},
-		{"with ttfb timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", origin.URL), ErrorTemplate: tpl, TTFBTimeout: time.Second}, httptest.NewRequest(http.MethodHead, "http://1.2.3.7/", nil), http.StatusBadGateway},
+		{"with overall timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://1.2.3.4/"), ErrorTemplate: tpl, Transport: &handler.TransportConfig{Timeout: time.Second}}, httptest.NewRequest(http.MethodGet, "http://1.2.3.5/", nil), http.StatusBadGateway},
+		{"with connect timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://blackhole.webpagetest.org/"), ErrorTemplate: tpl, Transport: &handler.TransportConfig{ConnectTimeout: time.Second}}, httptest.NewRequest(http.MethodGet, "http://1.2.3.6/", nil), http.StatusBadGateway},
+		{"with ttfb timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", origin.URL), ErrorTemplate: tpl, Transport: &handler.TransportConfig{TTFBTimeout: time.Second}}, httptest.NewRequest(http.MethodHead, "http://1.2.3.7/", nil), http.StatusBadGateway},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/internal/test/helper_proxy.go b/internal/test/helper_proxy.go
index c5c5bc5b..604e0426 100644
--- a/internal/test/helper_proxy.go
+++ b/internal/test/helper_proxy.go
@@ -20,7 +20,11 @@ func (h *Helper) NewProxy(opts *handler.ProxyOptions) (*handler.Proxy, *http.Cli
 	var upstream http.HandlerFunc
 	server := httptest.NewServer(upstream)
 
-	opts.BackendName = "HelperUpstream"
+	if opts.Transport == nil {
+		opts.Transport = &handler.TransportConfig{}
+	}
+
+	opts.Transport.BackendName = "HelperUpstream"
 	proxy, err := handler.NewProxy(opts, logger.WithContext(context.Background()), nil, eval.NewENVContext(nil))
 	h.Must(err)