Go version

go version go1.23.2 darwin/arm64

Output of go env in your module/workspace:

GO111MODULE=''
GOARCH='arm64'
GOBIN=''
GOCACHE='/Users/zy.chen/Library/Caches/go-build'
GOENV='/Users/zy.chen/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFLAGS=''
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMODCACHE='/Users/zy.chen/go/pkg/mod'
GONOPROXY='github.com/AfterShip'
GONOSUMDB='github.com/AfterShip'
GOOS='darwin'
GOPATH='/Users/zy.chen/go'
GOPRIVATE='github.com/AfterShip'
GOPROXY='direct'
GOROOT='/opt/homebrew/Cellar/go/1.23.2/libexec'
GOSUMDB='sum.golang.org'
GOTMPDIR=''
GOTOOLCHAIN='local'
GOTOOLDIR='/opt/homebrew/Cellar/go/1.23.2/libexec/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.23.2'
GODEBUG=''
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/zy.chen/Library/Application Support/go/telemetry'
GCCGO='gccgo'
GOARM64='v8.0'
AR='ar'
CC='cc'
CXX='c++'
CGO_ENABLED='1'
GOMOD='/opt/homebrew/opt/go/libexec/src/go.mod'
GOWORK=''
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
PKG_CONFIG='pkg-config'
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/fm/spjh4bvs1hzfsg9w8ybmc1zm0000gn/T/go-build2972010941=/tmp/go-build -gno-record-gcc-switches -fno-common'

What did you do?

Found an inconsistency in error message in net/http/client.go.

What did you see happen?

The current error message says:

if b.reqDidTimeout() {
    err = &timeoutError{err.Error() + " (Client.Timeout or context cancellation while reading body)"}
}

However, when reqDidTimeout() returns true in cancelTimerBody.Read(), it should only be due to Client.Timeout expiration, so the error message should only mention "Client.Timeout".

What did you expect to see?

The error message should only mention "Client.Timeout" as below:

err = &timeoutError{err.Error() + " (Client.Timeout exceeded while reading body)"}

Analysis

After analyzing the code in setRequestCancel(), reqDidTimeout() only returns true when:

  1. fast path: return true when client timeout exceeds
if req.Cancel == nil && knownTransport {
    // If they already had a Request.Context that's
    // expiring sooner, do nothing:
    if !timeBeforeContextDeadline(deadline, oldCtx) {
        return nop, alwaysFalse
    }

    var cancelCtx func()
    req.ctx, cancelCtx = context.WithDeadline(oldCtx, deadline)
    return cancelCtx, func() bool { return time.Now().After(deadline) }
}
  1. normal path: return true when the timer created with deadline fires (<-timer.C)
timer := time.NewTimer(time.Until(deadline))
var timedOut atomic.Bool

go func() {
    select {
    case <-initialReqCancel:
        doCancel()
        timer.Stop()
    case <-timer.C:
        timedOut.Store(true)
        doCancel()
    case <-stopTimerCh:
        timer.Stop()
    }
}()

return stopTimer, timedOut.Load

Context cancellation or timeout does not trigger timedOut.Store(true), so when reqDidTimeout() returns true, it's always due to client timeout.

Comment From: seankhliao

or it may be a bug that a body read isn't cancelled when the request context is

Comment From: gabyhelp

Related Issues

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

Comment From: cherrymui

cc @neild

Comment From: neild

Looks like this error used to be returned on a path where either Client.Timeout or context cancellation could occur, but some refactoring has left it only happening after Timeout.