carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 28b814202125a5faa2effe9667f6ec346d24ad5e parent 57375a7
2 files changed, +45 −2
  • internal/billingstripe/transport.go +13 −2
  • internal/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)
+ }
+ }
+}