Skip to content

Commit 0d2bc4d

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

7 files changed

Lines changed: 224 additions & 70 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/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: 113 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,99 @@ 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+
// todo: using options in request
519+
if c.ClientOptions.SenseContextCancel && ctx.Done() != nil {
520+
doChan := make(chan doResult)
521+
go func() {
522+
canRetry, dErr := c.readResp(cc, inPool, resetConnection, req, resp, deadline)
523+
doChan <- doResult{
524+
canIdempotentRetry: canRetry,
525+
err: dErr,
526+
}
527+
}()
528+
select {
529+
case <-ctx.Done():
530+
// Close connection directly and wait for readResp releasing connection resource
531+
cc.c.Close()
532+
// wait for goroutine exited
533+
// since the lifecycle of context has finished,
534+
// there is no need to retry
535+
<-doChan
536+
return false, ctx.Err()
537+
case res := <-doChan:
538+
return res.canIdempotentRetry, res.err
539+
}
540+
}
541+
542+
canIdempotentRetry, err = c.readResp(cc, inPool, resetConnection, req, resp, deadline)
543+
472544
return canIdempotentRetry, err
473545
}
474546

547+
type doResult struct {
548+
canIdempotentRetry bool
549+
err error
550+
}
551+
475552
func minTimeout(d0, d1 time.Duration) time.Duration {
476553
if d0 <= 0 {
477554
if d1 <= 0 {
@@ -516,7 +593,7 @@ func calcTimeout(deadline time.Time, timeout time.Duration) time.Duration {
516593
return timeout
517594
}
518595

519-
func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Response) (bool, error) {
596+
func (c *HostClient) checkAndConfigureReqResp(req *protocol.Request, resp *protocol.Response) {
520597
if req == nil {
521598
panic("BUG: req cannot be nil")
522599
}
@@ -536,46 +613,16 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
536613
if c.DisablePathNormalizing {
537614
req.URI().DisablePathNormalizing = true
538615
}
616+
}
539617

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-
}
618+
func (c *HostClient) writeReq(cc *clientConn, connInPool bool, req *protocol.Request, resp *protocol.Response, deadline time.Time) (
619+
canIdempotentRetry bool,
620+
resetConnection bool,
621+
shouldFinish bool,
622+
err error,
623+
) {
556624
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-
}
625+
o := req.Options()
579626

580627
usingProxy := false
581628
if c.ProxyURI != nil && bytes.Equal(req.Scheme(), bytestr.StrHTTP) {
@@ -584,18 +631,16 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
584631
}
585632

586633
// write starts
587-
588-
timeout = calcTimeout(deadline, minTimeout(c.WriteTimeout, o.WriteTimeout()))
634+
timeout := calcTimeout(deadline, minTimeout(c.WriteTimeout, o.WriteTimeout()))
589635
if timeout < 0 {
590636
c.closeConn(cc)
591-
return false, errTimeout
637+
return false, resetConnection, true, errTimeout
592638
}
593639
if err = conn.SetWriteTimeout(timeout); err != nil {
594640
c.closeConn(cc)
595-
return true, err
641+
return true, resetConnection, true, err
596642
}
597643

598-
resetConnection := false
599644
if c.MaxConnDuration > 0 && time.Since(cc.createdTime) > c.MaxConnDuration && !req.ConnectionClose() {
600645
req.SetConnectionClose()
601646
resetConnection = true
@@ -625,7 +670,7 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
625670
err = errNorm.ToHertzError(err)
626671
}
627672
if !errors.Is(err, errs.ErrConnectionClosed) {
628-
return true, err
673+
return true, resetConnection, true, err
629674
}
630675

631676
// introduced by https://github.com/cloudwego/hertz/pull/412
@@ -635,27 +680,34 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
635680
// NOTE: can't use deadline since it likely already exceeded deadline when write
636681
timeout = minTimeout(50*time.Millisecond, minTimeout(c.ReadTimeout, o.ReadTimeout()))
637682
if conn.SetReadTimeout(timeout) != nil {
638-
return true, err
683+
return true, resetConnection, true, err
639684
}
640685
zr := c.acquireReader(conn)
641686
defer zr.Release()
642687
if respI.ReadHeaderAndLimitBody(resp, zr, c.MaxResponseBodySize) == nil {
643688
if code := resp.StatusCode(); code >= 400 && code < 600 {
644689
// strictly for 4xx only, but 5xx is also acceptable.
645690
// both can be considered better response rather than write err
646-
return false, nil
691+
return false, resetConnection, true, nil
647692
}
648693
}
649694

650-
if inPool {
695+
if connInPool {
651696
err = errs.ErrBadPoolConn
652697
}
653-
return true, err
698+
return true, resetConnection, true, err
654699
}
700+
return false, resetConnection, false, nil
701+
}
655702

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

658-
timeout = calcTimeout(deadline, minTimeout(c.ReadTimeout, o.ReadTimeout()))
709+
// read starts
710+
timeout := calcTimeout(deadline, minTimeout(c.ReadTimeout, o.ReadTimeout()))
659711
if timeout < 0 {
660712
c.closeConn(cc)
661713
return false, errTimeout
@@ -683,7 +735,7 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
683735
if err != nil {
684736
zr.Release() //nolint:errcheck
685737
c.closeConn(cc)
686-
if inPool && (err == io.EOF || err == syscall.ECONNRESET) {
738+
if connInPool && (err == io.EOF || err == syscall.ECONNRESET) {
687739
return true, errs.ErrBadPoolConn
688740
}
689741
// if this is not a pooled connection,
@@ -1454,4 +1506,8 @@ type ClientOptions struct {
14541506

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

0 commit comments

Comments
 (0)