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:

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.md has no row a 503 from a process-wide cap would change.
  • No configuration surface changes, so docs/dev/configuration-reference.md and config.example.yaml are untouched. The cap's own keys land in Step 1.
  • No route is added or removed, so api/openapi/** and api/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)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading