http3: correctly handle closed clients (#3684)

* http3: use a mock roundTripCloser in tests

* http3: correctly handle failed clients

Specifically,
* immediately remove a client when a request errored
* if that error was an idle error, and the client was a reused client
(from an earlier request that already completed the handshake),
re-dial the connection
This commit is contained in:
Marten Seemann 2023-01-28 00:49:52 -08:00 committed by GitHub
parent 7b2c69451e
commit 89769f409f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 336 additions and 147 deletions

View file

@ -10,27 +10,14 @@ import (
"time"
"github.com/quic-go/quic-go"
mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
"github.com/quic-go/quic-go/internal/qerr"
"github.com/golang/mock/gomock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockClient struct {
closed bool
}
func (m *mockClient) RoundTripOpt(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
return &http.Response{Request: req}, nil
}
func (m *mockClient) Close() error {
m.closed = true
return nil
}
var _ roundTripCloser = &mockClient{}
//go:generate sh -c "./../mockgen_private.sh http3 mock_roundtripcloser_test.go github.com/quic-go/quic-go/http3 roundTripCloser"
type mockBody struct {
reader bytes.Reader
@ -60,57 +47,29 @@ func (m *mockBody) Close() error {
var _ = Describe("RoundTripper", func() {
var (
rt *RoundTripper
req1 *http.Request
conn *mockquic.MockEarlyConnection
handshakeCtx context.Context // an already canceled context
rt *RoundTripper
req *http.Request
)
BeforeEach(func() {
rt = &RoundTripper{}
var err error
req1, err = http.NewRequest("GET", "https://www.example.org/file1.html", nil)
req, err = http.NewRequest("GET", "https://www.example.org/file1.html", nil)
Expect(err).ToNot(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
cancel()
handshakeCtx = ctx
})
Context("dialing hosts", func() {
origDialAddr := dialAddr
BeforeEach(func() {
conn = mockquic.NewMockEarlyConnection(mockCtrl)
origDialAddr = dialAddr
dialAddr = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
// return an error when trying to open a stream
// we don't want to test all the dial logic here, just that dialing happens at all
return conn, nil
}
})
AfterEach(func() {
dialAddr = origDialAddr
})
It("creates new clients", func() {
closed := make(chan struct{})
testErr := errors.New("test err")
req, err := http.NewRequest("GET", "https://quic.clemente.io/foobar.html", nil)
Expect(err).ToNot(HaveOccurred())
conn.EXPECT().OpenUniStream().AnyTimes().Return(nil, testErr)
conn.EXPECT().HandshakeComplete().Return(handshakeCtx)
conn.EXPECT().OpenStreamSync(context.Background()).Return(nil, testErr)
conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
<-closed
return nil, errors.New("test done")
}).MaxTimes(1)
conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).Do(func(quic.ApplicationErrorCode, string) { close(closed) })
rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
cl := NewMockRoundTripCloser(mockCtrl)
cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).Return(nil, testErr)
return cl, nil
}
_, err = rt.RoundTrip(req)
Expect(err).To(MatchError(testErr))
Expect(rt.clients).To(HaveLen(1))
Eventually(closed).Should(BeClosed())
})
It("uses the quic.Config, if provided", func() {
@ -121,7 +80,7 @@ var _ = Describe("RoundTripper", func() {
return nil, errors.New("handshake error")
}
rt.QuicConfig = config
_, err := rt.RoundTrip(req1)
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("handshake error"))
Expect(receivedConfig.HandshakeIdleTimeout).To(Equal(config.HandshakeIdleTimeout))
})
@ -133,33 +92,144 @@ var _ = Describe("RoundTripper", func() {
return nil, errors.New("handshake error")
}
rt.Dial = dialer
_, err := rt.RoundTrip(req1)
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("handshake error"))
Expect(dialed).To(BeTrue())
})
})
Context("reusing clients", func() {
var req1, req2 *http.Request
BeforeEach(func() {
var err error
req1, err = http.NewRequest("GET", "https://quic.clemente.io/file1.html", nil)
Expect(err).ToNot(HaveOccurred())
req2, err = http.NewRequest("GET", "https://quic.clemente.io/file2.html", nil)
Expect(err).ToNot(HaveOccurred())
Expect(req1.URL).ToNot(Equal(req2.URL))
})
It("reuses existing clients", func() {
closed := make(chan struct{})
var count int
rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
count++
cl := NewMockRoundTripCloser(mockCtrl)
cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
return &http.Response{Request: req}, nil
}).Times(2)
cl.EXPECT().HandshakeComplete().Return(true)
return cl, nil
}
rsp1, err := rt.RoundTrip(req1)
Expect(err).ToNot(HaveOccurred())
Expect(rsp1.Request.URL).To(Equal(req1.URL))
rsp2, err := rt.RoundTrip(req2)
Expect(err).ToNot(HaveOccurred())
Expect(rsp2.Request.URL).To(Equal(req2.URL))
Expect(count).To(Equal(1))
})
It("immediately removes a clients when a request errored", func() {
testErr := errors.New("test err")
conn.EXPECT().OpenUniStream().AnyTimes().Return(nil, testErr)
conn.EXPECT().HandshakeComplete().Return(handshakeCtx).Times(2)
conn.EXPECT().OpenStreamSync(context.Background()).Return(nil, testErr).Times(2)
conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
<-closed
return nil, errors.New("test done")
}).MaxTimes(1)
conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).Do(func(quic.ApplicationErrorCode, string) { close(closed) })
req, err := http.NewRequest("GET", "https://quic.clemente.io/file1.html", nil)
Expect(err).ToNot(HaveOccurred())
_, err = rt.RoundTrip(req)
var count int
rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
count++
cl := NewMockRoundTripCloser(mockCtrl)
cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).Return(nil, testErr)
return cl, nil
}
_, err := rt.RoundTrip(req1)
Expect(err).To(MatchError(testErr))
Expect(rt.clients).To(HaveLen(1))
req2, err := http.NewRequest("GET", "https://quic.clemente.io/file2.html", nil)
Expect(err).ToNot(HaveOccurred())
_, err = rt.RoundTrip(req2)
Expect(err).To(MatchError(testErr))
Expect(rt.clients).To(HaveLen(1))
Eventually(closed).Should(BeClosed())
Expect(count).To(Equal(2))
})
It("recreates a client when a request times out", func() {
var reqCount int
cl1 := NewMockRoundTripCloser(mockCtrl)
cl1.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
reqCount++
if reqCount == 1 { // the first request is successful...
Expect(req.URL).To(Equal(req1.URL))
return &http.Response{Request: req}, nil
}
// ... after that, the connection timed out in the background
Expect(req.URL).To(Equal(req2.URL))
return nil, &qerr.IdleTimeoutError{}
}).Times(2)
cl1.EXPECT().HandshakeComplete().Return(true)
cl2 := NewMockRoundTripCloser(mockCtrl)
cl2.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
return &http.Response{Request: req}, nil
})
var count int
rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
count++
if count == 1 {
return cl1, nil
}
return cl2, nil
}
rsp1, err := rt.RoundTrip(req1)
Expect(err).ToNot(HaveOccurred())
Expect(rsp1.Request.RemoteAddr).To(Equal(req1.RemoteAddr))
rsp2, err := rt.RoundTrip(req2)
Expect(err).ToNot(HaveOccurred())
Expect(rsp2.Request.RemoteAddr).To(Equal(req2.RemoteAddr))
})
It("only issues a request once, even if a timeout error occurs", func() {
var count int
rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
count++
cl := NewMockRoundTripCloser(mockCtrl)
cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).Return(nil, &qerr.IdleTimeoutError{})
return cl, nil
}
_, err := rt.RoundTrip(req1)
Expect(err).To(MatchError(&qerr.IdleTimeoutError{}))
Expect(count).To(Equal(1))
})
It("handles a burst of requests", func() {
wait := make(chan struct{})
reqs := make(chan struct{}, 2)
var count int
rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
count++
cl := NewMockRoundTripCloser(mockCtrl)
cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
reqs <- struct{}{}
<-wait
return nil, &qerr.IdleTimeoutError{}
}).Times(2)
cl.EXPECT().HandshakeComplete()
return cl, nil
}
done := make(chan struct{}, 2)
go func() {
defer GinkgoRecover()
defer func() { done <- struct{}{} }()
_, err := rt.RoundTrip(req1)
Expect(err).To(MatchError(&qerr.IdleTimeoutError{}))
}()
go func() {
defer GinkgoRecover()
defer func() { done <- struct{}{} }()
_, err := rt.RoundTrip(req2)
Expect(err).To(MatchError(&qerr.IdleTimeoutError{}))
}()
// wait for both requests to be issued
Eventually(reqs).Should(Receive())
Eventually(reqs).Should(Receive())
close(wait) // now return the requests
Eventually(done).Should(Receive())
Eventually(done).Should(Receive())
Expect(count).To(Equal(1))
})
It("doesn't create new clients if RoundTripOpt.OnlyCachedConn is set", func() {
@ -181,66 +251,66 @@ var _ = Describe("RoundTripper", func() {
})
It("rejects requests without a URL", func() {
req1.URL = nil
req1.Body = &mockBody{}
_, err := rt.RoundTrip(req1)
req.URL = nil
req.Body = &mockBody{}
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("http3: nil Request.URL"))
Expect(req1.Body.(*mockBody).closed).To(BeTrue())
Expect(req.Body.(*mockBody).closed).To(BeTrue())
})
It("rejects request without a URL Host", func() {
req1.URL.Host = ""
req1.Body = &mockBody{}
_, err := rt.RoundTrip(req1)
req.URL.Host = ""
req.Body = &mockBody{}
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("http3: no Host in request URL"))
Expect(req1.Body.(*mockBody).closed).To(BeTrue())
Expect(req.Body.(*mockBody).closed).To(BeTrue())
})
It("doesn't try to close the body if the request doesn't have one", func() {
req1.URL = nil
Expect(req1.Body).To(BeNil())
_, err := rt.RoundTrip(req1)
req.URL = nil
Expect(req.Body).To(BeNil())
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("http3: nil Request.URL"))
})
It("rejects requests without a header", func() {
req1.Header = nil
req1.Body = &mockBody{}
_, err := rt.RoundTrip(req1)
req.Header = nil
req.Body = &mockBody{}
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("http3: nil Request.Header"))
Expect(req1.Body.(*mockBody).closed).To(BeTrue())
Expect(req.Body.(*mockBody).closed).To(BeTrue())
})
It("rejects requests with invalid header name fields", func() {
req1.Header.Add("foobär", "value")
_, err := rt.RoundTrip(req1)
req.Header.Add("foobär", "value")
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("http3: invalid http header field name \"foobär\""))
})
It("rejects requests with invalid header name values", func() {
req1.Header.Add("foo", string([]byte{0x7}))
_, err := rt.RoundTrip(req1)
req.Header.Add("foo", string([]byte{0x7}))
_, err := rt.RoundTrip(req)
Expect(err.Error()).To(ContainSubstring("http3: invalid http header field value"))
})
It("rejects requests with an invalid request method", func() {
req1.Method = "foobär"
req1.Body = &mockBody{}
_, err := rt.RoundTrip(req1)
req.Method = "foobär"
req.Body = &mockBody{}
_, err := rt.RoundTrip(req)
Expect(err).To(MatchError("http3: invalid method \"foobär\""))
Expect(req1.Body.(*mockBody).closed).To(BeTrue())
Expect(req.Body.(*mockBody).closed).To(BeTrue())
})
})
Context("closing", func() {
It("closes", func() {
rt.clients = make(map[string]roundTripCloser)
cl := &mockClient{}
cl := NewMockRoundTripCloser(mockCtrl)
cl.EXPECT().Close()
rt.clients["foo.bar"] = cl
err := rt.Close()
Expect(err).ToNot(HaveOccurred())
Expect(len(rt.clients)).To(BeZero())
Expect(cl.closed).To(BeTrue())
})
It("closes a RoundTripper that has never been used", func() {