Skip to content

Commit d8f5ea3

Browse files
committed
feat: client support context cancel to control the lifecycle of request
1 parent acb0795 commit d8f5ea3

9 files changed

Lines changed: 243 additions & 72 deletions

File tree

pkg/app/client/option.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ func WithDialFunc(f network.DialFunc, dialers ...network.Dialer) config.ClientOp
181181
}}
182182
}
183183

184+
// WithSenseContextCancel is used to set whether the ctx passed into Do control the lifecycle of the request,
185+
// so that when the ctx is canceled, the corresponding request will also be finished
186+
func WithSenseContextCancel(b bool) config.ClientOption {
187+
return config.ClientOption{F: func(o *config.ClientOptions) {
188+
o.SenseContextCancel = b
189+
}}
190+
}
191+
184192
// customDialer set customDialerFunc and params to set dailFunc
185193
type customDialer struct {
186194
network.Dialer

pkg/app/client/option_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func TestClientOptions(t *testing.T) {
4444
),
4545
WithWriteTimeout(time.Second),
4646
WithConnStateObserve(nil, time.Second),
47+
WithSenseContextCancel(true),
4748
})
4849
assert.DeepEqual(t, 100*time.Millisecond, opt.DialTimeout)
4950
assert.DeepEqual(t, 128, opt.MaxConnsPerHost)
@@ -59,6 +60,7 @@ func TestClientOptions(t *testing.T) {
5960
assert.DeepEqual(t, 5*time.Second, opt.RetryConfig.MaxDelay)
6061
assert.DeepEqual(t, 1*time.Second, opt.RetryConfig.MaxJitter)
6162
assert.DeepEqual(t, 1*time.Second, opt.ObservationInterval)
63+
assert.DeepEqual(t, true, opt.SenseContextCancel)
6264
for i := 0; i < 100; i++ {
6365
assert.DeepEqual(t, opt.RetryConfig.DelayPolicy(uint(i), nil, opt.RetryConfig), retry.CombineDelay(retry.DefaultDelayPolicy, retry.FixedDelayPolicy, retry.BackOffDelayPolicy)(uint(i), nil, opt.RetryConfig))
6466
}

pkg/common/config/client_option.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ type ClientOptions struct {
134134
// Callback hook for re-configuring host client
135135
// If an error is returned, the request will be terminated.
136136
HostClientConfigHook func(hc interface{}) error
137+
138+
// Whether to let the ctx passed into Do control the lifecycle of the request,
139+
// so that when the ctx is canceled, the corresponding request will also be finished
140+
SenseContextCancel bool
137141
}
138142

139143
func NewClientOptions(opts []ClientOption) *ClientOptions {

pkg/common/config/request_option.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ type RequestOptions struct {
2929
writeTimeout time.Duration
3030
// Request timeout. Usually set by DoDeadline or DoTimeout
3131
// if <= 0, means not set
32-
requestTimeout time.Duration
33-
start time.Time
32+
requestTimeout time.Duration
33+
start time.Time
34+
senseContextCancel bool
3435
}
3536

3637
// RequestOption is the only struct to set request-level options.
@@ -109,6 +110,16 @@ func WithRequestTimeout(t time.Duration) RequestOption {
109110
}}
110111
}
111112

113+
// WithSenseContextCancel is used to set whether the ctx passed into Do control the lifecycle of the request,
114+
// so that when the ctx is canceled, the corresponding request will also be finished.
115+
//
116+
// This is the request level configuration.
117+
func WithSenseContextCancel(b bool) RequestOption {
118+
return RequestOption{F: func(o *RequestOptions) {
119+
o.senseContextCancel = b
120+
}}
121+
}
122+
112123
func (o *RequestOptions) Apply(opts []RequestOption) {
113124
for _, op := range opts {
114125
op.F(o)
@@ -143,6 +154,10 @@ func (o *RequestOptions) RequestTimeout() time.Duration {
143154
return o.requestTimeout
144155
}
145156

157+
func (o *RequestOptions) SenseContextCancel() bool {
158+
return o.senseContextCancel
159+
}
160+
146161
// StartRequest records the start time of the request.
147162
//
148163
// Note: Users should not call this method.
@@ -171,6 +186,7 @@ func (o *RequestOptions) CopyTo(dst *RequestOptions) {
171186
dst.dialTimeout = o.dialTimeout
172187
dst.requestTimeout = o.requestTimeout
173188
dst.start = o.start
189+
dst.senseContextCancel = o.senseContextCancel
174190
}
175191

176192
// SetPreDefinedOpts Pre define some RequestOption here

pkg/common/config/request_option_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ func TestRequestOptions(t *testing.T) {
3333
WithDialTimeout(time.Second),
3434
WithReadTimeout(time.Second),
3535
WithWriteTimeout(time.Second),
36+
WithSenseContextCancel(true),
3637
})
3738
assert.DeepEqual(t, "b", opt.Tag("a"))
3839
assert.DeepEqual(t, "d", opt.Tag("c"))
3940
assert.DeepEqual(t, "f", opt.Tag("e"))
4041
assert.DeepEqual(t, time.Second, opt.DialTimeout())
4142
assert.DeepEqual(t, time.Second, opt.ReadTimeout())
4243
assert.DeepEqual(t, time.Second, opt.WriteTimeout())
44+
assert.DeepEqual(t, true, opt.SenseContextCancel())
4345
assert.True(t, opt.IsSD())
4446
}
4547

pkg/network/netpoll/connection.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import (
2121
"strings"
2222
"syscall"
2323

24+
"github.com/cloudwego/netpoll"
25+
2426
errs "github.com/cloudwego/hertz/pkg/common/errors"
2527
"github.com/cloudwego/hertz/pkg/common/hlog"
2628
"github.com/cloudwego/hertz/pkg/network"
27-
"github.com/cloudwego/netpoll"
2829
)
2930

3031
type Conn struct {

pkg/network/netpoll/connection_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ import (
2323
"testing"
2424
"time"
2525

26-
"github.com/cloudwego/hertz/pkg/common/test/assert"
2726
"github.com/cloudwego/netpoll"
27+
28+
"github.com/cloudwego/hertz/pkg/common/test/assert"
2829
)
2930

3031
func TestReadBytes(t *testing.T) {

pkg/protocol/http1/client.go

Lines changed: 112 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ func (c *HostClient) Do(ctx context.Context, req *protocol.Request, resp *protoc
398398
default:
399399
}
400400

401-
canIdempotentRetry, err = c.do(req, resp)
401+
canIdempotentRetry, err = c.do(ctx, req, resp)
402402
// If there is no custom retry and err is equal to nil, the loop simply exits.
403403
if err == nil && isDefaultRetryFunc {
404404
if connAttempts != 0 {
@@ -456,22 +456,98 @@ func (c *HostClient) PendingRequests() int {
456456
return int(atomic.LoadInt32(&c.pendingRequests))
457457
}
458458

459-
func (c *HostClient) do(req *protocol.Request, resp *protocol.Response) (bool, error) {
459+
func (c *HostClient) do(ctx context.Context, req *protocol.Request, resp *protocol.Response) (bool, error) {
460460
nilResp := false
461461
if resp == nil {
462462
nilResp = true
463463
resp = protocol.AcquireResponse()
464464
}
465+
defer func() {
466+
if nilResp {
467+
protocol.ReleaseResponse(resp)
468+
}
469+
}()
465470

466-
canIdempotentRetry, err := c.doNonNilReqResp(req, resp)
471+
c.checkAndConfigureReqResp(req, resp)
467472

468-
if nilResp {
469-
protocol.ReleaseResponse(resp)
473+
// whole request timeout
474+
o := req.Options()
475+
deadline := time.Time{}
476+
if v := o.RequestTimeout(); v > 0 {
477+
deadline = o.StartTime().Add(v)
470478
}
471479

480+
// dial starts
481+
timeout := calcTimeout(deadline, minTimeout(c.DialTimeout, o.DialTimeout()))
482+
if timeout < 0 {
483+
return false, errTimeout
484+
}
485+
cc, inPool, err := c.acquireConn(timeout)
486+
if err != nil {
487+
return false, err
488+
}
489+
conn := cc.c
490+
resp.ParseNetAddr(conn)
491+
// force handshake using dial timeout
492+
if c.IsTLS && timeout > 0 {
493+
// NOTE: Handshake() here is optional as Write would tirigger handshake
494+
// but for tls handshake, it writes and reads, and we need to set deadline for that.
495+
tlsconn, ok := conn.(network.ConnTLSer)
496+
if ok {
497+
// currently netpoll doesn't support conn.SetDeadline nor tls, but crypto/tls.Conn does.
498+
// in case netpoll supports tls in the future, may need to change this to
499+
// call both conn.SetReadTimeout, and conn.SetWriteTimeout
500+
err = conn.SetDeadline(time.Now().Add(timeout))
501+
if err == nil {
502+
err = tlsconn.Handshake()
503+
// NOTE: no need conn.SetDeadline(time.Time{})?
504+
// we always reset before Write and Read
505+
}
506+
if err != nil {
507+
c.closeConn(cc)
508+
return true, err
509+
}
510+
}
511+
}
512+
513+
// write req
514+
canIdempotentRetry, resetConnection, shouldFinish, err := c.writeReq(cc, inPool, req, resp, deadline)
515+
if shouldFinish {
516+
return canIdempotentRetry, err
517+
}
518+
if (c.ClientOptions.SenseContextCancel || o.SenseContextCancel()) && ctx.Done() != nil {
519+
doChan := make(chan doResult)
520+
go func() {
521+
canRetry, dErr := c.readResp(cc, inPool, resetConnection, req, resp, deadline)
522+
doChan <- doResult{
523+
canIdempotentRetry: canRetry,
524+
err: dErr,
525+
}
526+
}()
527+
select {
528+
case <-ctx.Done():
529+
// Close connection directly and wait for readResp releasing connection resource
530+
cc.c.Close()
531+
// wait for goroutine exited
532+
// since the lifecycle of context has finished,
533+
// there is no need to retry
534+
<-doChan
535+
return false, ctx.Err()
536+
case res := <-doChan:
537+
return res.canIdempotentRetry, res.err
538+
}
539+
}
540+
541+
canIdempotentRetry, err = c.readResp(cc, inPool, resetConnection, req, resp, deadline)
542+
472543
return canIdempotentRetry, err
473544
}
474545

546+
type doResult struct {
547+
canIdempotentRetry bool
548+
err error
549+
}
550+
475551
func minTimeout(d0, d1 time.Duration) time.Duration {
476552
if d0 <= 0 {
477553
if d1 <= 0 {
@@ -516,7 +592,7 @@ func calcTimeout(deadline time.Time, timeout time.Duration) time.Duration {
516592
return timeout
517593
}
518594

519-
func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Response) (bool, error) {
595+
func (c *HostClient) checkAndConfigureReqResp(req *protocol.Request, resp *protocol.Response) {
520596
if req == nil {
521597
panic("BUG: req cannot be nil")
522598
}
@@ -536,46 +612,16 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
536612
if c.DisablePathNormalizing {
537613
req.URI().DisablePathNormalizing = true
538614
}
615+
}
539616

540-
o := req.Options()
541-
deadline := time.Time{}
542-
if v := o.RequestTimeout(); v > 0 {
543-
deadline = o.StartTime().Add(v)
544-
}
545-
546-
// dial starts
547-
548-
timeout := calcTimeout(deadline, minTimeout(c.DialTimeout, o.DialTimeout()))
549-
if timeout < 0 {
550-
return false, errTimeout
551-
}
552-
cc, inPool, err := c.acquireConn(timeout)
553-
if err != nil {
554-
return false, err
555-
}
617+
func (c *HostClient) writeReq(cc *clientConn, connInPool bool, req *protocol.Request, resp *protocol.Response, deadline time.Time) (
618+
canIdempotentRetry bool,
619+
resetConnection bool,
620+
shouldFinish bool,
621+
err error,
622+
) {
556623
conn := cc.c
557-
resp.ParseNetAddr(conn)
558-
559-
if c.IsTLS && timeout > 0 { // force handshake using dial timeout
560-
// NOTE: Handshake() here is optional as Write would tirigger handshake
561-
// but for tls handshake, it writes and reads, and we need to set deadline for that.
562-
tlsconn, ok := conn.(network.ConnTLSer)
563-
if ok {
564-
// currently netpoll doesn't support conn.SetDeadline nor tls, but crypto/tls.Conn does.
565-
// in case netpoll supports tls in the future, may need to change this to
566-
// call both conn.SetReadTimeout, and conn.SetWriteTimeout
567-
err := conn.SetDeadline(time.Now().Add(timeout))
568-
if err == nil {
569-
err = tlsconn.Handshake()
570-
// NOTE: no need conn.SetDeadline(time.Time{})?
571-
// we always reset before Write and Read
572-
}
573-
if err != nil {
574-
c.closeConn(cc)
575-
return true, err
576-
}
577-
}
578-
}
624+
o := req.Options()
579625

580626
usingProxy := false
581627
if c.ProxyURI != nil && bytes.Equal(req.Scheme(), bytestr.StrHTTP) {
@@ -584,18 +630,16 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
584630
}
585631

586632
// write starts
587-
588-
timeout = calcTimeout(deadline, minTimeout(c.WriteTimeout, o.WriteTimeout()))
633+
timeout := calcTimeout(deadline, minTimeout(c.WriteTimeout, o.WriteTimeout()))
589634
if timeout < 0 {
590635
c.closeConn(cc)
591-
return false, errTimeout
636+
return false, resetConnection, true, errTimeout
592637
}
593638
if err = conn.SetWriteTimeout(timeout); err != nil {
594639
c.closeConn(cc)
595-
return true, err
640+
return true, resetConnection, true, err
596641
}
597642

598-
resetConnection := false
599643
if c.MaxConnDuration > 0 && time.Since(cc.createdTime) > c.MaxConnDuration && !req.ConnectionClose() {
600644
req.SetConnectionClose()
601645
resetConnection = true
@@ -625,7 +669,7 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
625669
err = errNorm.ToHertzError(err)
626670
}
627671
if !errors.Is(err, errs.ErrConnectionClosed) {
628-
return true, err
672+
return true, resetConnection, true, err
629673
}
630674

631675
// introduced by https://github.com/cloudwego/hertz/pull/412
@@ -635,27 +679,34 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
635679
// NOTE: can't use deadline since it likely already exceeded deadline when write
636680
timeout = minTimeout(50*time.Millisecond, minTimeout(c.ReadTimeout, o.ReadTimeout()))
637681
if conn.SetReadTimeout(timeout) != nil {
638-
return true, err
682+
return true, resetConnection, true, err
639683
}
640684
zr := c.acquireReader(conn)
641685
defer zr.Release()
642686
if respI.ReadHeaderAndLimitBody(resp, zr, c.MaxResponseBodySize) == nil {
643687
if code := resp.StatusCode(); code >= 400 && code < 600 {
644688
// strictly for 4xx only, but 5xx is also acceptable.
645689
// both can be considered better response rather than write err
646-
return false, nil
690+
return false, resetConnection, true, nil
647691
}
648692
}
649693

650-
if inPool {
694+
if connInPool {
651695
err = errs.ErrBadPoolConn
652696
}
653-
return true, err
697+
return true, resetConnection, true, err
654698
}
699+
return false, resetConnection, false, nil
700+
}
655701

656-
// read starts
702+
func (c *HostClient) readResp(cc *clientConn, connInPool, resetConnection bool, req *protocol.Request, resp *protocol.Response, deadline time.Time) (bool, error) {
703+
var err error
704+
customSkipBody := resp.SkipBody
705+
conn := cc.c
706+
o := req.Options()
657707

658-
timeout = calcTimeout(deadline, minTimeout(c.ReadTimeout, o.ReadTimeout()))
708+
// read starts
709+
timeout := calcTimeout(deadline, minTimeout(c.ReadTimeout, o.ReadTimeout()))
659710
if timeout < 0 {
660711
c.closeConn(cc)
661712
return false, errTimeout
@@ -683,7 +734,7 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
683734
if err != nil {
684735
zr.Release() //nolint:errcheck
685736
c.closeConn(cc)
686-
if inPool && (err == io.EOF || err == syscall.ECONNRESET) {
737+
if connInPool && (err == io.EOF || err == syscall.ECONNRESET) {
687738
return true, errs.ErrBadPoolConn
688739
}
689740
// if this is not a pooled connection,
@@ -1454,4 +1505,8 @@ type ClientOptions struct {
14541505

14551506
// StateObserve execution interval
14561507
ObservationInterval time.Duration
1508+
1509+
// Whether to let the ctx passed into Do control the lifecycle of the request,
1510+
// so that when the ctx is canceled, the corresponding request will also be finished
1511+
SenseContextCancel bool
14571512
}

0 commit comments

Comments
 (0)