chore(npm): shed a publish the upload-session cap refused (upload-session-concurrency-cap plan: 4/12)
What this does
Renders storage.ErrUploadSessionsBusy as 503 upload_session_capacity_exceeded
on the npm publish route, with a jittered Retry-After and npm's own error
envelope. Step 4 of the
upload-session concurrency cap plan.
Nothing calls uploadAdmissionGate.Acquire yet, so the arm is unreachable in
production until Step 11 wires the acquire. What ships here is the mapping,
its code and safe message, the code budget slot, and the tests.
Merge order
The two MRs this one originally waited on have merged. One predecessor is still open, !2507 (merged), for the reason its row gives.
| Order | MR | Why it had to be first |
|---|---|---|
| 1 | !2464 (merged), merged 2026-09-10 10:54 UTC | Gives upload_session_capacity_exceeded its S11 Error Cases row. Ahead of it this MR renders a client-visible code no merged spec names. Enforced as a merge-request dependency, not just stated. |
| 2 | !2460 (merged), merged 2026-09-10 09:46 UTC | Pins the publish response write deadline at ceilingAt inside server.NewDeadlineReader. net/http arms the write deadline once at the header read from server.timeouts.write (10s), and this shed fires only after the admission gate spends its whole wait budget, so without !2460 (merged) the 503 and its Retry-After cannot be written and the client gets a teardown instead. |
| 3 | !2507 (merged), open | This MR rewrites inlineBuildTimeoutRetryAfter's doc in internal/format/npm/inline_build.go from a worker's lifetime to the render budget. Maven's own uploadShedRetryAfterBase comment restates the old framing (internal/format/maven/upload.go:315-317 at main's ccd1fe9cb), and !2507 (merged)'s head commit b1644eeec drops that restatement and points at internal/format/npm/packument_cache.md instead. Merged after this MR, main carries a Maven comment claiming the npm doc says something it no longer says. Stated rather than enforced as a dependency: the two MRs share no file, so neither the conflict scan below nor any pipeline reports the order. |
| 4 | this MR | the rendering, the code, the safe message, the budget slot |
The plan's own bookkeeping has also landed and is owed nowhere here: !2472 (merged)
recorded this MR's Status-table row (| 4 | npm publish shed arm | !2465 |)
from the standing table branch, and !2466 (merged) corrected Step 4's Files list.
docs/dev/conventions.md gives that table a single writer, so this MR touches
it nowhere.
This MR adds no write-deadline arm of its own.
TestPublishDeadline_WriteHalfIsPinnedAtTheCeiling, on main since !2460 (merged),
asserts exactly one write arm, so a second one here would redden it.
A new code rather than reusing CodeServiceUnavailable
Step 5 makes the opposite call for Maven and says so ("no new Code value is
added"), so this deviation is recorded rather than left implicit. A new code
is taken here because CodeServiceUnavailable is npm's authorization
fail-closed 503, written by DenialRenderer.WriteUnavailable from outside
the instrument seam and holding no requestCodeLabels slot. Reusing it would
have needed its own budget edit and would have merged a load shed with an
authorization failure under one label, which is the distinction an operator
reads this metric for. The cost is the code budget move and the S11 row in
!2464 (merged).
The code budget
Re-derived at 36fca155e: requestCodeLabels() holds 41 entries and
closedSetValues["code"] holds 41 keys. The Error Cases table in main's
S11 holds 38 distinct codes, and that set is exactly requestCodeLabels()
minus success and the two S15 remote codes, so 1 + 38 + 2 = 41.
metrics.md stated that decomposition as a condition and its event while
!2464 (merged) was open. Now that it has merged, the conditional clauses are gone and
the decomposition is stated outright; the total was 41 on both sides of that
merge and needed no edit.
This branch is now rebased onto main, so the 38 above is counted on the
branch's own copy of the S11 spec, which carries !2464 (merged)'s row and matches
main's.
Acceptance
| Criterion | Test |
|---|---|
The sentinel answers 503 with npm's envelope, its code, its safe message, Cache-Control: no-store, and a Retry-After |
TestPublishStage_SessionOpenFailureRendering |
Any other session-open failure on a live request context keeps its 500 |
same test, second row; an arm keyed on any session-open failure passes the first row and fails this one |
| No session opens and no row commits | same test, both rows |
Retry-After is whole seconds inside the jitter window, asserted as bounds |
publishShedRetryAfter, used by all three cases |
| A fleet shed in one instant is not told to return at one second | TestPublishStage_UploadSessionShedJittersItsRetryAfter, 24 draws over 15 possible values |
| The window is drawn around the base rather than upward from it, which S11 specifies as 35 seconds plus or minus 20% | same test; it counts the renders below uploadShedRetryAfterBase and requires the count to be positive, since the [28, 42] bounds admit a one-sided 35-to-42 draw on their own |
The shed mints a npm_request_total series under its own code |
TestPublishStage_UploadSessionShedCodeIsBudgeted |
| The shed's one attributed record is the completion line, at Error, joinable to a tenant | TestPublishStage_UploadSessionShedRecordsItsOutcomeAtError |
isServerErrorCode's membership cannot drift |
TestIsServerErrorCode_MembershipDoesNotDrift |
isServerErrorCode gains this code, so a shed logs at Error like the two
inline-build 503s beside it. inline_build_superseded is still the one
503 in requestCodeLabels it excludes; that predates this branch, the new
assertion names it so the gap reads as known, and changing it would move a
second code's level in an MR reviewed for something else. metrics.md points
that residual at #1274, which reports the same code missing from
s11ErrorCaseCodes.
Rebased onto main
Rebased at the operator's call while settling a remember: thread, which
needed a base carrying main's ac3e84dc4. The rebase is the only thing it
changed: the branch's net diff before and after is identical apart from blob
hashes and hunk offsets. Two commits that wrote a convention into the
pre-restructure AGENTS.md and then reverted it are gone from the history
rather than replayed, since they cancelled out and conflicted on that
restructure.
This MR changes no AGENTS.md. The shared-home convention that thread
asks for is recorded nowhere by this MR and needs its own main-based MR;
the thread says so. The pre-rebase head is kept at
dmeshcharakou/upload-session-concurrency-cap-step-4-prerebase.
Diff size
462 reviewable LOC (427 added, 35 removed) across 12 files, under the
500-LOC ceiling. Split: production Go 88, tests 302, docs 72. The test share
is the acceptance table above; publish_internal_test.go carries 255 of it.
Re-derived at ba858b8fd against the merge base 1df2de910.
Conflict scan
Re-derived at 36fca155e against the 64 open MRs (63 others), all head refs
fetched, 0 unchecked. Fourteen share a file with this branch:
- Six merge cleanly: !2502 (merged), !2496 (merged), !2495 (merged), !2482 (merged), !2480 (merged) and !2463 (merged), sharing
docs/dev/observability.md,internal/format/npm/metrics.md,internal/format/npm/metrics.go,internal/format/npm/metrics_test.goandinternal/metrics/cardinality.gobetween them. - Eight conflict, and every one of the eight conflicts with
mainitself, so each needs a rebase whatever this branch does and nothing is owed here. !2503 (merged), !2500 (merged), !2492 (merged), !2491 (merged), !2490 (merged), !2485 (merged) and !2052 are each 70 commits behindmainand share onlyinternal/format/npm/metrics.go; !1598 is 1972 behind and sharesdocs/dev/observability.md. - The other 49 open MRs share no file with this branch.
Each of those seven merged cleanly against this branch before its rebase,
and the change is a property of the merge base rather than of this diff: with
the branch 84 commits behind, each pairwise merge base predated main's own
edit to internal/format/npm/metrics.go, so the conflict they already had
with main did not surface in the pair. Rebasing moved the base forward and
made it visible. !1598 conflicted on both sides of the rebase.
Notes
- No e2e scenario is added or affected. The arm cannot be reached until Step 11
wires the acquire, so no harness can drive it, and
docs/testing/e2e/npm.mdhas no row a503from a process-wide cap would change. - No configuration surface changes, so
docs/dev/configuration-reference.mdandconfig.example.yamlare untouched. The cap's own keys land in Step 1. - No route is added or removed, so
api/openapi/**andapi/bruno/**need no change; those cover the management API rather than the npm client routes. - The branch's earlier commit bodies describe intermediate states. This MR
squashes on merge, so only this description reaches
main.
Related to #1234 (closed)