Skip to content

6.28.0 - One origin request budget, and a 429 that is a not-yet

Choose a tag to compare

@superuser404notfound superuser404notfound released this 17 Aug 03:00
· 662 commits to main since this release

Drop-in from 6.27.1. Two additive API surfaces, and one behaviour change that only a source being rate limited can reach. Both come out of #377, where a reporter's origin meters requests and the engine kept reading its refusals as a death.

Half of what the reader knew never left the reader

AVIOReader.isRateLimitStatus has existed for a while and is used in seven places, every one of them inside AVIOReader.swift. Sources/AetherEngine/Video/ contained no reference to rate limiting at all. The classification died at the give-up arm, which returns a bare -1: FFmpeg renders that as "Operation not permitted", and the session's revive arm saw exactly what a source that is genuinely gone produces.

So a metered source spent both attempts of a two-attempt budget inside a minute, each one reopening from byte 0 against an origin refusing precisely that, and then declared the source "not readable in this session". The counter-evidence was in the same report: back out, press play, it starts immediately. The verdict was not merely unhelpful, it was false.

A 429 is a not-yet. It now takes its own larger budget with a growing backoff (3 s, 8 s, 20 s, 45 s) rather than an immediate reopen, and the terminal surface is a new kind:

if info.kind == .sourceRateLimited {
    // the source is being metered, not lost. The same request works later,
    // and a handoff to a second player meets the same refusal.
}

That last clause is the reason this is its own kind rather than a message: the reporter's host is dual-engine, and its fallback engine asked the same origin and was refused identically.

Four connection caps that never composed into one

httpMaximumConnectionsPerHost is a per-URLSession cap, and the reader fetches over four pools: the pump's ranges (2), detour blocks (2), size probes on URLSessionConfiguration.default (6), plus a per-call streaming session. The subtitle side reader shares the same static pools. Against one signed CDN URL a pump range, a detour block and a probe could all be open at once, and nothing anywhere held the sum.

The cap also measures the wrong thing, which is the part that cannot be established from outside the engine: over HTTP/2 a URLSession multiplexes every request of a session onto a single connection, so a cap of 1 there bounds nothing while the origin still counts every request. URLSessionTaskMetrics.networkProtocolName was read nowhere in the tree. It now logs one line per origin saying which case that origin is.

OriginRequestBudget counts requests per origin, keyed on scheme+host+port so a rotating signed token shares one ceiling rather than starting a fresh one per refresh. Counting is unconditional; capping is not. With no limit set nothing waits and a healthy origin behaves exactly as before. A limit arrives either from the host or from the origin itself, halving from the concurrency actually reached on each refusal:

try await engine.load(url: url, options: LoadOptions(
    maxConcurrentSourceRequests: 1   // provider documents one connection per link
))

Deadlock is excluded structurally rather than managed. At one slot the speculative parallel paths (detour blocks, the tail prefetch) switch off instead of queueing, and each already had a serial fallback: the detour's is repositioning the persistent connection, the probe fan's is running in order. Nothing ever blocks on a slot its own caller holds, and no acquire can park a read indefinitely.

Measured against an origin that meters

A test origin with real Range support that answers 429 above one in-flight request, playing one file:

run peak concurrency the origin saw 429s requests served
budget learns from the refusals 2 2 3, session survives
maxConcurrentSourceRequests: 1 1 0 1

The measurement also named the request nobody suspected. The tail prefetch leaves microseconds before the first data connection, so a metered origin sees two requests on the very first open, and it is the pump that gets refused. That is the "429 not long after first opening a file, long before any real number of requests" from the report. It is speculative and nobody waits on it, so it now takes a slot only if one is free, and it counts when it does; while it did not, the budget reported a peak of 1 for an origin that had just seen 2.

A refusal is also recorded against the source URL, not only the URL that produced it. A metered source is routinely a proxy that 302s to a CDN: the CDN refuses, the engine's revive arm only ever knows the URL the host loaded, and keying the verdict solely on the refusing host would have built a classification that is never once reached.

Also in this release

The slow read: summary carries origin=<n>inflight/<peak>peak limit=<n>, so the concurrency a metered origin was reacting to is in the line a field report already sends. aetherctl play --max-concurrent-requests N reproduces a connection-capped origin.

Not claimed

The budget throttles what this process asks of an origin; it cannot know what that origin actually permits, and no automatic increase ever raises a learned limit back. Whether request count, concurrency or bandwidth is what a given provider meters is still the provider's business: this makes the concurrency observable and bounded, and names the transport, so the next trace can answer it rather than repeat the question.

Reported by Rasmusmart57 (#377).