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
b08e974
Pin the download hop-1 retry policy in SPEC §14 and the shared fixtures
jeremy Aug 1, 2026
05162d7
TypeScript: extract executeWithRetry and route download hop 1 through it
jeremy Aug 1, 2026
3b429d9
Python: route download hop 1 through the retry loop with the declared…
jeremy Aug 1, 2026
a0ea5e9
Ruby: route download hop 1 through the retry loop with the declared set
jeremy Aug 1, 2026
5bee24a
Kotlin: route download hop 1 through the retry loop with the declared…
jeremy Aug 1, 2026
9df2d42
Swift: route download hop 1 through a retry loop with the declared set
jeremy Aug 1, 2026
28850ee
Go: pin the download hop-1 retry set that the implementation already …
jeremy Aug 1, 2026
3c1e17f
Point the retry-metadata token smoke at the relocated consumption sites
jeremy Aug 1, 2026
4710daa
Scope the download hop-1 headers to what SPEC §14 allows in Python an…
jeremy Aug 1, 2026
b1fd567
Share Kotlin's auth-phase tag and bound the Swift cancellation poll
jeremy Aug 1, 2026
fc11654
Use assert_not_equal for the Ruby Accept-header pin
jeremy Aug 1, 2026
99919b0
Apply ruff format to the Python accept seam
jeremy Aug 1, 2026
63ab1dd
Tighten the round-two review findings across the spec, fixtures and t…
jeremy Aug 1, 2026
b966222
Count the 401 refresh replay, make Swift cancellation terminal, resto…
jeremy Aug 1, 2026
de8c925
Await the async refresh, not its boolean
jeremy Aug 1, 2026
aca2c48
Restore SPEC §4's refresh replay and teach every runner the retry-gap…
jeremy Aug 1, 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
48 changes: 31 additions & 17 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -984,25 +984,45 @@ Downloads use a two-hop pattern: an authenticated API request that returns a red
FUNCTION downloadURL(raw_url: String) → DownloadResult
1. Validate raw_url is an absolute URL with http(s) scheme.
2. Rewrite URL: replace origin with base_url origin, preserve path+query+fragment.
3. Hop 1 — Authenticated API GET:
a. Set Authorization and User-Agent headers only (no Accept or Content-Type — this is a binary download, not a JSON API call).
3. Hop 1 — Authenticated API GET, wrapped in the hop-1 retry loop (below):
a. Set Authorization and User-Agent headers only (no Accept or Content-Type — this is a binary download, not a JSON API call). Every attempt is authenticated — re-run the auth strategy on retry so a rotated token is picked up.
b. Fetch with redirect: manual (do not follow redirects automatically).
c. If response is redirect (301, 302, 303, 307, 308):
c. If the attempt fails with a network error, or the response status is in DOWNLOAD_RETRY_ON = {429, 502, 503, 504}: retry with exponential backoff while attempts remain (honor Retry-After on 429), else surface the failure. 500 is DELIBERATELY outside the set — it is never retried.
d. If response is redirect (301, 302, 303, 307, 308):
- Extract Location header. ⊥ if absent.
- Resolve Location against rewritten URL (handle relative redirects).
- Proceed to Hop 2.
d. If response is 2xx:
e. If response is 2xx:
- Direct download (no second hop needed).
- → DownloadResult from response body.
e. If response is error → ⊥ BasecampError from response.
f. If response is any other error → ⊥ BasecampError from response, without retry.

4. Hop 2 — Unauthenticated fetch (signed URL):
a. Fetch Location URL with NO auth headers.
a. Fetch Location URL with NO auth headers. Hop 2 is NEVER retried and NEVER authenticated — the signed URL is single-purpose and credentials must not leak to the storage host.
b. If not 2xx → ⊥ BasecampError.
c. → DownloadResult from response body.
END
```

### Hop-1 Retry `[conformance]`

The authenticated first hop retries on **network errors plus {429, 502, 503, 504}** — never 500. The set is declared here rather than inherited from anywhere else, and it matches neither of the two sets an SDK already has to hand: it is broader than the per-operation `retry_on` in `behavior-model.json` (`{429, 503}` for all 238 operations, which never governs `DownloadURL` because it has no entry there), and narrower than the error taxonomy's "all 5xx retryable" flag, which would sweep in the 500 this policy deliberately excludes. It is the gateway-error set Go's hand-written `singleRequest` already uses for GETs. Backoff is exponential from a 1-second base with jitter; `Retry-After` is honored on 429. The second hop is exempt: no retry, no auth.

"Network error" means a transport failure, with one carve-out that SDKs inherit from their main GET loop rather than restate: an attempt that exhausted the caller's entire per-attempt time budget (a request timeout) is not retried. The timeout is per attempt, so a retry spends another full budget on the same slowness rather than riding out a blip. Kotlin implements this explicitly; SDKs whose transports surface timeouts indistinguishably from other connection failures retry them.

Attempt budget per SDK — disabling retry (each SDK's spelling of `enable_retry=false` or a zero cap) yields exactly ONE hop-1 attempt:

| SDK | Budget |
|-----|--------|
| Go | `MaxRetries` as total attempts (hand-written client rejects < 1) |
| Python | `max_retries` as total attempts, floored at one (`max_retries: 0` still sends one attempt) |
| Ruby | `max_retries` as total attempts, floored at one for downloads (`max_retries: 0` still sends one attempt; the general ungoverned GET path's zero-attempt behavior is tracked separately) |
| Kotlin | `maxRetries` as total attempts, floored at one, gated on `enableRetry`; an accepted `maxRetries = 0` still sends exactly one attempt |
| TypeScript | Fixed three-attempt policy when `enableRetry` is true; one attempt when false. No public numeric knob. |
| Swift | Fixed three-attempt policy when `enableRetry` is true; one attempt when false. No public numeric knob. |

Python and Ruby carve downloads out of their ungoverned GET taxonomy (which retries 500): the download hop uses the declared `{429, 502, 503, 504}` set, in both directions — the taxonomy neither widens nor vetoes it. `DownloadURL` is deliberately absent from `behavior-model.json`; SDKs pass this policy to their retry primitive directly rather than looking it up by operation.

### DownloadResult RECORD

```
Expand Down Expand Up @@ -1666,9 +1686,7 @@ 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`) — unwaivered:
- "DownloadURL retries on 503 at the auth'd first hop" — download path uses `get_no_retry`; hop-1 retry not implemented.
- "DownloadURL honors Retry-After on 429 at the auth'd first hop" — same as above.
**Python** (`conformance/runner/python/runner.py` `SKIPS`) — none.

**Ruby** (`conformance/runner/ruby/runner.rb` `RUBY_SKIPS`):
- "PUT operation is naturally idempotent" — GET-only retry (waiver 2B.3).
Expand All @@ -1682,19 +1700,13 @@ logic is covered by `TestIsSameOrigin` unit tests:
- "PrioritizeAssignment POST retries when marked idempotent" — GET-only retry (waiver 2B.3).
- "DeprioritizeAssignment DELETE retries when marked idempotent" — GET-only retry (waiver 2B.3).
- "Network error on an idempotent POST is retried then succeeds" — GET-only network retry (waiver 2B.3).
- "DownloadURL retries on 503 at the auth'd first hop" — download path uses `get_no_retry`; hop-1 retry not implemented (unwaivered).
- "DownloadURL honors Retry-After on 429 at the auth'd first hop" — same as above (unwaivered).

**TypeScript** (`conformance/runner/typescript/runner.test.ts` `TS_SDK_SKIPS`):
- "Large integer IDs preserved without precision loss" — `Number` is 53-bit (waiver 1B.6).
- "DownloadURL retries on 503 at the auth'd first hop" — `downloadURL` uses raw fetch bypassing retry (unwaivered).
- "DownloadURL honors Retry-After on 429 at the auth'd first hop" — same as above (unwaivered).

**Kotlin** (`kotlin/conformance/.../Main.kt` — `KOTLIN_SKIPS` plus one tag-based
branch ahead of it):
**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).
- "DownloadURL retries on 503 at the auth'd first hop" — Kotlin download hop 1 does not retry yet (B4) (unwaivered).
- "DownloadURL honors Retry-After on 429 at the auth'd first hop" — same as above (unwaivered).

The TypeScript live canary additionally reports one placeholder skip when
`BASECAMP_LIVE` is unset (`live-runner.test.ts`) — that is the opt-in gate for
Expand Down Expand Up @@ -1901,6 +1913,8 @@ account, attachments, automation, boosts, campfires, cardColumns, cardSteps, car
| `downloads.json` | DownloadURL auth'd first hop 302s to signed URL | §14 |
| `downloads.json` | DownloadURL direct 2xx body | §14 |
| `downloads.json` | DownloadURL retries on 503 at the auth'd first hop | §14, §7 |
| `downloads.json` | DownloadURL retries hop 1 on a network error | §14, §7 |
| `downloads.json` | DownloadURL does not retry hop 1 on 500 | §14, §7 |
| `downloads.json` | DownloadURL honors Retry-After on 429 at the auth'd first hop | §14, §7 |
| `downloads.json` | DownloadURL surfaces redirect with no Location | §14 |
| `network-retry.json` | Network error on a non-idempotent POST is not retried | §7 (Gate 2) |
Expand Down
12 changes: 10 additions & 2 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -943,11 +943,19 @@ func checkAssertion(
}

case "delayBetweenRequests":
// index selects a single inter-request GAP (gap i is between request i
// and i+1); it defaults to the first. A named gap that does not exist
Comment thread
jeremy marked this conversation as resolved.
// is a failure, not a silent pass — otherwise a dropped retry makes the
// assertion vanish instead of firing.
if len(requestTimes) >= 2 {
delay := requestTimes[1].Sub(requestTimes[0])
gap := assertionIndex(assertion)
if gap+1 >= len(requestTimes) {
Comment thread
jeremy marked this conversation as resolved.
return fail(tc, fmt.Sprintf("Expected a delay at gap %d, but only %d request(s) were made", gap, len(requestTimes)))
}
delay := requestTimes[gap+1].Sub(requestTimes[gap])
minDelay := time.Duration(assertion.Min) * time.Millisecond
if delay < minDelay {
return fail(tc, fmt.Sprintf("Expected delay >= %v, got %v", minDelay, delay))
return fail(tc, fmt.Sprintf("Expected delay >= %v at gap %d, got %v", minDelay, gap, delay))
}
}

Expand Down
27 changes: 16 additions & 11 deletions conformance/runner/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,22 @@ def _verify_assertions(self, *, result: Any, error: Exception | None) -> TestRes
failures.append(f"Expected {expected} requests, got {actual}")

case "delayBetweenRequests":
# Every inter-request gap must clear the minimum, unless the
# fixture names one: not all gaps are retry gaps — the
# download flow's final gap is the redirect hop to the
# signed URL, which is deliberately un-delayed — so those
# fixtures assert per-gap with an index.
delays = self._tracker.delays_between_requests
min_delay = assertion.get("min")
if min_delay and delays and any(d < min_delay for d in delays):
failures.append(f"Expected minimum delay of {min_delay}ms, got {min(delays)}ms")
index = assertion.get("index")
if min_delay and delays:
if index is not None:
if index < len(delays) and delays[index] < min_delay:
failures.append(
f"Expected minimum delay of {min_delay}ms at gap {index}, got {delays[index]}ms"
)
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
elif any(d < min_delay for d in delays):
failures.append(f"Expected minimum delay of {min_delay}ms, got {min(delays)}ms")

case "noError":
if error:
Expand Down Expand Up @@ -673,15 +685,8 @@ def _get_error_field(error: Exception, field_path: str) -> Any:


class ConformanceRunner:
_DOWNLOAD_RETRY_SKIP = "Python SDK download path uses get_no_retry; retry on 5xx / Retry-After is not implemented"
SKIPS: set[str] = {
"DownloadURL retries on 503 at the auth'd first hop",
"DownloadURL honors Retry-After on 429 at the auth'd first hop",
}
SKIP_REASONS: dict[str, str] = {
"DownloadURL retries on 503 at the auth'd first hop": _DOWNLOAD_RETRY_SKIP,
"DownloadURL honors Retry-After on 429 at the auth'd first hop": _DOWNLOAD_RETRY_SKIP,
}
SKIPS: set[str] = set()
SKIP_REASONS: dict[str, str] = {}

def __init__(self, tests_dir: str):
self._tests_dir = Path(tests_dir)
Expand Down
21 changes: 14 additions & 7 deletions conformance/runner/ruby/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,9 @@ def todo_write_kwargs(body)
"UpdateCalendar PUT retries when marked idempotent",
"PrioritizeAssignment POST retries when marked idempotent",
"DeprioritizeAssignment DELETE retries when marked idempotent",
"DownloadURL retries on 503 at the auth'd first hop",
"DownloadURL honors Retry-After on 429 at the auth'd first hop",
"Network error on an idempotent POST is retried then succeeds",
].freeze)

DOWNLOAD_RETRY_SKIP = "Ruby SDK download path uses http.get_no_retry; retry on 5xx / Retry-After is not implemented".freeze
RUBY_SKIP_REASONS = {
"PUT operation is naturally idempotent" => "Ruby SDK only retries GET",
"DELETE operation is naturally idempotent" => "Ruby SDK only retries GET",
Expand All @@ -310,8 +307,6 @@ def todo_write_kwargs(body)
"UpdateCalendar PUT retries when marked idempotent" => "Ruby SDK only retries GET",
"PrioritizeAssignment POST retries when marked idempotent" => "Ruby SDK only retries GET",
"DeprioritizeAssignment DELETE retries when marked idempotent" => "Ruby SDK only retries GET",
"DownloadURL retries on 503 at the auth'd first hop" => DOWNLOAD_RETRY_SKIP,
"DownloadURL honors Retry-After on 429 at the auth'd first hop" => DOWNLOAD_RETRY_SKIP,
"Network error on an idempotent POST is retried then succeeds" => "Ruby SDK only retries GET network errors; mutations go through single_request with no retry",
}.freeze

Expand Down Expand Up @@ -531,10 +526,22 @@ def verify_assertions(result:, error:)
end

when "delayBetweenRequests"
# Every inter-request gap must clear the minimum, unless the fixture
# names one: not all gaps are retry gaps — the download flow's final
# gap is the redirect hop to the signed URL, which is deliberately
# un-delayed — so those fixtures assert per-gap with an index.
delays = @tracker.delays_between_requests
min_delay = assertion["min"]
if min_delay && delays.any? { |d| d < min_delay }
failures << "Expected minimum delay of #{min_delay}ms, got #{delays.min}ms"
index = assertion["index"]
if min_delay && delays.any?
if index
gap = delays[index]
if gap && gap < min_delay
Comment thread
jeremy marked this conversation as resolved.
failures << "Expected minimum delay of #{min_delay}ms at gap #{index}, got #{gap}ms"
end
Comment thread
jeremy marked this conversation as resolved.
elsif delays.any? { |d| d < min_delay }
failures << "Expected minimum delay of #{min_delay}ms, got #{delays.min}ms"
end
end

when "noError"
Expand Down
23 changes: 16 additions & 7 deletions conformance/runner/typescript/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,6 @@ const TEST_ACCOUNT_ID = "999";
const TS_SDK_SKIPS: Record<string, string> = {
"Large integer IDs preserved without precision loss":
"JavaScript loses precision on integers > Number.MAX_SAFE_INTEGER (2^53)",
"DownloadURL retries on 503 at the auth'd first hop":
"TS SDK downloadURL uses raw fetch bypassing the retry middleware; 5xx / Retry-After retry is not implemented",
"DownloadURL honors Retry-After on 429 at the auth'd first hop":
"TS SDK downloadURL uses raw fetch bypassing the retry middleware; 5xx / Retry-After retry is not implemented",
};

/**
Expand Down Expand Up @@ -804,7 +800,15 @@ function checkAssertions(
case "delayBetweenRequests": {
const times = tracker.requestTimes();
if (times.length >= 2) {
const delay = times[1]! - times[0]!;
// index selects a single inter-request GAP (gap i is between request
// i and i+1), defaulting to the first. A named gap that does not
// exist fails rather than passing silently.
Comment thread
jeremy marked this conversation as resolved.
const gap = assertion.index ?? 0;
expect(
times.length,
`[${tc.name}] expected a delay at gap ${gap}, but only ${times.length} request(s) were made`,
).toBeGreaterThan(gap + 1);
const delay = times[gap + 1]! - times[gap]!;
const minDelay = assertion.min ?? 0;
// Node's timers may fire marginally BEFORE the requested delay —
// libuv rounds the deadline down internally, so a 2000ms sleep can
Expand All @@ -818,7 +822,7 @@ function checkAssertions(
// interval (no delay at all).
expect(
delay,
`[${tc.name}] expected delay >= ${minDelay}ms (allowing ${TIMER_SLACK_MS}ms timer slack), got ${delay}ms`,
`[${tc.name}] expected delay >= ${minDelay}ms at gap ${gap} (allowing ${TIMER_SLACK_MS}ms timer slack), got ${delay}ms`,
).toBeGreaterThanOrEqual(minDelay - TIMER_SLACK_MS);
}
break;
Expand Down Expand Up @@ -1205,11 +1209,16 @@ function shouldEnableRetry(tc: TestCase, filename: string): boolean {
if (
filename === "retry.json" ||
filename === "idempotency.json" ||
filename === "network-retry.json"
filename === "network-retry.json" ||
filename === "downloads.json"
) {
// network-retry.json's CreateTodo safety case must run retry-ENABLED so it
// actually proves the SDK doesn't re-send a non-idempotent POST on a network
// error (with retry off, the requestCount:1 assertion would be vacuous).
// downloads.json exercises the hop-1 retry policy (SPEC §14), and its
// no-retry cases (500, redirect-no-Location) stay single-attempt because
// those failures are outside the declared retry set, not because retry is
// disabled.
return true;
}

Expand Down
2 changes: 1 addition & 1 deletion conformance/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@
},
"index": {
"type": "integer",
"description": "Request index for per-request assertions: headerPresent / headerAbsent / headerInjected / requestPath / requestMethod / requestBody / requestBodyAbsent (0-based; negative values index from the end, e.g. -1 = last request). Default 0."
"description": "Request index for per-request assertions: headerPresent / headerAbsent / headerInjected / requestPath / requestMethod / requestBody / requestBodyAbsent (0-based; negative values index from the end, e.g. -1 = last request). Default 0. For delayBetweenRequests it selects a single inter-request GAP (gap i is between request i and i+1); omit it to require the minimum on every gap."
Comment thread
jeremy marked this conversation as resolved.
}
}
}
Expand Down
Loading
Loading