From 6fbb756bbd28d9d82935b4680b9bb3ee3a6a7fb7 Mon Sep 17 00:00:00 2001 From: Charlotte Hartmann Paludo Date: Thu, 27 Aug 2026 10:51:49 +0200 Subject: [PATCH] upstream: finishe and cache requests even when the client cancels --- internal/upstream/upstream.go | 8 ++++- internal/upstream/upstream_test.go | 48 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 internal/upstream/upstream_test.go diff --git a/internal/upstream/upstream.go b/internal/upstream/upstream.go index 1a7dbf3..94d9c6a 100644 --- a/internal/upstream/upstream.go +++ b/internal/upstream/upstream.go @@ -8,10 +8,13 @@ import ( "fmt" "io" "net/http" + "time" "golang.org/x/sync/singleflight" ) +const fetchTimeout = time.Minute + // Result is a captured upstream response. type Result struct { Status int @@ -32,8 +35,11 @@ func New(client *http.Client) *Fetcher { // Get fetches the given url. func (f *Fetcher) Get(ctx context.Context, url string) (*Result, error) { + fetchCtx := context.WithoutCancel(ctx) v, err, _ := f.group.Do(url, func() (any, error) { - return f.doGet(ctx, url) + fetchCtx, cancel := context.WithTimeout(fetchCtx, fetchTimeout) + defer cancel() + return f.doGet(fetchCtx, url) }) if err != nil { return nil, err diff --git a/internal/upstream/upstream_test.go b/internal/upstream/upstream_test.go new file mode 100644 index 0000000..3633722 --- /dev/null +++ b/internal/upstream/upstream_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package upstream + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCallerCancellationDoesNotAbortFetch covers the case of a client disconnecting while the upstream request it triggered is still in flight. +func TestCallerCancellationDoesNotAbortFetch(t *testing.T) { + release := make(chan struct{}) + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + <-release + select { + case <-r.Context().Done(): + // The server saw the fetch get canceled; fail via an empty body below. + return + default: + } + _, _ = w.Write([]byte("collateral")) + })) + defer srv.Close() + + f := New(srv.Client()) + + canceledCtx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Go(func() { + res, err := f.Get(canceledCtx, srv.URL) + require.NoError(t, err) + assert.Equal(t, "collateral", string(res.Body)) + }) + + cancel() + close(release) + wg.Wait() + assert.Equal(t, 1, hits) +}