Repository: quic-go/quic-go
Pull request #4551 (2024-06-04 05:15:34)

add context to EarlyConnection.NextConnection, handle handshake failures
@IrineSistiana I know it's been a while since you opened the issue, but could you test if this PR resolves your problem?


Patch:
diff --git a/connection.go b/connection.go
index 33ab14b8..13f905c5 100644
--- a/connection.go
+++ b/connection.go
@@ -2424,10 +2424,17 @@ func (s *connection) GetVersion() protocol.Version {
 	return s.version
 }
 
-func (s *connection) NextConnection() Connection {
-	<-s.HandshakeComplete()
-	s.streamsMap.UseResetMaps()
-	return s
+func (s *connection) NextConnection(ctx context.Context) (Connection, error) {
+	// The handshake might fail after the server rejected 0-RTT.
+	// This could happen if the Finished message is malformed or never received.
+	select {
+	case <-ctx.Done():
+		return nil, context.Cause(ctx)
+	case <-s.Context().Done():
+	case <-s.HandshakeComplete():
+		s.streamsMap.UseResetMaps()
+	}
+	return s, nil
 }
 
 // estimateMaxPayloadSize estimates the maximum payload size for short header packets.
diff --git a/go.mod b/go.mod
index 00abc69a..8cc39ebd 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/francoispqt/gojay v1.2.13
 	github.com/onsi/ginkgo/v2 v2.9.5
 	github.com/onsi/gomega v1.27.6
+	github.com/prometheus/client_golang v1.19.1
 	github.com/quic-go/qpack v0.4.0
 	go.uber.org/mock v0.4.0
 	golang.org/x/crypto v0.23.0
@@ -17,13 +18,18 @@ require (
 )
 
 require (
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/go-logr/logr v1.2.4 // indirect
 	github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
 	github.com/google/go-cmp v0.6.0 // indirect
 	github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
+	github.com/prometheus/client_model v0.5.0 // indirect
+	github.com/prometheus/common v0.48.0 // indirect
+	github.com/prometheus/procfs v0.12.0 // indirect
 	golang.org/x/mod v0.17.0 // indirect
 	golang.org/x/text v0.15.0 // indirect
 	golang.org/x/tools v0.21.0 // indirect
-	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
+	google.golang.org/protobuf v1.33.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 8fbf1c8a..fda9cbfc 100644
--- a/go.sum
+++ b/go.sum
@@ -10,8 +10,12 @@ git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGy
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
 github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -61,8 +65,9 @@ github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
@@ -84,11 +89,21 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
+github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
 github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
+github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
 github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
+github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
 github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
 github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
@@ -199,11 +214,11 @@ google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmE
 google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
-google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/interface.go b/interface.go
index cdefee44..56155f71 100644
--- a/interface.go
+++ b/interface.go
@@ -224,7 +224,7 @@ type EarlyConnection interface {
 	// however the client's identity is only verified once the handshake completes.
 	HandshakeComplete() <-chan struct{}
 
-	NextConnection() Connection
+	NextConnection(context.Context) (Connection, error)
 }
 
 // StatelessResetKey is a key used to derive stateless reset tokens.
diff --git a/internal/mocks/quic/early_conn.go b/internal/mocks/quic/early_conn.go
index f08f0481..a6b17341 100644
--- a/internal/mocks/quic/early_conn.go
+++ b/internal/mocks/quic/early_conn.go
@@ -311,17 +311,18 @@ func (c *MockEarlyConnectionLocalAddrCall) DoAndReturn(f func() net.Addr) *MockE
 }
 
 // NextConnection mocks base method.
-func (m *MockEarlyConnection) NextConnection() quic.Connection {
+func (m *MockEarlyConnection) NextConnection(arg0 context.Context) (quic.Connection, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "NextConnection")
+	ret := m.ctrl.Call(m, "NextConnection", arg0)
 	ret0, _ := ret[0].(quic.Connection)
-	return ret0
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
 }
 
 // NextConnection indicates an expected call of NextConnection.
-func (mr *MockEarlyConnectionMockRecorder) NextConnection() *MockEarlyConnectionNextConnectionCall {
+func (mr *MockEarlyConnectionMockRecorder) NextConnection(arg0 any) *MockEarlyConnectionNextConnectionCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextConnection", reflect.TypeOf((*MockEarlyConnection)(nil).NextConnection))
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextConnection", reflect.TypeOf((*MockEarlyConnection)(nil).NextConnection), arg0)
 	return &MockEarlyConnectionNextConnectionCall{Call: call}
 }
 
@@ -331,19 +332,19 @@ type MockEarlyConnectionNextConnectionCall struct {
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MockEarlyConnectionNextConnectionCall) Return(arg0 quic.Connection) *MockEarlyConnectionNextConnectionCall {
-	c.Call = c.Call.Return(arg0)
+func (c *MockEarlyConnectionNextConnectionCall) Return(arg0 quic.Connection, arg1 error) *MockEarlyConnectionNextConnectionCall {
+	c.Call = c.Call.Return(arg0, arg1)
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MockEarlyConnectionNextConnectionCall) Do(f func() quic.Connection) *MockEarlyConnectionNextConnectionCall {
+func (c *MockEarlyConnectionNextConnectionCall) Do(f func(context.Context) (quic.Connection, error)) *MockEarlyConnectionNextConnectionCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockEarlyConnectionNextConnectionCall) DoAndReturn(f func() quic.Connection) *MockEarlyConnectionNextConnectionCall {
+func (c *MockEarlyConnectionNextConnectionCall) DoAndReturn(f func(context.Context) (quic.Connection, error)) *MockEarlyConnectionNextConnectionCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
diff --git a/metrics/connection_tracer.go b/metrics/connection_tracer.go
new file mode 100644
index 00000000..adaee497
--- /dev/null
+++ b/metrics/connection_tracer.go
@@ -0,0 +1,201 @@
+package metrics
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"time"
+
+	"github.com/quic-go/quic-go"
+	"github.com/quic-go/quic-go/internal/qerr"
+	"github.com/quic-go/quic-go/logging"
+
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+var (
+	connStarted = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "connections_started_total",
+			Help:      "Connections Started",
+		},
+		[]string{"dir"},
+	)
+	connClosed = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "connections_closed_total",
+			Help:      "Connections Closed",
+		},
+		[]string{"dir", "reason"},
+	)
+	connDuration = prometheus.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Namespace: metricNamespace,
+			Name:      "connection_duration_seconds",
+			Help:      "Duration of a Connection",
+			Buckets:   prometheus.ExponentialBuckets(1.0/16, 2, 25), // up to 24 days
+		},
+		[]string{"dir"},
+	)
+	connHandshakeDuration = prometheus.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Namespace: metricNamespace,
+			Name:      "handshake_duration_seconds",
+			Help:      "Duration of the QUIC Handshake",
+			Buckets:   prometheus.ExponentialBuckets(0.001, 1.3, 35),
+		},
+		[]string{"dir"},
+	)
+)
+
+// DefaultTracer returns a callback that creates a metrics ConnectionTracer.
+// The ConnectionTracer returned can be set on the quic.Config for a new connection.
+// It should be reused across QUIC connections.
+func DefaultTracer() func(_ context.Context, p logging.Perspective, _ logging.ConnectionID) *logging.ConnectionTracer {
+	return DefaultTracerWithRegisterer(prometheus.DefaultRegisterer)
+}
+
+// DefaultTracerWithRegisterer returns a callback that creates a metrics ConnectionTracer
+// using a given Prometheus registerer.
+func DefaultTracerWithRegisterer(registerer prometheus.Registerer) func(_ context.Context, p logging.Perspective, _ logging.ConnectionID) *logging.ConnectionTracer {
+	return func(_ context.Context, p logging.Perspective, _ logging.ConnectionID) *logging.ConnectionTracer {
+		switch p {
+		case logging.PerspectiveClient:
+			return NewClientConnectionTracerWithRegisterer(registerer)
+		case logging.PerspectiveServer:
+			return NewServerConnectionTracerWithRegisterer(registerer)
+		default:
+			panic("invalid perspective")
+		}
+	}
+}
+
+// NewClientConnectionTracerWithRegisterer creates a new connection tracer for a connection
+// dialed on the client side with a given Prometheus registerer.
+func NewClientConnectionTracerWithRegisterer(registerer prometheus.Registerer) *logging.ConnectionTracer {
+	return newConnectionTracerWithRegisterer(registerer, true)
+}
+
+// NewServerConnectionTracerWithRegisterer creates a new connection tracer for a connection
+// accepted on the server side with a given Prometheus registerer.
+func NewServerConnectionTracerWithRegisterer(registerer prometheus.Registerer) *logging.ConnectionTracer {
+	return newConnectionTracerWithRegisterer(registerer, false)
+}
+
+func newConnectionTracerWithRegisterer(registerer prometheus.Registerer, isClient bool) *logging.ConnectionTracer {
+	for _, c := range [...]prometheus.Collector{
+		connStarted,
+		connHandshakeDuration,
+		connClosed,
+		connDuration,
+	} {
+		if err := registerer.Register(c); err != nil {
+			if ok := errors.As(err, &prometheus.AlreadyRegisteredError{}); !ok {
+				panic(err)
+			}
+		}
+	}
+
+	direction := "incoming"
+	if isClient {
+		direction = "outgoing"
+	}
+
+	var (
+		startTime         time.Time
+		handshakeComplete bool
+	)
+	return &logging.ConnectionTracer{
+		StartedConnection: func(_, _ net.Addr, _, _ logging.ConnectionID) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			startTime = time.Now()
+
+			*tags = append(*tags, direction)
+			connStarted.WithLabelValues(*tags...).Inc()
+		},
+		ClosedConnection: func(e error) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			*tags = append(*tags, direction)
+			// call connDuration.Observe before adding any more labels
+			if handshakeComplete {
+				connDuration.WithLabelValues(*tags...).Observe(time.Since(startTime).Seconds())
+			}
+
+			var (
+				statelessResetErr     *quic.StatelessResetError
+				handshakeTimeoutErr   *quic.HandshakeTimeoutError
+				idleTimeoutErr        *quic.IdleTimeoutError
+				applicationErr        *quic.ApplicationError
+				transportErr          *quic.TransportError
+				versionNegotiationErr *quic.VersionNegotiationError
+			)
+			var reason string
+			switch {
+			case errors.As(e, &statelessResetErr):
+				reason = "stateless_reset"
+			case errors.As(e, &handshakeTimeoutErr):
+				reason = "handshake_timeout"
+			case errors.As(e, &idleTimeoutErr):
+				if handshakeComplete {
+					reason = "idle_timeout"
+				} else {
+					reason = "handshake_timeout"
+				}
+			case errors.As(e, &applicationErr):
+				if applicationErr.Remote {
+					reason = "application_error (remote)"
+				} else {
+					reason = "application_error (local)"
+				}
+			case errors.As(e, &transportErr):
+				switch {
+				case transportErr.ErrorCode == qerr.ApplicationErrorErrorCode:
+					if transportErr.Remote {
+						reason = "application_error (remote)"
+					} else {
+						reason = "application_error (local)"
+					}
+				case transportErr.ErrorCode.IsCryptoError():
+					if transportErr.Remote {
+						reason = "crypto_error (remote)"
+					} else {
+						reason = "crypto_error (local)"
+					}
+				default:
+					if transportErr.Remote {
+						reason = "transport_error (remote)"
+					} else {
+						reason = fmt.Sprintf("transport_error (local): %s", transportErr.ErrorCode)
+					}
+				}
+			case errors.As(e, &versionNegotiationErr):
+				reason = "version_mismatch"
+			default:
+				reason = "unknown"
+			}
+			*tags = append(*tags, reason)
+			connClosed.WithLabelValues(*tags...).Inc()
+		},
+		UpdatedKeyFromTLS: func(l logging.EncryptionLevel, p logging.Perspective) {
+			// The client derives both 1-RTT keys when the handshake completes.
+			// The server derives the 1-RTT read key when the handshake completes.
+			if l != logging.Encryption1RTT || p != logging.PerspectiveClient {
+				return
+			}
+			handshakeComplete = true
+
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			*tags = append(*tags, direction)
+			connHandshakeDuration.WithLabelValues(*tags...).Observe(time.Since(startTime).Seconds())
+		},
+	}
+}
diff --git a/metrics/dashboards/README.md b/metrics/dashboards/README.md
new file mode 100644
index 00000000..f7620421
--- /dev/null
+++ b/metrics/dashboards/README.md
@@ -0,0 +1,37 @@
+# quic-go Prometheus / Grafana setup
+
+Expose a Grafana endpoint on `http://localhost:5001/prometheus`:
+```go
+import "github.com/prometheus/client_golang/prometheus/promhttp"
+
+go func() {
+    http.Handle("/prometheus", promhttp.Handler())
+    log.Fatal(http.ListenAndServe(":5001", nil))
+}()
+```
+
+Set a metrics tracer on the `Transport`:
+```go
+quic.Transport{
+	Tracer: metrics.NewTracer(),
+}
+```
+
+When using multiple `Transport`s, it is recommended to use the metrics tracer struct for all of them.
+
+
+Set a metrics connection tracer on the `Config`:
+```go
+tracer := metrics.DefaultTracer()
+quic.Config{
+	Tracer: tracer,
+}
+```
+
+It is recommended to use the same connection tracer returned by `DefaultTracer` on the `Config`s for all connections.
+
+
+Running:
+```shell
+docker-compose up
+```
diff --git a/metrics/dashboards/datasources.yml b/metrics/dashboards/datasources.yml
new file mode 100644
index 00000000..ed47ec11
--- /dev/null
+++ b/metrics/dashboards/datasources.yml
@@ -0,0 +1,13 @@
+apiVersion: 1
+
+deleteDatasources:
+  - name: Prometheus
+    orgId: 1
+
+datasources:
+  - name: Prometheus
+    orgId: 1
+    type: prometheus
+    access: proxy
+    url: http://prometheus:9090
+    editable: false
diff --git a/metrics/dashboards/docker-compose.yml b/metrics/dashboards/docker-compose.yml
new file mode 100644
index 00000000..8e3a8a55
--- /dev/null
+++ b/metrics/dashboards/docker-compose.yml
@@ -0,0 +1,25 @@
+version: '3.8'
+
+volumes:
+  prometheus_data: {}
+  grafana_data: {}
+
+services:
+  prometheus:
+    image: prom/prometheus:latest
+    container_name: prometheus
+    volumes:
+      - ./prometheus.yml:/etc/prometheus/prometheus.yml
+      - prometheus_data:/prometheus
+    command:
+      - '--config.file=/etc/prometheus/prometheus.yml'
+    expose:
+      - 9090
+  grafana:
+    image: grafana/grafana:latest
+    container_name: grafana
+    volumes:
+      - grafana_data:/var/lib/grafana
+      - ./datasources.yml:/etc/grafana/provisioning/datasources/prom.yml
+    ports:
+      - "3000:3000"
diff --git a/metrics/dashboards/prometheus.yml b/metrics/dashboards/prometheus.yml
new file mode 100644
index 00000000..5018097e
--- /dev/null
+++ b/metrics/dashboards/prometheus.yml
@@ -0,0 +1,9 @@
+global:
+  scrape_interval: 15s
+
+scrape_configs:
+  - job_name: 'quic-go'
+    scrape_interval: 15s
+    static_configs:
+      - targets: ['host.docker.internal:5001']
+    metrics_path: '/prometheus'
diff --git a/metrics/pool.go b/metrics/pool.go
new file mode 100644
index 00000000..75868bea
--- /dev/null
+++ b/metrics/pool.go
@@ -0,0 +1,27 @@
+package metrics
+
+import (
+	"fmt"
+	"sync"
+)
+
+const capacity = 4
+
+// The stringPool is used to avoid allocations when passing labels to Prometheus.
+var stringPool = sync.Pool{New: func() any {
+	s := make([]string, 0, capacity)
+	return &s
+}}
+
+func getStringSlice() *[]string {
+	s := stringPool.Get().(*[]string)
+	*s = (*s)[:0]
+	return s
+}
+
+func putStringSlice(s *[]string) {
+	if c := cap(*s); c < capacity {
+		panic(fmt.Sprintf("unexpected slice cap: %d", c))
+	}
+	stringPool.Put(s)
+}
diff --git a/metrics/tracer.go b/metrics/tracer.go
new file mode 100644
index 00000000..41245bbd
--- /dev/null
+++ b/metrics/tracer.go
@@ -0,0 +1,147 @@
+package metrics
+
+import (
+	"errors"
+	"fmt"
+	"net"
+
+	"github.com/quic-go/quic-go/internal/protocol"
+	"github.com/quic-go/quic-go/internal/qerr"
+	"github.com/quic-go/quic-go/logging"
+
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+const metricNamespace = "quicgo"
+
+func getIPVersion(addr net.Addr) string {
+	udpAddr, ok := addr.(*net.UDPAddr)
+	if !ok {
+		return ""
+	}
+	if udpAddr.IP.To4() != nil {
+		return "ipv4"
+	}
+	return "ipv6"
+}
+
+var (
+	connsRejected = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "server_connections_rejected_total",
+			Help:      "Connections Rejected",
+		},
+		[]string{"ip_version", "reason"},
+	)
+	packetDropped = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Namespace: metricNamespace,
+			Name:      "server_received_packets_dropped_total",
+			Help:      "packets dropped",
+		},
+		[]string{"ip_version", "reason"},
+	)
+)
+
+// NewTracer creates a new tracer using the default Prometheus registerer.
+// The Tracer returned from this function can be used to collect metrics for
+// events happening before the establishment of a QUIC connection.
+// It can be set on the Tracer field of quic.Transport.
+func NewTracer() *logging.Tracer {
+	return NewTracerWithRegisterer(prometheus.DefaultRegisterer)
+}
+
+// NewTracerWithRegisterer creates a new tracer using a given Prometheus registerer.
+func NewTracerWithRegisterer(registerer prometheus.Registerer) *logging.Tracer {
+	for _, c := range [...]prometheus.Collector{
+		connsRejected,
+		packetDropped,
+	} {
+		if err := registerer.Register(c); err != nil {
+			if ok := errors.As(err, &prometheus.AlreadyRegisteredError{}); !ok {
+				panic(err)
+			}
+		}
+	}
+
+	return &logging.Tracer{
+		SentPacket: func(addr net.Addr, hdr *logging.Header, _ logging.ByteCount, frames []logging.Frame) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			var reason string
+			switch {
+			case hdr.Type == protocol.PacketTypeRetry:
+				reason = "retry"
+			case hdr.Type == protocol.PacketTypeInitial:
+				var ccf *logging.ConnectionCloseFrame
+				for _, f := range frames {
+					cc, ok := f.(*logging.ConnectionCloseFrame)
+					if ok {
+						ccf = cc
+						break
+					}
+				}
+				// This should never happen. We only send Initials before creating the connection in order to
+				// reject a connection attempt.
+				if ccf == nil {
+					return
+				}
+				if ccf.IsApplicationError {
+					//nolint:exhaustive // Only a few error codes applicable.
+					switch qerr.TransportErrorCode(ccf.ErrorCode) {
+					case qerr.ConnectionRefused:
+						reason = "connection_refused"
+					case qerr.InvalidToken:
+						reason = "invalid_token"
+					default:
+						// This shouldn't happen, the server doesn't send CONNECTION_CLOSE frames with different errors.
+						reason = fmt.Sprintf("transport_error: %d", ccf.ErrorCode)
+					}
+				} else {
+					// This shouldn't happen, the server doesn't send application-level CONNECTION_CLOSE frames.
+					reason = "application_error"
+				}
+			}
+			*tags = append(*tags, getIPVersion(addr))
+			*tags = append(*tags, reason)
+			connsRejected.WithLabelValues(*tags...).Inc()
+		},
+		SentVersionNegotiationPacket: func(addr net.Addr, _, _ logging.ArbitraryLenConnectionID, _ []logging.VersionNumber) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			*tags = append(*tags, getIPVersion(addr))
+			*tags = append(*tags, "version_negotiation")
+			connsRejected.WithLabelValues(*tags...).Inc()
+		},
+		DroppedPacket: func(addr net.Addr, pt logging.PacketType, _ logging.ByteCount, reason logging.PacketDropReason) {
+			tags := getStringSlice()
+			defer putStringSlice(tags)
+
+			var dropReason string
+			//nolint:exhaustive // Only a few drop reasons applicable.
+			switch reason {
+			case logging.PacketDropDOSPrevention:
+				if pt == logging.PacketType0RTT {
+					dropReason = "0rtt_dos_prevention"
+				} else {
+					dropReason = "dos_prevention"
+				}
+			case logging.PacketDropHeaderParseError:
+				dropReason = "header_parsing"
+			case logging.PacketDropPayloadDecryptError:
+				dropReason = "payload_decrypt"
+			case logging.PacketDropUnexpectedPacket:
+				dropReason = "unexpected_packet"
+			default:
+				dropReason = "unknown"
+			}
+
+			*tags = append(*tags, getIPVersion(addr))
+			*tags = append(*tags, dropReason)
+			packetDropped.WithLabelValues(*tags...).Inc()
+		},
+	}
+}
diff --git a/mtu_discoverer.go b/mtu_discoverer.go
index 1ee62606..3f3a640a 100644
--- a/mtu_discoverer.go
+++ b/mtu_discoverer.go
@@ -25,16 +25,80 @@ const (
 	maxMTUDiff = 20
 	// send a probe packet every mtuProbeDelay RTTs
 	mtuProbeDelay = 5
+	// Once maxLostMTUProbes MTU probe packets larger than a certain size are lost,
+	// MTU discovery won't probe for larger MTUs than this size.
+	// The algorithm used here is resilient to packet loss of (maxLostMTUProbes - 1) packets.
+	maxLostMTUProbes = 3
 )
 
+// The Path MTU is found by sending a larger packet every now and then.
+// If the packet is acknowledged, we conclude that the path supports this larger packet size.
+// If the packet is lost, this can mean one of two things:
+//   1. The path doesn't support this larger packet size, or
+//   2. The packet was lost due to packet loss, independent of its size.
+// The algorithm used here is resilient to packet loss of (maxLostMTUProbes - 1) packets.
+// For simplicty, the following example use maxLostMTUProbes = 2.
+//
+// Initialization:
+//    |------------------------------------------------------------------------------|
+//   min                                                                            max
+//
+// The first MTU probe packet will have size (min+max)/2.
+// Assume that this packet is acknowledged. We can now move the min marker,
+// and continue the search in the resulting interval.
+//
+// If 1st probe packet acknowledged:
+//    |---------------------------------------|--------------------------------------|
+//                                           min                                    max
+//
+// If 1st probe packet lost:
+//    |---------------------------------------|--------------------------------------|
+//   min                                    lost[0]                                 max
+//
+// We can't conclude that the path doesn't support this packet size, since the loss of the probe
+// packet could have been unrelated to the packet size.  A larger probe packet will be sent later on.
+// After a loss, the next probe packet has size (min+lost[0])/2.
+// Now assume this probe packet is acknowledged:
+//
+// 2nd probe packet acknowledged:
+//    |------------------|--------------------|--------------------------------------|
+//                      min                lost[0]                                  max
+//
+// First of all, we conclude that the path supports at least this MTU. That's progress!
+// Second, we probe a bit more aggressively with the next probe packet:
+// After an acknowledgement, the next probe packet has size (min+max)/2.
+// This means we'll send a packet larger than the first probe packet (which was lost).
+//
+// If 3rd probe packet acknowledged:
+//    |-------------------------------------------------|----------------------------|
+//                                                     min                          max
+//
+// We can conclude that the loss of the 1st probe packet was not due to its size, and
+// continue searching in a much smaller interval now.
+//
+// If 3rd probe packet lost:
+//    |------------------|--------------------|---------|----------------------------|
+//                      min                lost[0]     max
+//
+// Since in our example numPTOProbes = 2, and we lost 2 packets smaller than max, we
+// conclude that this packet size is not supported on the path, and reduce the maximum
+// value of the search interval.
+//
+// MTU discovery concludes once the interval min and max has been narrowed down to maxMTUDiff.
+
 type mtuFinder struct {
 	lastProbeTime time.Time
 	mtuIncreased  func(protocol.ByteCount)
 
 	rttStats *utils.RTTStats
+
 	inFlight protocol.ByteCount // the size of the probe packet currently in flight. InvalidByteCount if none is in flight
-	current  protocol.ByteCount
-	max      protocol.ByteCount // the maximum value, as advertised by the peer (or our maximum size buffer)
+	min      protocol.ByteCount
+	limit    protocol.ByteCount
+
+	// on initialization, we treat the maximum size as the first "lost" packet
+	lost             [maxLostMTUProbes]protocol.ByteCount
+	lastProbeWasLost bool
 
 	tracer *logging.ConnectionTracer
 }
@@ -47,29 +111,43 @@ func newMTUDiscoverer(
 	mtuIncreased func(protocol.ByteCount),
 	tracer *logging.ConnectionTracer,
 ) *mtuFinder {
-	return &mtuFinder{
+	f := &mtuFinder{
 		inFlight:     protocol.InvalidByteCount,
-		current:      start,
-		max:          max,
+		min:          start,
+		limit:        max,
 		rttStats:     rttStats,
 		mtuIncreased: mtuIncreased,
 		tracer:       tracer,
 	}
+	for i := range f.lost {
+		if i == 0 {
+			f.lost[i] = max
+			continue
+		}
+		f.lost[i] = protocol.InvalidByteCount
+	}
+	return f
 }
 
 func (f *mtuFinder) done() bool {
-	return f.max-f.current <= maxMTUDiff+1
+	return f.max()-f.min <= maxMTUDiff+1
 }
 
-func (f *mtuFinder) Start() {
-	if f.max == protocol.InvalidByteCount {
-		panic("invalid")
+func (f *mtuFinder) max() protocol.ByteCount {
+	for i, v := range f.lost {
+		if v == protocol.InvalidByteCount {
+			return f.lost[i-1]
+		}
 	}
+	return f.lost[len(f.lost)-1]
+}
+
+func (f *mtuFinder) Start() {
 	f.lastProbeTime = time.Now() // makes sure the first probe packet is not sent immediately
 }
 
 func (f *mtuFinder) ShouldSendProbe(now time.Time) bool {
-	if f.max == 0 || f.lastProbeTime.IsZero() {
+	if f.lastProbeTime.IsZero() {
 		return false
 	}
 	if f.inFlight != protocol.InvalidByteCount || f.done() {
@@ -79,7 +157,12 @@ func (f *mtuFinder) ShouldSendProbe(now time.Time) bool {
 }
 
 func (f *mtuFinder) GetPing() (ackhandler.Frame, protocol.ByteCount) {
-	size := (f.max + f.current) / 2
+	var size protocol.ByteCount
+	if f.lastProbeWasLost {
+		size = (f.min + f.lost[0]) / 2
+	} else {
+		size = (f.min + f.max()) / 2
+	}
 	f.lastProbeTime = time.Now()
 	f.inFlight = size
 	return ackhandler.Frame{
@@ -89,7 +172,7 @@ func (f *mtuFinder) GetPing() (ackhandler.Frame, protocol.ByteCount) {
 }
 
 func (f *mtuFinder) CurrentSize() protocol.ByteCount {
-	return f.current
+	return f.min
 }
 
 type mtuFinderAckHandler struct {
@@ -104,7 +187,25 @@ func (h *mtuFinderAckHandler) OnAcked(wire.Frame) {
 		panic("OnAcked callback called although there's no MTU probe packet in flight")
 	}
 	h.inFlight = protocol.InvalidByteCount
-	h.current = size
+	h.min = size
+	h.lastProbeWasLost = false
+	// remove all values smaller than size from the lost array
+	var j int
+	for i, v := range h.lost {
+		if size < v {
+			j = i
+			break
+		}
+	}
+	if j > 0 {
+		for i := 0; i < len(h.lost); i++ {
+			if i+j < len(h.lost) {
+				h.lost[i] = h.lost[i+j]
+			} else {
+				h.lost[i] = protocol.InvalidByteCount
+			}
+		}
+	}
 	if h.tracer != nil && h.tracer.UpdatedMTU != nil {
 		h.tracer.UpdatedMTU(size, h.done())
 	}
@@ -116,6 +217,13 @@ func (h *mtuFinderAckHandler) OnLost(wire.Frame) {
 	if size == protocol.InvalidByteCount {
 		panic("OnLost callback called although there's no MTU probe packet in flight")
 	}
-	h.max = size
+	h.lastProbeWasLost = true
 	h.inFlight = protocol.InvalidByteCount
+	for i, v := range h.lost {
+		if size < v {
+			copy(h.lost[i+1:], h.lost[i:])
+			h.lost[i] = size
+			break
+		}
+	}
 }


Test patch:
diff --git a/integrationtests/gomodvendor/go.sum b/integrationtests/gomodvendor/go.sum
index 260b2088..6d99bc8b 100644
--- a/integrationtests/gomodvendor/go.sum
+++ b/integrationtests/gomodvendor/go.sum
@@ -47,8 +47,8 @@ golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
 golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
-google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/integrationtests/self/mtu_test.go b/integrationtests/self/mtu_test.go
index 64de6ab1..744d9fa0 100644
--- a/integrationtests/self/mtu_test.go
+++ b/integrationtests/self/mtu_test.go
@@ -18,9 +18,7 @@ import (
 )
 
 var _ = Describe("DPLPMTUD", func() {
-	// This test is very sensitive to packet loss, as the loss of a single Path MTU probe packet makes DPLPMTUD
-	// clip the assumed MTU at that value.
-	It("discovers the MTU", FlakeAttempts(3), func() {
+	It("discovers the MTU", func() {
 		rtt := scaleDuration(5 * time.Millisecond)
 		const mtu = 1400
 
diff --git a/integrationtests/self/zero_rtt_test.go b/integrationtests/self/zero_rtt_test.go
index bf966b9c..ccdeab6d 100644
--- a/integrationtests/self/zero_rtt_test.go
+++ b/integrationtests/self/zero_rtt_test.go
@@ -871,7 +871,8 @@ var _ = Describe("0-RTT", func() {
 		_, err = conn.AcceptStream(ctx)
 		Expect(err).To(Equal(quic.Err0RTTRejected))
 
-		newConn := conn.NextConnection()
+		newConn, err := conn.NextConnection(context.Background())
+		Expect(err).ToNot(HaveOccurred())
 		str, err := newConn.OpenUniStream()
 		Expect(err).ToNot(HaveOccurred())
 		_, err = newConn.OpenUniStream()
diff --git a/mock_quic_conn_test.go b/mock_quic_conn_test.go
index 179f26ee..785ef5d3 100644
--- a/mock_quic_conn_test.go
+++ b/mock_quic_conn_test.go
@@ -310,17 +310,18 @@ func (c *MockQUICConnLocalAddrCall) DoAndReturn(f func() net.Addr) *MockQUICConn
 }
 
 // NextConnection mocks base method.
-func (m *MockQUICConn) NextConnection() Connection {
+func (m *MockQUICConn) NextConnection(arg0 context.Context) (Connection, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "NextConnection")
+	ret := m.ctrl.Call(m, "NextConnection", arg0)
 	ret0, _ := ret[0].(Connection)
-	return ret0
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
 }
 
 // NextConnection indicates an expected call of NextConnection.
-func (mr *MockQUICConnMockRecorder) NextConnection() *MockQUICConnNextConnectionCall {
+func (mr *MockQUICConnMockRecorder) NextConnection(arg0 any) *MockQUICConnNextConnectionCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextConnection", reflect.TypeOf((*MockQUICConn)(nil).NextConnection))
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextConnection", reflect.TypeOf((*MockQUICConn)(nil).NextConnection), arg0)
 	return &MockQUICConnNextConnectionCall{Call: call}
 }
 
@@ -330,19 +331,19 @@ type MockQUICConnNextConnectionCall struct {
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MockQUICConnNextConnectionCall) Return(arg0 Connection) *MockQUICConnNextConnectionCall {
-	c.Call = c.Call.Return(arg0)
+func (c *MockQUICConnNextConnectionCall) Return(arg0 Connection, arg1 error) *MockQUICConnNextConnectionCall {
+	c.Call = c.Call.Return(arg0, arg1)
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MockQUICConnNextConnectionCall) Do(f func() Connection) *MockQUICConnNextConnectionCall {
+func (c *MockQUICConnNextConnectionCall) Do(f func(context.Context) (Connection, error)) *MockQUICConnNextConnectionCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockQUICConnNextConnectionCall) DoAndReturn(f func() Connection) *MockQUICConnNextConnectionCall {
+func (c *MockQUICConnNextConnectionCall) DoAndReturn(f func(context.Context) (Connection, error)) *MockQUICConnNextConnectionCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
diff --git a/mtu_discoverer_test.go b/mtu_discoverer_test.go
index fc42f539..ff764670 100644
--- a/mtu_discoverer_test.go
+++ b/mtu_discoverer_test.go
@@ -1,9 +1,11 @@
 package quic
 
 import (
-	"math/rand"
+	"fmt"
 	"time"
 
+	"golang.org/x/exp/rand"
+
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/logging"
@@ -25,6 +27,7 @@ var _ = Describe("MTU Discoverer", func() {
 		now           time.Time
 		discoveredMTU protocol.ByteCount
 	)
+	r := rand.New(rand.NewSource(uint64(GinkgoRandomSeed())))
 
 	BeforeEach(func() {
 		rttStats = &utils.RTTStats{}
@@ -76,6 +79,7 @@ var _ = Describe("MTU Discoverer", func() {
 		t := now.Add(5 * rtt)
 		for d.ShouldSendProbe(t) {
 			ping, size := d.GetPing()
+			fmt.Println("sending", size)
 			ping.Handler.OnAcked(ping.Frame)
 			sizes = append(sizes, size)
 			t = t.Add(5 * rtt)
@@ -91,53 +95,107 @@ var _ = Describe("MTU Discoverer", func() {
 		}
 	})
 
-	It("finds the MTU", func() {
-		const rep = 3000
-		var maxDiff protocol.ByteCount
-		for i := 0; i < rep; i++ {
-			maxMTU := protocol.ByteCount(rand.Intn(int(3000-startMTU))) + startMTU + 1
-			currentMTU := startMTU
-			var tracedMTU protocol.ByteCount
-			var tracerDone bool
-			d := newMTUDiscoverer(
-				rttStats,
-				startMTU,
-				maxMTU,
-				func(s protocol.ByteCount) { currentMTU = s },
-				&logging.ConnectionTracer{
-					UpdatedMTU: func(mtu logging.ByteCount, done bool) {
-						tracedMTU = mtu
-						tracerDone = done
-					},
+	It("finds the MTU", MustPassRepeatedly(300), func() {
+		maxMTU := protocol.ByteCount(r.Intn(int(3000-startMTU))) + startMTU + 1
+		currentMTU := startMTU
+		var tracedMTU protocol.ByteCount
+		var tracerDone bool
+		d := newMTUDiscoverer(
+			rttStats,
+			startMTU,
+			maxMTU,
+			func(s protocol.ByteCount) { currentMTU = s },
+			&logging.ConnectionTracer{
+				UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+					tracedMTU = mtu
+					tracerDone = done
 				},
-			)
-			d.Start()
-			now := time.Now()
-			realMTU := protocol.ByteCount(rand.Intn(int(maxMTU-startMTU))) + startMTU
-			t := now.Add(mtuProbeDelay * rtt)
-			var count int
-			for d.ShouldSendProbe(t) {
-				if count > 25 {
-					Fail("too many iterations")
-				}
-				count++
+			},
+		)
+		d.Start()
+		now := time.Now()
+		realMTU := protocol.ByteCount(r.Intn(int(maxMTU-startMTU))) + startMTU
+		fmt.Fprintf(GinkgoWriter, "MTU: %d, max: %d\n", realMTU, maxMTU)
+		t := now.Add(mtuProbeDelay * rtt)
+		var probes []protocol.ByteCount
+		for d.ShouldSendProbe(t) {
+			if len(probes) > 24 {
+				Fail(fmt.Sprintf("too many iterations: %v", probes))
+			}
+			ping, size := d.GetPing()
+			probes = append(probes, size)
+			if size <= realMTU {
+				ping.Handler.OnAcked(ping.Frame)
+			} else {
+				ping.Handler.OnLost(ping.Frame)
+			}
+			t = t.Add(mtuProbeDelay * rtt)
+		}
+		diff := realMTU - currentMTU
+		Expect(diff).To(BeNumerically(">=", 0))
+		if maxMTU > currentMTU+maxMTU {
+			Expect(tracedMTU).To(Equal(currentMTU))
+			Expect(tracerDone).To(BeTrue())
+		}
+		fmt.Fprintf(GinkgoWriter, "MTU discovered: %d (diff: %d)\n", currentMTU, diff)
+		fmt.Fprintf(GinkgoWriter, "probes sent (%d): %v\n", len(probes), probes)
+		Expect(diff).To(BeNumerically("<=", maxMTUDiff))
+	})
 
-				ping, size := d.GetPing()
-				if size <= realMTU {
-					ping.Handler.OnAcked(ping.Frame)
+	const maxRandomLoss = maxLostMTUProbes - 1
+	It(fmt.Sprintf("finds the MTU, with up to %d packets lost", maxRandomLoss), MustPassRepeatedly(500), func() {
+		maxMTU := protocol.ByteCount(r.Intn(int(3000-startMTU))) + startMTU + 1
+		currentMTU := startMTU
+		var tracedMTU protocol.ByteCount
+		var tracerDone bool
+		d := newMTUDiscoverer(
+			rttStats,
+			startMTU,
+			maxMTU,
+			func(s protocol.ByteCount) { currentMTU = s },
+			&logging.ConnectionTracer{
+				UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+					tracedMTU = mtu
+					tracerDone = done
+				},
+			},
+		)
+		d.Start()
+		now := time.Now()
+		realMTU := protocol.ByteCount(r.Intn(int(maxMTU-startMTU))) + startMTU
+		fmt.Fprintf(GinkgoWriter, "MTU: %d, max: %d\n", realMTU, maxMTU)
+		t := now.Add(mtuProbeDelay * rtt)
+		var probes, randomLosses []protocol.ByteCount
+		for d.ShouldSendProbe(t) {
+			if len(probes) > 32 {
+				Fail(fmt.Sprintf("too many iterations: %v", probes))
+			}
+			ping, size := d.GetPing()
+			probes = append(probes, size)
+			packetFits := size <= realMTU
+			var acked bool
+			if packetFits {
+				randomLoss := r.Intn(maxLostMTUProbes) == 0 && len(randomLosses) < maxRandomLoss
+				if randomLoss {
+					randomLosses = append(randomLosses, size)
 				} else {
-					ping.Handler.OnLost(ping.Frame)
+					ping.Handler.OnAcked(ping.Frame)
+					acked = true
 				}
-				t = t.Add(mtuProbeDelay * rtt)
 			}
-			diff := realMTU - currentMTU
-			Expect(diff).To(BeNumerically(">=", 0))
-			maxDiff = max(maxDiff, diff)
-			if maxMTU > currentMTU+maxMTU {
-				Expect(tracedMTU).To(Equal(currentMTU))
-				Expect(tracerDone).To(BeTrue())
+			if !acked {
+				ping.Handler.OnLost(ping.Frame)
 			}
+			t = t.Add(mtuProbeDelay * rtt)
+		}
+		diff := realMTU - currentMTU
+		Expect(diff).To(BeNumerically(">=", 0))
+		if maxMTU > currentMTU+maxMTU {
+			Expect(tracedMTU).To(Equal(currentMTU))
+			Expect(tracerDone).To(BeTrue())
 		}
-		Expect(maxDiff).To(BeEquivalentTo(maxMTUDiff))
+		fmt.Fprintf(GinkgoWriter, "MTU discovered with random losses %v: %d (diff: %d)\n", randomLosses, currentMTU, diff)
+		fmt.Fprintf(GinkgoWriter, "probes sent (%d): %v\n", len(probes), probes)
+		Expect(diff).To(BeNumerically("<=", maxMTUDiff))
 	})
 })