feat(remote): serve taxonomy, transport marker, and upstream doer adapter (S13 Step 11, part 1/3)

📦 What this MR does

Part 1 of 3 of S13 virtual/remote foundation — Step 11: standalone remote request.

This step is split into 3 stacked MRs to keep each within the review size limit (~800 reviewable LoC). Each part targets the previous one (part 1 targets main); review and merge proceed bottom-up.

This part lands the shared foundations the standalone flow (part 2/3) composes:

  • ServeResult/ServeSource (serve.go, new): the serve-outcome taxonomy for the standalone flow (the Step 12 resolver ships its own result type in internal/virtual — see !1286 (merged)/!1287 (merged)), with SafeHeaders sharing FetchResult's fail-closed allowlist through the extracted safeHeadersFrom. ServeSource gives the cache-fallback serve its own member, ServeFromCacheDegraded: it shares every ServeResult field rule with ServeFromCache and differs only in the claim, so a log or metric keyed on the source separates a working cache from a failing upstream, and a handler can branch on it for a staleness header. Nothing in this part sets it — part 2's Standalone.Serve will, on its serveOnFetchError path.
  • errUpstreamTransport (fetch.go): Fetch marks every failure of an issued upstream GET with a transport sentinel, at two sites — fetchOnce's post-send wrap, and cacheFillTee's discard site for a mid-transfer body read, which the wrap site cannot reach because streamAndFill has already returned. Cache-fallback eligibility is therefore positive and fail-closed: fallbackEligible (with its 21-row partition test) requires the marker, and a local error introduced anywhere else — a staging-write failure included — can never become fallback-eligible by omission. Because the second site marks every non-EOF body read, the two read-time body caps arrive marked and are excluded by explicit predicate instead: a cap breach is a deterministic, operator-owned rejection, and every retry breaches the same cap, so answering it from cache would pin clients to an aging copy and hide the misconfiguration. A Fetch-level test drives a body past a cap and asserts marker, sentinel transparency and ineligibility together, since the marking and the predicate live in different files. Fetch now also releases a partial response a doer returns alongside its error.
  • followerLeaderError (singleflight.go): a coalesced follower classifies for cache fallback exactly as its leader did, and the follower-facing wrap carries errUpstreamTransport exactly as far as that symmetry requires. A dial failure or connection reset travels transparently, marker and sentinels intact, rather than reaching followers labelled as a cancellation. The two context errors are opposite cases and are handled as such: a marked context.Canceled is unambiguously someone hanging up, which fallbackEligible excludes for the leader, so the marker is dropped rather than forwarded — carrying it across left every follower fallback-eligible while the leader correctly hard-failed, serving stale bytes against a reachable upstream. A marked context.DeadlineExceeded cannot be told from the request_total_timeout the upstream client applies to an unresponsive upstream, so it keeps the marker and drops the ErrLeaderCanceled claim, whose contract promises the outcome was not an upstream or transport failure and invites re-issuing the Fetch.
  • logDegradedInfra (health.go): the shared degrade-not-abort logging helper (DEBUG on the caller's own cancellation, WARN otherwise), refactored out of the health monitor's counter-error logging for the standalone health gate to reuse once part 2 lands.
  • upstreamhttp.Doer (new): the first production remote.UpstreamDoer adapter over the security-hardened upstreamhttp.Client, bridging maxBodyBytes to WithMaxBody.
  • remotetest.AssertUpstreamDoerConformance (new): the shared seam-conformance assertion proving the two UpstreamDoer promises (nil error carries a non-nil body; the caller's cancellation stays errors.Is-visible through a real context.CancelFunc), plus remotetest.LoopbackClientConfig as the one shared loopback client test policy (the upstreamhttp client tests now delegate to it).
  • no-remotetest-in-prod (.golangci.yaml): a depguard rule keeping a non-test file from importing internal/remote/remotetest, whose loopback carve-out and testing dependency must not reach a binary, with a negative-control probe in internal/jobs and an entry in the depguard:negative-control assertion loop.

📖 References

📐 Plan-files divergence

The plan's Step 11 Files list is kept current in-tree and now enumerates all twenty-three files this branch touches; the divergence paragraph beneath it records why the list grew from the five paths first planned. In short: the serve-outcome taxonomy split out of standalone.go, the safeHeadersFrom/logDegradedInfra/errUpstreamTransport extractions are shared seams the review rounds settled on, the shared loopback config moved out of client_test.go, and review added the follower-error correction, the no-remotetest-in-prod rule with its probe and CI assertion, and a CLAUDE.md convention.

Work item 269

The adapter ships over Client.Do as-is: the unconditional request_total_timeout bounds the whole fetch, including a client-paced fill (document-and-defer, recorded in the Doer doc comment). Ratifying that bound is not a closure of the underlying problem — the progress-based read deadline stays open on work item 269.

🧪 E2E scenario catalog

No scenario added or affected: this is service-layer composition with no HTTP endpoint. The format handlers that expose the standalone flow will add the client-visible scenarios.

:bricks: Stacked MRs (review/merge bottom-up)

Related to #332 (closed)

Edited by David Fernandez

Merge request reports

Loading
Loading