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
43 changes: 36 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1911,12 +1911,43 @@ either **waiver-backed** (a `rubric-audit.json` waiver ID), **architectural**
with no rubric record — tracked work, not an accepted divergence). A PR that
closes a gap deletes exactly its own lines.

One fixture, "List operation returns first page with Link header"
(`conformance/tests/pagination.json`, tagged `link-header`), is handled by a
tag branch rather than a named-skip entry, because the exclusion is
architectural: every SDK auto-paginates by design, so its first-page-only
`requestCount` assertion is inapplicable. What that branch excludes differs by
runner, and the difference is deliberate:

- **Go, Python, Ruby, TypeScript** suppress the `requestCount` ASSERTION only.
The case still runs, and its `statusCode: 200` and `noError` assertions still
fire. This is what lets `requestCount` be asserted as an exact count
everywhere else (#573) without shedding the rest of the case.

- **Kotlin and Swift** skip the whole CASE, as they always have. Both derive a
response's status from the last mock response the SDK consumed, and an
auto-paginating SDK walks past the end of a one-response queue, so `statusCode`
reports "no response" and the case cannot pass on those two runners. Narrowing
them to the assertion was tried and reverted: `make conformance-kotlin` and
`make conformance-swift` each then report
`FAIL: List operation returns first page with Link header` /
`Expected status code 200, but got no response` and exit 2. Widening their
status model is separate work, not a skip to delete here.

Note the shape this avoids: #573 first narrowed nothing and instead added the
whole-case skip to all four remaining runners, which left the fixture skipped by
all six — present in `pagination.json`, passing `conformance-fixtures-check` and
`check-fixture-coverage`, and executed by nothing. That is #572's defect one
layer down. Nothing in the build detects a fixture no runner runs; that gap is
tracked as #602.

**Go** (`conformance/runner/go/main.go` `goSDKSkips`) — architectural; same-origin
logic is covered by `TestIsSameOrigin` unit tests:
- "Mixed-case host and explicit default port stay on the mocked origin" — Go runner dials `configOverrides.baseUrl` directly; its `httptest` mock owns its origin, so origin-interception normalization does not apply.
- "Bracketed IPv6 loopback origin stays on the mocked origin" — same as above.

**Python** (`conformance/runner/python/runner.py` `SKIPS`) — none.
**Python** (`conformance/runner/python/runner.py` `SKIPS`) — none. The
`link-header` fixture above runs; only its `requestCount` assertion is
suppressed.

**Ruby** (`conformance/runner/ruby/runner.rb` `RUBY_SKIPS`):
- "PUT operation is naturally idempotent" — GET-only retry (waiver 2B.3).
Expand All @@ -1934,13 +1965,11 @@ logic is covered by `TestIsSameOrigin` unit tests:
**TypeScript** (`conformance/runner/typescript/runner.test.ts` `TS_SDK_SKIPS`):
- "Large integer IDs preserved without precision loss" — `Number` is 53-bit (waiver 1B.6).

**Kotlin** (`kotlin/conformance/.../Main.kt` — one tag-based branch; `KOTLIN_SKIPS`
is empty):
- "List operation returns first page with Link header" — skipped via the `link-header` tag branch, not `KOTLIN_SKIPS`: Kotlin auto-paginates by design, so a first-page-only requestCount assertion is inapplicable (architectural).
**Kotlin** (`kotlin/conformance/.../Main.kt` — `KOTLIN_SKIPS` is empty) — none
beyond the whole-case `link-header` tag branch described above.

**Swift** (`conformance/runner/swift/.../Runner.swift` — one tag-based branch;
`temporarySkips` is empty):
- "List operation returns first page with Link header" — skipped via the `link-header` tag branch, not `temporarySkips`: Swift auto-paginates by design, so a first-page-only requestCount assertion is inapplicable (architectural, identical to Kotlin and TypeScript).
**Swift** (`conformance/runner/swift/.../Runner.swift` — `temporarySkips` is
empty) — none beyond the whole-case `link-header` tag branch described above.

Swift carries no capability skips. It is three-gate on retry (status, network,
idempotent POST) and, since #563, retries the authenticated download hop, so
Expand Down
25 changes: 8 additions & 17 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1061,25 +1061,16 @@ func checkAssertion(
) *TestResult {
sdkErr := opResult.err

// Detect if any mock response includes a Link header with rel="next".
// The real SDK auto-paginates, so actual requestCount will be >= expected.
hasLinkNextHeader := false
for _, mr := range tc.MockResponses {
if link, ok := mr.Headers["Link"]; ok && strings.Contains(link, `rel="next"`) {
hasLinkNextHeader = true
break
}
}

switch assertion.Type {
case "requestCount":
expected := expectedInt(assertion.Expected)
if hasLinkNextHeader {
if requestCount < expected {
return fail(tc, fmt.Sprintf("Expected >= %d requests (SDK auto-paginates), got %d", expected, requestCount))
}
} else if requestCount != expected {
return fail(tc, fmt.Sprintf("Expected %d requests, got %d", expected, requestCount))
// The Go SDK auto-paginates list operations, so a fixture that counts
// first-page requests only is inapplicable — but ONLY its count is.
// The rest of the case still runs. See requestCountApplies (#573).
if !requestCountApplies(tc.Tags) {
return nil
}
if msg := checkRequestCount(requestCount, expectedInt(assertion.Expected)); msg != "" {
return fail(tc, msg)
}

case "delayBetweenRequests":
Expand Down
60 changes: 60 additions & 0 deletions conformance/runner/go/request_count.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import "fmt"

// checkRequestCount validates one `requestCount` assertion, returning "" when
// it holds and a failure message otherwise.
//
// EXACT, always — including the auto-paginating fixtures. The runners used to
// relax this to a lower bound whenever any mock response carried a
// `Link: rel="next"` header, on the theory that an auto-paginating SDK would
// legitimately make more requests than the fixture named. That is backwards for
// the fixtures the relaxation covered: in conformance/tests/pagination.json,
// "Pagination stops at maxPages safety cap" and "maxItems caps results across
// pages" each queue THREE pages and expect TWO requests, because stopping early
// is the behavior under test. `>=` passes an SDK that ignored the cap and
// walked every page. "Auto-pagination follows Link headers across multiple
// pages" is the exposed case: its only assertions are requestCount and noError,
// so an over-fetch has nothing else to catch it — the other two happen to carry
// a `responseMeta` truncated assertion that fires instead, coverage by luck.
//
// The one fixture where the count genuinely does not apply to an
// auto-paginating SDK — "List operation returns first page with Link header",
// which asserts a single request — carries the `link-header` tag, and
// requestCountApplies reports false for it. Nothing that still reaches this
// function needs the relaxation.
//
// Swift took this in #558; #573 is the same fix for the other five runners.
func checkRequestCount(actual, expected int) string {
if actual != expected {
return fmt.Sprintf("Expected %d requests, got %d", expected, actual)
}
return ""
}

// linkHeaderTag marks a fixture whose requestCount counts first-page requests
// only, which an auto-paginating SDK cannot satisfy.
const linkHeaderTag = "link-header"

// requestCountApplies reports whether a fixture's `requestCount` assertion is
// meaningful for this SDK.
//
// SCOPE: this suppresses ONE ASSERTION, not the whole test case. An earlier
// revision skipped the entire `link-header` case in every runner, which took
// its `statusCode: 200` and `noError` assertions down with the inapplicable
// `requestCount` — Kotlin and Swift had always skipped the case wholesale, so
// once Go, Python, Ruby and TypeScript joined them the fixture was executed by
// nothing at all while still sitting in conformance/tests/pagination.json,
// passing conformance-fixtures-check and check-fixture-coverage. That is the
// #572 shape ("present, run by nothing") one layer down. Only the count is
// inapplicable; the status code and the absence of an error are not, and they
// are the assertions that catch an auto-paginating SDK that walked the Link
// header into an error.
func requestCountApplies(tags []string) bool {
for _, tag := range tags {
if tag == linkHeaderTag {
return false
}
}
return true
}
106 changes: 106 additions & 0 deletions conformance/runner/go/request_count_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Bounds contract for the requestCount assertion (#573).
//
// Until this commit the five non-Swift runners evaluated requestCount as a
// LOWER bound whenever any mock response carried `Link: rel="next"`. Every
// committed fixture passes under both rules, so nothing in the suite could tell
// them apart — the same shape as the #563 delayBetweenRequests regression these
// support modules exist to pin. The over-fetch case below is the one that
// distinguishes them, and it is the case that matters: pagination.json's
// maxPages and maxItems fixtures each queue three pages and assert two
// requests, so a lower bound green-passes an SDK that ignored the cap.

package main

import "testing"

func TestRequestCountAcceptsTheExactCount(t *testing.T) {
if msg := checkRequestCount(2, 2); msg != "" {
t.Fatalf("exact match should pass; got %q", msg)
}
}

func TestRequestCountRejectsAnUnderFetch(t *testing.T) {
if msg := checkRequestCount(1, 2); msg == "" {
t.Fatal("1 request where 2 were expected should fail")
}
}

func TestRequestCountRejectsAnOverFetch(t *testing.T) {
// The regression. Under the old lower bound this returned "" — an SDK that
// walked all three queued pages instead of stopping at the maxPages cap
// reported a clean pass.
if msg := checkRequestCount(3, 2); msg == "" {
t.Fatal("3 requests where 2 were expected should fail; a lower bound would accept it")
}
}

func TestRequestCountMessageNamesBothCounts(t *testing.T) {
msg := checkRequestCount(3, 2)
if msg != "Expected 2 requests, got 3" {
t.Fatalf("failure message should name expected and actual; got %q", msg)
}
}

func TestRequestCountZeroRequestsIsNotAFreePass(t *testing.T) {
// A test whose operation never reached the wire records zero requests.
// That must fail an assertion expecting one, not read as "no data, no
// opinion".
if msg := checkRequestCount(0, 1); msg == "" {
t.Fatal("0 requests where 1 was expected should fail")
}
}

func TestRequestCountZeroExpectedRequiresZeroActual(t *testing.T) {
if msg := checkRequestCount(0, 0); msg != "" {
t.Fatalf("0 expected and 0 actual should pass; got %q", msg)
}
if msg := checkRequestCount(1, 0); msg == "" {
t.Fatal("1 request where 0 were expected should fail")
}
}

// Applicability contract (#573). The `link-header` fixture's requestCount is
// inapplicable to an auto-paginating SDK; its statusCode and noError
// assertions are not. Suppressing the CASE instead of the ASSERTION left the
// fixture executed by nothing at all — it stays in pagination.json and passes
// conformance-fixtures-check and check-fixture-coverage either way, so nothing
// else would have reported it.

func TestRequestCountDoesNotApplyToLinkHeaderFixtures(t *testing.T) {
if requestCountApplies([]string{"pagination", "link-header"}) {
t.Fatal("link-header fixtures must not have their requestCount asserted")
}
}

func TestRequestCountAppliesToEveryOtherFixture(t *testing.T) {
for _, tags := range [][]string{nil, {}, {"pagination"}, {"retry", "idempotent"}} {
if !requestCountApplies(tags) {
t.Fatalf("requestCount must be asserted for tags %v", tags)
}
}
}

// The suppression is one assertion wide. If it ever grows to the whole case
// again, the fixture's other two assertions stop running everywhere they still
// run, and nothing downstream notices.
func TestLinkHeaderSuppressionIsScopedToTheCountAssertion(t *testing.T) {
tc := TestCase{
Name: "List operation returns first page with Link header",
Tags: []string{"pagination", "link-header"},
Assertions: []Assertion{
{Type: "requestCount", Expected: float64(1)},
{Type: "statusCode", Expected: float64(200)},
{Type: "noError"},
},
}
live := 0
for _, a := range tc.Assertions {
if a.Type == "requestCount" && !requestCountApplies(tc.Tags) {
continue
}
live++
}
if live != 2 {
t.Fatalf("statusCode and noError must still be evaluated; %d assertion(s) live", live)
}
}
69 changes: 62 additions & 7 deletions conformance/runner/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,57 @@ def check_delay_gaps(
return None


def check_request_count(actual: int, expected: int) -> str | None:
"""Validate one ``requestCount`` assertion; None when it holds.

EXACT, always — including the auto-paginating fixtures. The runner used to
relax this to a lower bound whenever any mock response carried
``Link: rel="next"``, on the theory that an auto-paginating SDK would
legitimately make more requests than the fixture named. That is backwards
for the fixtures the relaxation covered: in conformance/tests/pagination.json,
"Pagination stops at maxPages safety cap" and "maxItems caps results across
pages" each queue THREE pages and expect TWO requests, because stopping
early is the behavior under test. ``>=`` passes an SDK that ignored the cap
and walked every page. "Auto-pagination follows Link headers across multiple
pages" is the exposed case: its only assertions are requestCount and
noError, so an over-fetch has nothing else to catch it.

The one fixture where the count genuinely does not apply to an
auto-paginating SDK — "List operation returns first page with Link header",
which asserts a single request — carries the ``link-header`` tag, and
``request_count_applies`` reports False for it. Nothing that still reaches
this function needs the relaxation.

Swift took this in #558; #573 is the same fix for the other five runners.
"""
if actual != expected:
return f"Expected {expected} requests, got {actual}"
return None


#: Marks a fixture whose requestCount counts first-page requests only, which an
#: auto-paginating SDK cannot satisfy.
LINK_HEADER_TAG = "link-header"


def request_count_applies(tags: list[str]) -> bool:
"""Whether a fixture's ``requestCount`` assertion is meaningful for this SDK.

SCOPE: this suppresses ONE ASSERTION, not the whole test case. An earlier
revision skipped the entire ``link-header`` case in every runner, which took
its ``statusCode: 200`` and ``noError`` assertions down with the
inapplicable ``requestCount`` — Kotlin and Swift had always skipped the case
wholesale, so once Go, Python, Ruby and TypeScript joined them the fixture
was executed by nothing at all while still sitting in
conformance/tests/pagination.json, passing conformance-fixtures-check and
check-fixture-coverage. That is the #572 shape ("present, run by nothing")
one layer down. Only the count is inapplicable; the status code and the
absence of an error are not, and they are the assertions that catch an
auto-paginating SDK that walked the Link header into an error.
"""
return LINK_HEADER_TAG not in tags


@dataclass
class TestTracker:
requests: list[dict] = field(default_factory=list)
Expand Down Expand Up @@ -564,13 +615,17 @@ def _verify_assertions(self, *, result: Any, error: Exception | None) -> TestRes
for assertion in self._test.get("assertions", []):
match assertion["type"]:
case "requestCount":
actual = self._tracker.request_count
expected = assertion["expected"]
if self._auto_paginates():
if actual < expected:
failures.append(f"Expected >= {expected} requests, got {actual}")
elif actual != expected:
failures.append(f"Expected {expected} requests, got {actual}")
# The Python SDK auto-paginates list operations, so a
# fixture that counts first-page requests only is
# inapplicable — but ONLY its count is. The rest of the
# case still runs. See request_count_applies (#573).
if not request_count_applies(self._test.get("tags", [])):
continue
failure = check_request_count(
self._tracker.request_count, assertion["expected"]
)
if failure is not None:
failures.append(failure)

case "delayBetweenRequests":
# Not all gaps are retry gaps — the download flow's final
Expand Down
Loading
Loading