Reconcile the Retry-After rule for a relayed upstream status across S13, S15, S16 and S31

Problem

Two Retry-After claims in the merged specs need one amendment across four documents. Both surfaced in review of !1678 (merged), which adds remote.RetryAfterSeconds and deliberately states no answer for either.

1. A 503 that relays an upstream status has no agreed Retry-After

The merged documents disagree:

  • docs/specs/S13-virtual-remote-foundation.md:431 says a slice relaying a status the upstream itself produced relays that response's own Retry-After where it carried one, and that the fixed value covers only the responses the three named verdicts produce.
  • docs/specs/S31-npm-virtual.md:957 and :982 repeat that for the WinnerFetchFailed row and add that the fixed health-interval value is not added. The Resolutions bullet at :1201-1211 gives the reason: the value is a claim about when an upstream's health status can next change, so a 503 reporting no upstream condition has no business quoting it.
  • docs/specs/S15-npm-remote.md:753 requires Retry-After on every 503 upstream_unavailable. The upstream-5xx row at :748 propagates a non-500 upstream 5xx with the upstream_unavailable code, so a propagated upstream 503 is one of those responses.
  • docs/specs/S16-container-remote.md:712 and :941 require the header on every 503 UNAVAILABLE, a propagated or remapped upstream 5xx included, and say this handler supplies its own value where the upstream sent none.

Relaying the upstream's own value is not possible today. forwardableHeaders in internal/remote/operations.go is a closed allowlist of Content-Type, ETag, Cache-Control and Last-Modified, so FetchResult.SafeHeaders and ServeResult.SafeHeaders never expose an upstream Retry-After. docs/dev/api-style.md:54 says the same thing from the other side: propagated responses carry no upstream headers.

A renderer for a relayed 503 therefore has no value it can send that satisfies all four documents.

2. The reason the fixed value rests on is broader than what holds

docs/specs/S13-virtual-remote-foundation.md:428 and :881, docs/specs/S31-npm-virtual.md:977-980 and :1210-1211, and the Proposal in #609 (closed) all say the sweep interval is the earliest point at which the status the client is retrying against can change. The claim that holds is narrower: a client honoring the header retries no sooner than the next sweep is due. The next sweep is due at most one interval away, a manual probe changes a status on demand, and the UpstreamUnavailable verdict answers a live transport failure rather than a stored status.

The two S31 passages carry weight, because that reason is S31's stated argument for refusing the value on the WinnerFetchFailed row. Narrowing it weakens the argument, which is why the two items belong in one amendment rather than two.

Proposal

One spec MR amending docs/specs/S13-virtual-remote-foundation.md, docs/specs/S15-npm-remote.md, docs/specs/S16-container-remote.md and docs/specs/S31-npm-virtual.md together: decide item 1, and correct item 2 in the four passages it touches.

Item 1 needs a design decision first. Two shapes it could take:

  • Widen the relay allowlist so an error response can carry the upstream's own Retry-After, and keep S31's rule that nothing is added where the upstream sent none. S15 and S16 then say what a relayed 503 without an upstream value carries, instead of requiring a value with no source.
  • Keep the allowlist closed and fix a value for a relayed 503 that is not the health-sweep interval, so no response quotes a health claim it is not in a position to make.

Scope

  1. The design decision on item 1.
  2. One spec MR across the four documents, covering both items.

Notes

Nothing in the service reads either claim yet. remote.RetryAfterSeconds has no caller while the slice and fill-handler MRs that render these 503s are open; the first of those to merge is the point where the decision becomes visible to a client, so the amendment is worth landing before it.

The Retry-After value for the three verdicts remote.RetryAfterSeconds covers is settled and is not in question here: !1502 (merged) fixed it in S13, and !1678 (merged) derives it.

Related to #609 (closed) and !1678 (merged).