Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 13 additions & 10 deletions assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions jq_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package httpassert

import (
"context"
"errors"
"net/http"
"strings"
Expand Down Expand Up @@ -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.
Expand Down
Loading