Skip to content

Pace uploads to the rate limit the wiki advertises - #89

Merged
alistair3149 merged 3 commits into
masterfrom
pace-uploads-to-the-advertised-rate-limit
Aug 21, 2026
Merged

Pace uploads to the rate limit the wiki advertises#89
alistair3149 merged 3 commits into
masterfrom
pace-uploads-to-the-advertised-rate-limit

Conversation

@alistair3149

@alistair3149 alistair3149 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Follows-up to #83

The upload queue learned the wiki's rate limit only by being refused by it. That left two problems.

The client could not wait longer than about two minutes in total, whatever the wiki's window was. The retry ladder ran 2, 4, 8, 16, 32, 60 seconds and then gave up, telling the user to wait a moment when the real wait might be hours. Re-selecting the files restarted the same two minutes, so on a wiki configuring a longer window — say $wgRateLimits['upload']['user'] = [ 100, 86400 ], a daily cap — a batch could not be finished at all.

When the gate reopened it released every waiting upload in the same tick, into a window that might have had fewer free slots than there were waiting files. Some were refused, and the delay doubled for a reason the client had caused.

meta=userinfo&uiprop=ratelimits gives the hits and seconds that will actually be enforced. Releases are now spaced to the advertised rate, and the retry ladder is capped by the advertised window.

Spacing the attempts is what carries a file past a long window, not the cap. A single wait already tops out at about 64 seconds, so the derived cap only ever binds on a wiki that refills faster than that — against a daily window it moves the total from 122 to 126 seconds. A wiki with a genuinely long window still stops eventually and asks for the remaining files again, but after far longer and with an honest message: the give-up text no longer claims the user need only wait a moment.

Pacing starts only after a refusal, and lapses after a quiet window

A batch that fits inside the budget is never refused, so it is never slowed down — bursting is what makes the ordinary case fast. Making that the mechanism rather than a special case means the client never has to predict whether it will exceed the budget.

Pacing also has to switch back off, or one refusal at the tail of a batch would slow every later selection for as long as the tab stayed open. It lapses once a full window has passed with nothing refused, on the grounds that whatever was exhausted has refilled. Clearing it on a new selection instead would be wrong: that is the path where pacing is most likely to be needed.

Spacing is not serialising. Releases are handed out one per interval, but up to four uploads still overlap when a transfer takes longer than the interval, so concurrency adapts to whichever constraint binds.

Measured against a live wiki at 4 uploads per 20 seconds:

Case Result
3 files, fits the budget 3/3 in 2.0s, not paced
12 files, exceeds it 12/12 in 73.3s, nothing failed
12 files, user holding noratelimit 12/12 in 4.5s, never paced

Picking the limit that binds

An upload is charged against both the edit and the upload limiter (UploadBase::verifyTitlePermissions), and a user can fall under several categories of one action at once — a registered newbie is charged as ip, newbie and user together. So bindingLimit() takes the most restrictive across every category of both actions, comparing sustained rate rather than count: 100 per day is tighter than 8 per minute despite the larger number.

Two guards, both tested. An empty limits object means the user holds noratelimit — unlimited, not zero — so nothing is paced. A malformed or zero bucket is ignored rather than pacing on a NaN.

Cost

The limits query is one request per page load, not per batch, and read modules are not rate limited: ApiMain::checkExecutePermissions() never pings the limiter, and neither ApiQuery, ApiQueryUserInfo nor ApiQueryTokens contains a pingLimiter call. Checked live: 80 read queries produced zero refusals, and four uploads immediately afterwards all succeeded against a budget of four per sixty seconds. The CSRF token costs nothing either: mw.Api seeds its cache from mw.user.tokens, so a four file batch makes exactly four API calls, one POST per file.

The query is deliberately not awaited before the widget is wired, so the button works the moment the page is ready. The gate adopts the limit when it arrives, and pacing cannot begin before then anyway.

Considered, omitted

  • Shipping the limit in mw.config via MakeGlobalVariablesScript instead of querying for it. It would save a request that costs nothing, at the price of bytes on every page view where the module loads and a second implementation of the category selection in PHP.
  • Jitter on the backoff. The gate spaces releases now, which is the actual fix for the reopen burst; jitter on a shared timer moves a burst rather than spreading it.
  • Ramping concurrency up after a refusal. That is guidance for capacity-based backends whose partitions warm up; a fixed-window counter does not, so ramping earns no extra quota.

AI-authored — Claude Code, Opus 5 1M (ultracode); asked to pick up the three follow-ups recorded in #83's "considered, omitted"; diff not yet human-reviewed; 79 unit tests plus PHPCS/PHPStan/PHPUnit green, and the behaviour checked end to end in a browser against a live wiki.

alistair3149 and others added 3 commits August 21, 2026 09:41
The gate learned about the limit only by being refused by it, which left
two problems.

The backoff cap was the constant 60 seconds, which is right only because
$wgRateLimits happens to default to a 60 second window. A wiki with a
daily cap made the window unreachable: the batch climbed 2, 4, 8, 16, 32,
60 seconds, gave up after 122, and told the user to wait a moment when
the real wait was hours. Re-selecting the files restarted the same
122 seconds, so the extension was unusable past such a cap.

And when the gate reopened it released every waiting upload in the same
tick, into a window that may have had fewer free slots than there were
waiting files. Some were refused and the delay doubled for a reason the
client had caused.

Both come from not reading what the wiki already publishes.
meta=userinfo&uiprop=ratelimits gives the hits and seconds that will be
enforced, so the cap is now the advertised window and releases are spaced
to the advertised rate.

Pacing starts only after the wiki has refused something. A batch that
fits inside the budget is never refused and so is never slowed down:
verified against a live wiki at 4 uploads per 20 seconds, three files
still finish in 2 seconds while twelve now complete in 73 with nothing
failed. A user holding noratelimit gets an empty limits object, which
means unlimited rather than zero, and is never paced: twelve files in
4.5 seconds.

The query is deliberately not awaited before the widget is wired, so the
button works the moment the page is ready; the gate adopts the limit when
it arrives, and pacing cannot begin before then anyway.

The give-up message no longer claims the user only has to wait a moment,
since the window is configurable and can be a day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pacing was switched on by the first refusal and never switched off, so a
single refusal at the tail of one batch slowed every later selection on
that page for as long as the tab stayed open -- including batches small
enough to fit the budget comfortably. Measured at 8 uploads per 60
seconds, a five file batch that fitted was given 30 seconds of pacing it
did not need. That contradicts the rule the pacing was built around:
a batch that fits must not be slowed down.

Pacing now lapses once a full window has passed with nothing refused, on
the grounds that whatever was exhausted has refilled by then. Clearing it
on a new selection instead would be wrong, because that is the path where
pacing is most likely to be needed.

Also moves the reading of the API response into rateLimits.js, where it
is covered by a test against a real response body. It was the one part of
the feature with no test and it failed silently: a wrong path degraded to
"this user is not limited", which is indistinguishable from the real
thing. A failed query is now logged rather than swallowed.

Corrects an overstatement while here. The derived cap only ever binds
below about 64 seconds, because the retry ladder tops out there, so it
shortens the wait for a wiki that refills quickly rather than lengthening
it for one that refills slowly. Spacing the attempts is what carries a
file past a long window. The docblock, the test that claimed to cover it
and the release note all said otherwise; the test asserted on a pure
function and exercised nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bullet described the mechanism at length where the reader only needs
the outcome: batches on wikis with a long limit window survive longer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alistair3149

Copy link
Copy Markdown
Member Author
simplebatchupload-pacing.mp4

@alistair3149
alistair3149 marked this pull request as ready for review August 21, 2026 14:36
@alistair3149
alistair3149 merged commit 46bb124 into master Aug 21, 2026
22 checks passed
@alistair3149
alistair3149 deleted the pace-uploads-to-the-advertised-rate-limit branch August 21, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant