Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3fc2af2
spec(retry): pin the Retry-After contract and give conformance a clock
jeremy Sep 10, 2026
ce588f9
fix(go): honour Retry-After at every retried status, in both clients
jeremy Sep 10, 2026
24b03f1
fix(typescript): honour Retry-After at every retried status
jeremy Sep 10, 2026
a45991e
fix(ruby): honour Retry-After at every retried status, round dates up
jeremy Sep 10, 2026
87b9e68
fix(python): round a Retry-After HTTP-date remainder up
jeremy Sep 10, 2026
3e7a9f8
fix(kotlin): honour Retry-After at every retried status
jeremy Sep 10, 2026
1f33d69
fix(swift): honour Retry-After at every retried status
jeremy Sep 10, 2026
97b969e
Carry RetryAfter on every Go error arm, bound the clock token, stub t…
jeremy Sep 10, 2026
154b80b
Drive the generated Go retry test through its public client; map the …
jeremy Sep 10, 2026
d9e064c
Close the response body and drop the unused server return in the gene…
jeremy Sep 10, 2026
9b7677c
Count Rust among the SDKs the Retry-After contract inventories
jeremy Sep 10, 2026
8889751
Hand the parsed Retry-After to the hook error, and true up three inve…
jeremy Sep 10, 2026
bc5cf0e
Carry Retry-After through Ruby's public mapper, make the 429 download…
jeremy Sep 11, 2026
3bce40e
Sleep the Retry-After the download loop mapped, and inventory Ruby's …
jeremy Sep 11, 2026
77613f7
Carry Retry-After on Ruby's download errors too
jeremy Sep 13, 2026
3d5e6cc
Say which layer the every-status Retry-After claim binds at, and inve…
jeremy Sep 14, 2026
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
279 changes: 160 additions & 119 deletions SPEC.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions conformance/runner/go/header_tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"fmt"
"net/http"
"regexp"
"strconv"
"time"
)

var (
headerToken = regexp.MustCompile(`^\{\{(.*)\}\}$`)
httpdateToken = regexp.MustCompile(`^httpdate\+([0-9]{1,9})s$`)
)

// resolveHeaderValue substitutes the one token a fixture header value may
// carry, `{{httpdate+Ns}}`, at the moment the response is served (SPEC §19,
// conformance/schema.json). Every other value passes through untouched.
//
// The token resolves to the IMF-fixdate of floor(now) + N + 1 seconds: the
// first whole second strictly more than N seconds after the second the
// response is served in. A compliant SPEC §6 parser sees a remainder in
// (N - latency, N + 1] and, rounding up, computes at least N whole seconds, so
// the fixture pairs it with a `delayBetweenRequests` floor of N × 1000 ms. It
// exists because a static fixture has no clock: a literal past date pins only
// the fall-through, and a far-future one is differently behaved per host.
//
// N is one to nine digits, so the arithmetic is exact everywhere and every
// runner's date formatter stays in range; a longer N is an unrecognised token.
//
// An unrecognised `{{…}}` is an error rather than a literal: a typo'd token
// served verbatim would be an unparseable header, which the SDK answers with
// its ordinary backoff — the exact outcome the case exists to distinguish from.
func resolveHeaderValue(value string, now time.Time) (string, error) {
token := headerToken.FindStringSubmatch(value)
if token == nil {
return value, nil
}
inner := httpdateToken.FindStringSubmatch(token[1])
if inner == nil {
return "", fmt.Errorf("unrecognised header token %q: only {{httpdate+Ns}} is defined (conformance/schema.json)", value)
}
n, err := strconv.ParseInt(inner[1], 10, 64)
if err != nil {
return "", fmt.Errorf("header token %q: %w", value, err)
}
at := time.Unix(now.Unix()+n+1, 0).UTC()
return at.Format(http.TimeFormat), nil
}
50 changes: 50 additions & 0 deletions conformance/runner/go/header_tokens_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"strings"
"testing"
"time"
)

// A quarter-second into 10:18:14, so the floor and the round-up land on
// different seconds and a resolver that rounded would show it.
var tokenNow = time.Date(2021, time.June, 9, 10, 18, 14, 250_000_000, time.UTC)

func TestResolveHeaderValue_PassesPlainValuesThrough(t *testing.T) {
for _, v := range []string{"", "2", "Wed, 09 Jun 2021 10:18:14 GMT", "application/json", "{not a token}"} {
got, err := resolveHeaderValue(v, tokenNow)
if err != nil || got != v {
t.Errorf("resolveHeaderValue(%q) = %q, %v; want the value unchanged", v, got, err)
}
}
}

func TestResolveHeaderValue_ResolvesHttpdateToTheWholeSecondPastN(t *testing.T) {
cases := map[string]string{
"{{httpdate+2s}}": "Wed, 09 Jun 2021 10:18:17 GMT",
"{{httpdate+0s}}": "Wed, 09 Jun 2021 10:18:15 GMT",
"{{httpdate+10s}}": "Wed, 09 Jun 2021 10:18:25 GMT",
}
for token, want := range cases {
got, err := resolveHeaderValue(token, tokenNow)
if err != nil {
t.Fatalf("resolveHeaderValue(%q): %v", token, err)
}
if got != want {
t.Errorf("resolveHeaderValue(%q) = %q, want %q (floor(now) + N + 1, IMF-fixdate)", token, got, want)
}
}
}

func TestResolveHeaderValue_RejectsAnUnknownToken(t *testing.T) {
for _, v := range []string{"{{httpdate}}", "{{httpdate+2}}", "{{httpdate-2s}}", "{{now}}", "{{}}", "{{httpdate+1000000000s}}"} {
got, err := resolveHeaderValue(v, tokenNow)
if err == nil {
t.Errorf("resolveHeaderValue(%q) = %q, want an error — an unknown token must never be served literally", v, got)
continue
}
if !strings.Contains(err.Error(), v) {
t.Errorf("error for %q does not name the token: %v", v, err)
}
}
}
11 changes: 9 additions & 2 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,16 @@ func runTest(tc TestCase) TestResult {
// WithResponse parsing requires it for JSON body detection).
w.Header().Set("Content-Type", "application/json")

// Set response headers (may override Content-Type)
// Set response headers (may override Content-Type). Resolved at serve
// time: a `{{httpdate+Ns}}` value is relative to NOW, not to when the
// fixture was loaded.
for k, v := range resp.Headers {
w.Header().Set(k, v)
resolved, err := resolveHeaderValue(v, time.Now())
if err != nil {
fmt.Fprintf(os.Stderr, "fixture error: %v\n", err)
os.Exit(1)
}
w.Header().Set(k, resolved)
}

w.WriteHeader(resp.Status)
Expand Down
39 changes: 38 additions & 1 deletion conformance/runner/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
from __future__ import annotations

import json
import math
import os
import re
import sys
import time
from email.utils import formatdate
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -461,6 +463,39 @@ def _summarize_upcoming(envelope: dict) -> dict:
return summary


_HEADER_TOKEN = re.compile(r"^\{\{(.*)\}\}$")
_HTTPDATE_TOKEN = re.compile(r"^httpdate\+([0-9]{1,9})s$")


def resolve_header_value(value: str, now: float) -> str:
"""Substitute the one token a fixture header value may carry, `{{httpdate+Ns}}`.

Resolved at the moment the response is served (SPEC section 19,
conformance/schema.json) to the IMF-fixdate of floor(now) + N + 1 seconds:
the first whole second strictly more than N seconds after the second the
response is served in. A compliant SPEC section 6 parser sees a remainder in
(N - latency, N + 1] and, rounding up, computes at least N whole seconds, so
the fixture pairs it with a `delayBetweenRequests` floor of N * 1000 ms. It
exists because a static fixture has no clock: a literal past date pins only
the fall-through, and a far-future one is differently behaved per host.

N is one to nine digits, so the arithmetic is exact everywhere and every
runner's date formatter stays in range; a longer N is an unrecognised token.

An unrecognised `{{...}}` is an error rather than a literal: a typo'd token
served verbatim would be an unparseable header, which the SDK answers with
its ordinary backoff -- the exact outcome the case exists to distinguish from.
Every other value passes through untouched.
"""
token = _HEADER_TOKEN.match(value)
if token is None:
return value
inner = _HTTPDATE_TOKEN.match(token.group(1))
if inner is None:
raise ValueError(f"unrecognised header token {value!r}: only {{{{httpdate+Ns}}}} is defined (conformance/schema.json)")
return formatdate(math.floor(now) + int(inner.group(1)) + 1, usegmt=True)


def _normalize_body(body: Any, status: int | None) -> Any:
"""Normalize a mock response body for SDK compatibility.

Expand Down Expand Up @@ -1170,7 +1205,9 @@ def side_effect(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("simulated network error")
body = json.dumps(_normalize_body(r["body"], r.get("status"))).encode() if r.get("body") is not None else b""
headers = {"Content-Type": "application/json"}
headers.update(r.get("headers", {}))
# Resolved at serve time: a `{{httpdate+Ns}}` value is
# relative to NOW, not to when the fixture was loaded.
headers.update({k: resolve_header_value(v, time.time()) for k, v in r.get("headers", {}).items()})
return httpx.Response(r["status"], content=body, headers=headers)
elif paginates:
return httpx.Response(200, content=b"[]", headers={"Content-Type": "application/json"})
Expand Down
40 changes: 40 additions & 0 deletions conformance/runner/python/test_header_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""The `{{httpdate+Ns}}` header token (SPEC section 19, conformance/schema.json).

Run: `uv run pytest test_header_tokens.py`

A static fixture has no clock, so the positive half of SPEC section 6's
HTTP-date branch was unpinnable until this token (#780). These cases pin the
resolver's arithmetic against a frozen instant so the fixture's one-sided
timing floor rests on a deterministic contract.
"""
from __future__ import annotations

import pytest

from runner import resolve_header_value

# A quarter-second into 10:18:14 UTC, so floor and round-up differ.
NOW = 1623233894.25


@pytest.mark.parametrize("value", ["", "2", "Wed, 09 Jun 2021 10:18:14 GMT", "application/json", "{not a token}"])
def test_plain_values_pass_through(value: str) -> None:
assert resolve_header_value(value, NOW) == value


@pytest.mark.parametrize(
"token,expected",
[
("{{httpdate+2s}}", "Wed, 09 Jun 2021 10:18:17 GMT"),
("{{httpdate+0s}}", "Wed, 09 Jun 2021 10:18:15 GMT"),
("{{httpdate+10s}}", "Wed, 09 Jun 2021 10:18:25 GMT"),
],
)
def test_httpdate_resolves_to_the_whole_second_past_n(token: str, expected: str) -> None:
assert resolve_header_value(token, NOW) == expected


@pytest.mark.parametrize("value", ["{{httpdate}}", "{{httpdate+2}}", "{{httpdate-2s}}", "{{now}}", "{{}}", "{{httpdate+1000000000s}}"])
def test_unknown_tokens_are_errors_not_literals(value: str) -> None:
with pytest.raises(ValueError, match="unrecognised header token"):
resolve_header_value(value, NOW)
38 changes: 38 additions & 0 deletions conformance/runner/ruby/header_tokens_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

# The `{{httpdate+Ns}}` header token (SPEC §19, conformance/schema.json).
#
# A static fixture has no clock, so the positive half of SPEC §6's HTTP-date
# branch was unpinnable until this token (#780). These cases pin the resolver's
# arithmetic against a frozen instant so the fixture's one-sided timing floor
# rests on a deterministic contract. Ruby is the runner that had to move its
# header merge into the serve block for the token to see the right `now`.
#
# Run: `bundle exec ruby header_tokens_test.rb`

require "minitest/autorun"
require_relative "runner"

class HeaderTokensTest < Minitest::Test
# A quarter-second into 10:18:14 UTC, so floor and round-up differ.
NOW = Time.at(1_623_233_894.25).utc

def test_plain_values_pass_through
[ "", "2", "Wed, 09 Jun 2021 10:18:14 GMT", "application/json", "{not a token}" ].each do |value|
assert_equal value, HeaderTokens.resolve(value, NOW)
end
end

def test_httpdate_resolves_to_the_whole_second_past_n
assert_equal "Wed, 09 Jun 2021 10:18:17 GMT", HeaderTokens.resolve("{{httpdate+2s}}", NOW)
assert_equal "Wed, 09 Jun 2021 10:18:15 GMT", HeaderTokens.resolve("{{httpdate+0s}}", NOW)
assert_equal "Wed, 09 Jun 2021 10:18:25 GMT", HeaderTokens.resolve("{{httpdate+10s}}", NOW)
end

def test_unknown_tokens_are_errors_not_literals
[ "{{httpdate}}", "{{httpdate+2}}", "{{httpdate-2s}}", "{{now}}", "{{}}", "{{httpdate+1000000000s}}" ].each do |value|
error = assert_raises(ArgumentError) { HeaderTokens.resolve(value, NOW) }
assert_includes error.message, value
end
end
end
42 changes: 41 additions & 1 deletion conformance/runner/ruby/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require "json"
require "set"
require "fileutils"
require "time"

WebMock.enable!
WebMock.disable_net_connect!
Expand Down Expand Up @@ -235,6 +236,41 @@ def self.check(dispatch_failed)

# The delayBetweenRequests assertion contract, kept apart from the runner so
# its bounds branches are unit-testable (delay_gaps_test.rb).
# The one token a fixture header value may carry, `{{httpdate+Ns}}` (SPEC §19,
# conformance/schema.json), resolved at the moment the response is served to
# the IMF-fixdate of floor(now) + N + 1 seconds: the first whole second strictly
# more than N seconds after the second the response is served in. A compliant
# SPEC §6 parser sees a remainder in (N - latency, N + 1] and, rounding up,
# computes at least N whole seconds, so the fixture pairs it with a
# `delayBetweenRequests` floor of N × 1000 ms. It exists because a static
# fixture has no clock: a literal past date pins only the fall-through, and a
# far-future one is differently behaved per host.
#
# N is one to nine digits, so the arithmetic is exact everywhere and every
# runner's date formatter stays in range; a longer N is an unrecognised token.
#
# An unrecognised `{{…}}` is an error rather than a literal: a typo'd token
# served verbatim would be an unparseable header, which the SDK answers with its
# ordinary backoff — the exact outcome the case exists to distinguish from.
# Every other value passes through untouched.
module HeaderTokens
TOKEN = /\A\{\{(.*)\}\}\z/
HTTPDATE = /\Ahttpdate\+(\d{1,9})s\z/

def self.resolve(value, now)
token = TOKEN.match(value)
return value unless token

inner = HTTPDATE.match(token[1])
unless inner
raise ArgumentError,
"unrecognised header token #{value.inspect}: only {{httpdate+Ns}} is defined (conformance/schema.json)"
end

Time.at(now.to_i + inner[1].to_i + 1).utc.httpdate
end
end

module DelayGaps
# Validates one assertion against the recorded inter-request gaps, returning
# nil when it holds and a failure message otherwise.
Expand Down Expand Up @@ -1259,7 +1295,11 @@ def setup_mock_responses
# unlike a blanket stub .to_raise.
raise Faraday::ConnectionFailed, "simulated network error" if resp[:network_error]

resp
# Header values are resolved HERE, inside the to_return block, and not
# when the queue was built above: a `{{httpdate+Ns}}` token is relative
# to the moment the response is served, and the queue is built eagerly
# before any request arrives.
resp.merge(headers: resp[:headers].transform_values { |v| HeaderTokens.resolve(v, Time.now) })
elsif paginates
# Beyond defined responses for paginated ops: empty 200 terminates pagination
call_count += 1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Basecamp
import ConformanceSupport
import Foundation

/// One outbound request captured by the scripted transport.
Expand Down Expand Up @@ -120,8 +121,10 @@ final class ScriptedTransport: Transport, @unchecked Sendable {
}

var headerFields = ["Content-Type": "application/json"]
// Resolved at serve time: a `{{httpdate+Ns}}` value is relative to
// NOW, not to when the fixture was loaded.
for (key, value) in mock.allHeaders {
headerFields[key] = value
headerFields[key] = try resolveHeaderValue(value, now: Date())
}

let body: Data
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

/// A `{{…}}` header value the runner does not define. Surfaced as an error
/// rather than served literally: a typo'd token on the wire would be an
/// unparseable header, which the SDK answers with its ordinary backoff — the
/// exact outcome the case exists to distinguish from.
public struct UnrecognisedHeaderToken: Error, CustomStringConvertible, Sendable {
public let value: String
public var description: String {
"unrecognised header token \"\(value)\": only {{httpdate+Ns}} is defined (conformance/schema.json)"
}
}

/// Substitutes the one token a fixture header value may carry,
/// `{{httpdate+Ns}}` (SPEC §19, conformance/schema.json), at the moment the
/// response is served. Every other value passes through untouched.
///
/// The token resolves to the IMF-fixdate of floor(now) + N + 1 seconds: the
/// first whole second strictly more than N seconds after the second the
/// response is served in. A compliant SPEC §6 parser sees a remainder in
/// (N − latency, N + 1] and, rounding up, computes at least N whole seconds, so
/// the fixture pairs it with a `delayBetweenRequests` floor of N × 1000 ms. It
/// exists because a static fixture has no clock: a literal past date pins only
/// the fall-through, and a far-future one is differently behaved per host.
///
/// N is one to nine digits, so the arithmetic is exact everywhere and every
/// runner's date formatter stays in range; a longer N is an unrecognised token.
public func resolveHeaderValue(_ value: String, now: Date) throws -> String {
guard value.hasPrefix("{{"), value.hasSuffix("}}"), value.count >= 4 else { return value }
let inner = value.dropFirst(2).dropLast(2)
let prefix = "httpdate+"
guard inner.hasPrefix(prefix), inner.hasSuffix("s") else { throw UnrecognisedHeaderToken(value: value) }
let digits = inner.dropFirst(prefix.count).dropLast()
guard !digits.isEmpty, digits.count <= 9, digits.allSatisfy({ $0.isASCII && $0.isNumber }), let n = Int(digits) else {
throw UnrecognisedHeaderToken(value: value)
}
let seconds = floor(now.timeIntervalSince1970) + Double(n) + 1
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "GMT")
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
return formatter.string(from: Date(timeIntervalSince1970: seconds))
}
Loading
Loading