Skip to content

Commit ea17ae8

Browse files
Merge pull request #166 from monzo/add-max-connection-age-server-option
Add max connection age typhon server option
2 parents b08f231 + 32e4835 commit ea17ae8

File tree

2 files changed

+116
-1
lines changed

2 files changed

+116
-1
lines changed

e2e_test.go

+38-1
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import (
1414
"time"
1515

1616
"github.com/fortytw2/leaktest"
17-
"github.com/monzo/terrors"
1817
"github.com/stretchr/testify/assert"
1918
"github.com/stretchr/testify/require"
2019
"golang.org/x/net/http2"
2120

21+
"github.com/monzo/terrors"
22+
2223
"github.com/monzo/typhon/prototest"
2324
)
2425

@@ -840,3 +841,39 @@ func TestE2EServerTimeouts(t *testing.T) {
840841
}
841842
})
842843
}
844+
845+
// TestE2EMaxConnectionAge_ConnectionAgeSet tests that when using the
846+
// WithMaxConnectionAge server option the connection age is available
847+
// to the handler. This is a fairly weak test as it doesn't actually
848+
// test that connections are closed. Unfortunately our test framework
849+
// seems to always close connections so this is as good as we can get.
850+
func TestE2EMaxConnectionAge_ConnectionAgeSet(t *testing.T) {
851+
addConnectionStartTimeHeader = true
852+
853+
someFlavours(t, []string{
854+
"http1.1",
855+
"http1.1-tls",
856+
"http2.0-h2",
857+
858+
// The Go h2c implementation doesn't currently support max
859+
// connection age.
860+
}, func(t *testing.T, flav e2eFlavour) {
861+
ctx, cancel := flav.Context()
862+
defer cancel()
863+
864+
srv := Service(func(req Request) Response {
865+
time.Sleep(100 * time.Millisecond)
866+
return NewResponse(req)
867+
})
868+
869+
srv = srv.Filter(ErrorFilter)
870+
s := flav.Serve(srv, WithMaxConnectionAge(time.Hour))
871+
defer s.Stop(ctx)
872+
873+
req := NewRequest(ctx, "GET", flav.URL(s), nil)
874+
rsp := req.Send().Response()
875+
assert.NoError(t, rsp.Error)
876+
connectionStart := rsp.Header.Get(connectionStartTimeHeaderKey)
877+
require.NotEmpty(t, connectionStart)
878+
})
879+
}

server.go

+78
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,81 @@ func WithTimeout(opts TimeoutOptions) ServerOption {
154154
s.srv.IdleTimeout = opts.Idle
155155
}
156156
}
157+
158+
var (
159+
connectionStartTimeHeaderKey = "X-Typhon-Connection-Start"
160+
// addConnectionStartTimeHeader is set to true within tests to
161+
// make it easier to test the server option.
162+
addConnectionStartTimeHeader = false
163+
)
164+
165+
// WithMaxConnectionAge returns a server option that will enforce a max
166+
// connection age. When a connection has reached the max connection age
167+
// then the next request that is processed on that connection will result
168+
// in the connection being gracefully closed. This does mean that if a
169+
// connection is not being used then it can outlive the maximum connection
170+
// age.
171+
func WithMaxConnectionAge(maxAge time.Duration) ServerOption {
172+
// We have no ability within a handler to get access to the
173+
// underlying net.Conn that the request came on. However,
174+
// the http.Server has a ConnContext field that can be used
175+
// to specify a function that can modify the context used for
176+
// that connection. We can use this to store the connection
177+
// start time in the context and then in the handler we can
178+
// read that out and whenever the maxAge has been exceeded we
179+
// can close the connection.
180+
//
181+
// We could close the connection by calling the Close method
182+
// on the net.Conn. This would have the benefit that we could
183+
// close the connection exactly at the expiry but would have
184+
// the disadvantage that it does not gracefully close the
185+
// connection – it would kill all in-flight requests. Instead,
186+
// we set the 'Connection: close' response header which will
187+
// be translated into an HTTP2 GOAWAY frame and result in the
188+
// connection being gracefully closed.
189+
190+
return func(s *Server) {
191+
// Wrap the current ConnContext (if set) to store a reference
192+
// to the connection start time in the context.
193+
origConnContext := s.srv.ConnContext
194+
s.srv.ConnContext = func(ctx context.Context, conn net.Conn) context.Context {
195+
if origConnContext != nil {
196+
ctx = origConnContext(ctx, conn)
197+
}
198+
199+
return setConnectionStartTimeInContext(ctx, time.Now())
200+
}
201+
202+
// Wrap the handler to set the 'Connection: close' response
203+
// header if the max age has been exceeded.
204+
origHandler := s.srv.Handler
205+
s.srv.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
206+
connectionStart, ok := readConnectionStartTimeFromContext(request.Context())
207+
if ok {
208+
if time.Since(connectionStart) > maxAge {
209+
h := writer.Header()
210+
h.Add("Connection", "close")
211+
}
212+
213+
// This is used within tests
214+
if addConnectionStartTimeHeader {
215+
h := writer.Header()
216+
h.Add(connectionStartTimeHeaderKey, connectionStart.String())
217+
}
218+
}
219+
220+
origHandler.ServeHTTP(writer, request)
221+
})
222+
}
223+
}
224+
225+
type connectionContextKey struct{}
226+
227+
func setConnectionStartTimeInContext(parent context.Context, t time.Time) context.Context {
228+
return context.WithValue(parent, connectionContextKey{}, t)
229+
}
230+
231+
func readConnectionStartTimeFromContext(ctx context.Context) (time.Time, bool) {
232+
conn, ok := ctx.Value(connectionContextKey{}).(time.Time)
233+
return conn, ok
234+
}

0 commit comments

Comments
 (0)