A deadline that closes the connection under a read is reported as the deadline
The transport closes the connection when the request's context ends, and the read in progress then failed with a closed-connection error, which the client classified as a network fault. Through http.Client the same failure read as a timeout or not depending on which of two timers fired first (the full gate caught it once in TestDo_TimeoutClassification). The transport now reports the context's error.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+45
−2
internal/billingstripe/transport.go+13 −2internal/billingstripe/transport_test.go+32 −0
diff --git a/internal/billingstripe/transport.go b/internal/billingstripe/transport.go| index d46685093..af00dca7e 100644 |
| --- a/internal/billingstripe/transport.go |
| +++ b/internal/billingstripe/transport.go |
| @@ -148,16 +148,27 @@ func (t *oneShotTransport) RoundTrip(req *http.Request) (*http.Response, error) |
| closeConn() |
| return nil, err |
| } |
| + // When the context ends, the connection is closed under whatever read |
| + // or write is in progress, and that call fails with "use of closed |
| + // network connection". The cause is the context, and that is what the |
| + // caller is told: an expired deadline must read as a timeout, not as a |
| + // network fault (the standard transport reports it the same way). |
| + because := func(err error) error { |
| + if cerr := ctx.Err(); cerr != nil { |
| + return cerr |
| + } |
| + return err |
| + } |
| out := req.Clone(ctx) |
| out.Close = true |
| if err := out.Write(conn); err != nil { |
| closeConn() |
| - return nil, err |
| + return nil, because(err) |
| } |
| resp, err := http.ReadResponse(bufio.NewReader(conn), out) |
| if err != nil { |
| closeConn() |
| - return nil, err |
| + return nil, because(err) |
| } |
| resp.Body = &closingBody{ReadCloser: resp.Body, done: closeConn} |
| return resp, nil |
diff --git a/internal/billingstripe/transport_test.go b/internal/billingstripe/transport_test.go| index 9c3172dca..52dff9be1 100644 |
| --- a/internal/billingstripe/transport_test.go |
| +++ b/internal/billingstripe/transport_test.go |
| @@ -164,3 +164,35 @@ func TestSuspendedSenderWakesToAClosedSendWindow(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +// A context deadline that passes while the response is awaited closes the |
| +// connection under the read. The transport reports the deadline, not the |
| +// closed connection, so the client classifies it as a timeout. Asked of the |
| +// transport directly: through http.Client the same failure is reported |
| +// either way depending on which of two timers fires first. |
| +func TestDeadlineDuringTheResponseIsReportedAsTheDeadline(t *testing.T) { |
| + block := make(chan struct{}) |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + <-block |
| + })) |
| + defer func() { |
| + close(block) |
| + srv.Close() |
| + }() |
| + for i := 0; i < 20; i++ { |
| + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/v1/subscriptions/sub_1", nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp, err := newOneShotTransport().RoundTrip(req) |
| + cancel() |
| + if err == nil { |
| + resp.Body.Close() |
| + t.Fatal("a held request was answered") |
| + } |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("run %d: err = %v, want the context's deadline", i, err) |
| + } |
| + } |
| +} |