From 232fb694722e8ed17c4545a06bccdabfcb5ac35f Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:54:14 -0400 Subject: [PATCH] fix(jq): Honor response request cancellation A cancelled request could still spend up to ten seconds evaluating a runaway jq expression because its assertion started from context.Background. Derive evaluation from the response request context while retaining the package timeout as a backstop for responses without a deadline. Co-Authored-By: OpenAI Codex (GPT-5) --- CHANGELOG.md | 5 +++++ assertions.go | 23 +++++++++++++---------- jq_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b5bb8..5674852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ are marked **Breaking** and listed first in their section. of the unbounded `http.DefaultClient`. Callers can still inject an HTTP client or apply a shorter request-context deadline. +### Fixed + +- jq evaluation stops when its response request's context is cancelled while + retaining the package's ten-second safety ceiling. + ## [0.3.0] - 2026-08-11 ### Added diff --git a/assertions.go b/assertions.go index 73c25e7..bd4bcbf 100644 --- a/assertions.go +++ b/assertions.go @@ -431,16 +431,13 @@ func AssertBodyEmpty() Assertion { // jqTimeout bounds the evaluation of one --assert-jq query. // // jq is a real language, so a query can simply never finish: `def f: f; f` -// compiles cleanly and runs forever. Nothing else a caller can type makes this -// program hang -- the request is bounded by --max-time, the retry loop by -// --retry, and an --assert-body pattern cannot blow up because Go's regexp -// engine is linear-time. This keeps that property rather than trading it away. +// compiles cleanly and runs forever. The response request's context is the +// primary cancellation signal, so its earlier deadline wins. This timeout is +// the backstop for a context without a deadline and for a Response constructed +// directly without an http.Request. // -// It is not a budget for real work, and deliberately is not --max-time: that -// bounds a request, and reusing it here would make a run take twice the number -// the caller set. Measured queries finish in tens of microseconds, so ten -// seconds is six orders of magnitude of headroom that no genuine assertion can -// reach. +// Measured queries finish in tens of microseconds, so ten seconds is six orders +// of magnitude of headroom that no genuine assertion can reach. const jqTimeout = 10 * time.Second // AssertJQ asserts that a jq expression holds against the response body. @@ -449,6 +446,8 @@ const jqTimeout = 10 * time.Second // value, which is what keeps this to one flag: jq already has types, // comparison and regexp, so there is no separator to invent, no ~= variant, // and no question of whether 5 means the number or the string. +// Evaluation stops when the response request's context is cancelled and is +// always bounded by the package's jq timeout. func AssertJQ(query string) (Assertion, error) { q, err := gojq.Parse(query) if err != nil { @@ -477,7 +476,11 @@ func runJQ(code *gojq.Code, query string, res *Response, timeout time.Duration) return nil, err } - ctx, cancel := context.WithTimeout(context.Background(), timeout) + parent := context.Background() + if res.Response != nil && res.Request != nil { + parent = res.Request.Context() + } + ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() outputs := 0 diff --git a/jq_test.go b/jq_test.go index 913a180..7b2e61f 100644 --- a/jq_test.go +++ b/jq_test.go @@ -1,6 +1,7 @@ package httpassert import ( + "context" "errors" "net/http" "strings" @@ -233,6 +234,46 @@ func Test_AssertJQ_boundsARunawayQuery(t *testing.T) { } } +func Test_AssertJQ_honorsRequestCancellation(t *testing.T) { + t.Parallel() + + assertion, err := AssertJQ(`def f: f; f`) + if err != nil { + t.Fatalf("AssertJQ: %s", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.test/health", nil) + if err != nil { + t.Fatalf("NewRequestWithContext: %s", err) + } + res := jqResponse(jqDoc) + res.Request = req + cancel() + + done := make(chan error, 1) + go func() { + failure, checkErr := assertion.Check(res) + if failure != nil { + checkErr = errors.New("cancelled query returned an assertion failure") + } + done <- checkErr + }() + + select { + case checkErr := <-done: + if !errors.Is(checkErr, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", checkErr) + } + var evaluation *EvaluationError + if !errors.As(checkErr, &evaluation) || evaluation.Code != EvaluationJQ { + t.Errorf("error = %T %+v, want jq EvaluationError", checkErr, evaluation) + } + case <-time.After(time.Second): + t.Fatal("jq evaluation ignored the cancelled request context") + } +} + // Test_jqTimeout pins the deadline the assertion actually uses. The test above // injects its own, so without this the real value could drift to something // useless and nothing would notice.