Skip to content

Commit b453f87

Browse files
committed
fix(test): harden WPT downloader against rate limits
Teach `Web.Platform.Test.load_json!/1` to retry transient WPT download failures instead of failing immediately on rate limits or short-lived transport errors. Add bounded retry handling for HTTP 403, 408, 425, 429, and 5xx responses, plus transient network failures such as timeouts, connection resets, aborted requests, and DNS/connect errors. Honor `retry-after` and GitHub-style `x-ratelimit-reset` headers, fall back to exponential backoff when those headers are missing, and cap both per-request timeout and total retry budget so stalled bodies fail promptly. Route remote fetches through a shared persistent `CountingGovernor` so concurrent test runs do not stampede the WPT source, while still preserving cached-response fallback when fresh downloads cannot be completed. Add focused tests covering 429 retry handling, transient socket failures, the shared five-request governor cap, and bounded failure behavior for stalled response bodies.
1 parent 0594321 commit b453f87

2 files changed

Lines changed: 620 additions & 20 deletions

File tree

test/support/wpt.ex

Lines changed: 274 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule Web.Platform.Test do
22
@moduledoc false
33

44
use Web
5+
@on_load :ensure_fetch_governor
56

67
alias Web.AsyncContext.Variable
78
alias Web.Platform.Test, as: PlatformTest
@@ -15,9 +16,23 @@ defmodule Web.Platform.Test do
1516
# file (e.g., urlpatterntestdata.json) caused the error without that
1617
# URL being passed through every single function.
1718
@context_key {__MODULE__, :current_url}
19+
@governor_key {__MODULE__, :fetch_governor}
20+
@governor_lock_key {__MODULE__, :fetch_governor_lock}
21+
@default_governor_capacity 5
22+
@default_max_fetch_attempts 6
23+
@default_fetch_timeout_ms 30_000
24+
@default_max_total_fetch_ms 60_000
25+
@default_base_backoff_ms 500
26+
@default_max_backoff_ms 10_000
27+
@default_max_retry_after_ms 30_000
1828

1929
@callback web_platform_test(any()) :: any()
2030

31+
def ensure_fetch_governor do
32+
_ = fetch_governor()
33+
:ok
34+
end
35+
2136
defmacro __using__(opts) do
2237
opts = normalize_use_options!(opts)
2338
urls = Keyword.fetch!(opts, :urls)
@@ -74,17 +89,75 @@ defmodule Web.Platform.Test do
7489

7590
{cache_path, meta_path} = cache_paths(url)
7691
meta = read_meta(meta_path)
92+
started_at = System.monotonic_time(:millisecond)
7793

7894
Variable.run(fetch_context_variable(), url, fn ->
79-
fetch_with_cache(url, cache_path, meta_path, meta)
95+
fetch_governor()
96+
|> Governor.with(fn ->
97+
fetch_with_cache(url, cache_path, meta_path, meta, started_at)
98+
end)
99+
|> Web.await()
80100
end)
81101
end
82102

83-
defp fetch_with_cache(url, cache_path, meta_path, meta) do
84-
headers = conditional_headers(meta)
103+
defp fetch_with_cache(url, cache_path, meta_path, meta, started_at) do
104+
meta
105+
|> conditional_headers()
106+
|> fetch_with_retry(url, started_at)
107+
|> handle_fetch_response(url, cache_path, meta_path)
108+
rescue
109+
error ->
110+
fallback_to_cache_or_raise(cache_path, url, Exception.message(error))
111+
catch
112+
:exit, reason ->
113+
fallback_to_cache_or_raise(
114+
cache_path,
115+
url,
116+
"Network error fetching WPT data: #{inspect(reason)}"
117+
)
85118

86-
response = Web.await(Web.fetch(url, headers: headers))
119+
kind, reason ->
120+
fallback_to_cache_or_raise(
121+
cache_path,
122+
url,
123+
"Fetch failure (#{kind}) fetching WPT data: #{inspect(reason)}"
124+
)
125+
end
126+
127+
defp fetch_with_retry(headers, url, started_at, attempt \\ 1)
128+
129+
defp fetch_with_retry(headers, url, started_at, attempt) do
130+
signal = fetch_timeout_signal(started_at)
131+
response = Web.await(Web.fetch(url, headers: headers, signal: signal))
132+
133+
if retryable_fetch_status?(response.status) and attempt < max_fetch_attempts() do
134+
discard_response_body(response)
135+
136+
started_at
137+
|> remaining_budget_ms()
138+
|> min(retry_delay_ms(response, attempt))
139+
|> sleep_if_needed()
140+
141+
fetch_with_retry(headers, url, started_at, attempt + 1)
142+
else
143+
response
144+
end
145+
catch
146+
:exit, reason ->
147+
if retryable_fetch_error?(reason) and attempt < max_fetch_attempts() and
148+
remaining_budget_ms(started_at) > 0 do
149+
started_at
150+
|> remaining_budget_ms()
151+
|> min(backoff_delay_ms(attempt))
152+
|> sleep_if_needed()
153+
154+
fetch_with_retry(headers, url, started_at, attempt + 1)
155+
else
156+
exit(reason)
157+
end
158+
end
87159

160+
defp handle_fetch_response(response, url, cache_path, meta_path) do
88161
case response.status do
89162
200 ->
90163
persist_fresh_response(response, cache_path, meta_path)
@@ -93,32 +166,159 @@ defmodule Web.Platform.Test do
93166
read_cached_json!(cache_path, url)
94167

95168
status when status in 400..599 ->
96-
fallback_to_cache_or_raise(cache_path, url, "HTTP #{status} fetching WPT data")
169+
discard_response_body(response)
170+
171+
reason =
172+
if retryable_fetch_status?(status) do
173+
"HTTP #{status} fetching WPT data after #{max_fetch_attempts()} attempts"
174+
else
175+
"HTTP #{status} fetching WPT data"
176+
end
177+
178+
fallback_to_cache_or_raise(cache_path, url, reason)
97179

98180
status ->
181+
discard_response_body(response)
182+
99183
fallback_to_cache_or_raise(
100184
cache_path,
101185
url,
102186
"Unexpected HTTP #{status} fetching WPT data"
103187
)
104188
end
189+
end
190+
191+
defp retry_delay_ms(response, attempt) do
192+
header_delay =
193+
[retry_after_delay_ms(response), rate_limit_reset_delay_ms(response)]
194+
|> Enum.reject(&is_nil/1)
195+
|> Enum.max(fn -> 0 end)
196+
197+
max(header_delay, backoff_delay_ms(attempt))
198+
end
199+
200+
defp retry_after_delay_ms(response) do
201+
case Web.Headers.get(response.headers, "retry-after") do
202+
nil ->
203+
nil
204+
205+
value ->
206+
retry_after = value |> to_string() |> String.trim()
207+
208+
case Integer.parse(retry_after) do
209+
{seconds, ""} when seconds >= 0 -> min(seconds * 1000, max_retry_after_ms())
210+
_ -> retry_after_http_date_delay_ms(retry_after)
211+
end
212+
end
213+
end
214+
215+
defp retry_after_http_date_delay_ms(retry_after) do
216+
case :httpd_util.convert_request_date(String.to_charlist(retry_after)) do
217+
{{year, month, day}, {hour, minute, second}} ->
218+
retry_at =
219+
DateTime.new!(Date.new!(year, month, day), Time.new!(hour, minute, second), "Etc/UTC")
220+
221+
max(DateTime.diff(retry_at, DateTime.utc_now(), :millisecond), 0)
222+
|> min(max_retry_after_ms())
223+
224+
_ ->
225+
nil
226+
end
105227
rescue
106-
error ->
107-
fallback_to_cache_or_raise(cache_path, url, Exception.message(error))
108-
catch
109-
:exit, reason ->
110-
fallback_to_cache_or_raise(
111-
cache_path,
112-
url,
113-
"Network error fetching WPT data: #{inspect(reason)}"
114-
)
228+
_ ->
229+
nil
230+
end
115231

116-
kind, reason ->
117-
fallback_to_cache_or_raise(
118-
cache_path,
119-
url,
120-
"Fetch failure (#{kind}) fetching WPT data: #{inspect(reason)}"
121-
)
232+
defp rate_limit_reset_delay_ms(response) do
233+
remaining = Web.Headers.get(response.headers, "x-ratelimit-remaining")
234+
reset = Web.Headers.get(response.headers, "x-ratelimit-reset")
235+
236+
case {remaining, reset} do
237+
{"0", reset_value} when not is_nil(reset_value) ->
238+
case Integer.parse(to_string(reset_value)) do
239+
{reset_epoch, ""} ->
240+
now = System.system_time(:second)
241+
wait_seconds = max(reset_epoch - now, 0)
242+
min(wait_seconds * 1000, max_retry_after_ms())
243+
244+
_ ->
245+
nil
246+
end
247+
248+
_ ->
249+
nil
250+
end
251+
end
252+
253+
defp sleep_if_needed(delay_ms) when delay_ms > 0 do
254+
Process.sleep(delay_ms)
255+
end
256+
257+
defp sleep_if_needed(_delay_ms), do: :ok
258+
259+
defp backoff_delay_ms(attempt) do
260+
pow = max(attempt - 1, 0)
261+
delay = trunc(base_backoff_ms() * :math.pow(2, pow))
262+
min(delay, max_backoff_ms())
263+
end
264+
265+
defp fetch_timeout_signal(started_at) do
266+
case remaining_budget_ms(started_at) do
267+
remaining when remaining > 0 ->
268+
Web.AbortSignal.any([
269+
Web.AbortSignal.timeout(fetch_timeout_ms()),
270+
Web.AbortSignal.timeout(remaining)
271+
])
272+
273+
_ ->
274+
Web.AbortSignal.abort(:timeout)
275+
end
276+
end
277+
278+
defp retryable_fetch_status?(status) do
279+
status in [403, 408, 425, 429] or status >= 500
280+
end
281+
282+
defp retryable_fetch_error?(%Mint.TransportError{reason: reason}) do
283+
transient_network_error?(reason)
284+
end
285+
286+
defp retryable_fetch_error?({:aborted, _reason}), do: true
287+
defp retryable_fetch_error?({:shutdown, reason}), do: retryable_fetch_error?(reason)
288+
defp retryable_fetch_error?(reason), do: transient_network_error?(reason)
289+
290+
defp transient_network_error?(reason)
291+
292+
defp transient_network_error?(reason)
293+
when reason in [:aborted, :timeout, :closed, :econnaborted, :econnrefused, :econnreset] do
294+
true
295+
end
296+
297+
defp transient_network_error?(reason)
298+
when reason in [:enetdown, :enetunreach, :ehostdown, :ehostunreach, :etimedout, :nxdomain] do
299+
true
300+
end
301+
302+
defp transient_network_error?({:failed_connect, _details}), do: true
303+
defp transient_network_error?(_reason), do: false
304+
305+
defp remaining_budget_ms(started_at) do
306+
max(max_total_fetch_ms() - elapsed_ms(started_at), 0)
307+
end
308+
309+
defp elapsed_ms(started_at) do
310+
System.monotonic_time(:millisecond) - started_at
311+
end
312+
313+
defp discard_response_body(%Web.Response{body: body}) do
314+
_ = Enum.reduce_while(body, :ok, fn _, acc -> {:halt, acc} end)
315+
:ok
316+
rescue
317+
_ ->
318+
:ok
319+
catch
320+
:exit, _reason ->
321+
:ok
122322
end
123323

124324
defp persist_fresh_response(response, cache_path, meta_path) do
@@ -208,6 +408,60 @@ defmodule Web.Platform.Test do
208408
end
209409
end
210410

411+
defp fetch_governor do
412+
governor = :persistent_term.get(@governor_key, nil)
413+
414+
if live_fetch_governor?(governor) do
415+
governor
416+
else
417+
init_fetch_governor()
418+
end
419+
end
420+
421+
defp init_fetch_governor do
422+
:global.trans(@governor_lock_key, fn ->
423+
governor = :persistent_term.get(@governor_key, nil)
424+
425+
if live_fetch_governor?(governor) do
426+
governor
427+
else
428+
new_fetch_governor()
429+
end
430+
end)
431+
end
432+
433+
defp new_fetch_governor do
434+
governor = CountingGovernor.new(governor_capacity())
435+
:persistent_term.put(@governor_key, governor)
436+
governor
437+
end
438+
439+
defp live_fetch_governor?(%CountingGovernor{pid: pid}) when is_pid(pid) do
440+
Process.alive?(pid) and pid_governor_capacity(pid) == governor_capacity()
441+
end
442+
443+
defp live_fetch_governor?(_), do: false
444+
445+
defp pid_governor_capacity(pid) do
446+
:sys.get_state(pid).capacity
447+
rescue
448+
_ -> nil
449+
end
450+
451+
defp governor_capacity, do: runtime_option(:governor_capacity, @default_governor_capacity)
452+
defp max_fetch_attempts, do: runtime_option(:max_fetch_attempts, @default_max_fetch_attempts)
453+
defp fetch_timeout_ms, do: runtime_option(:fetch_timeout_ms, @default_fetch_timeout_ms)
454+
defp max_total_fetch_ms, do: runtime_option(:max_total_fetch_ms, @default_max_total_fetch_ms)
455+
defp base_backoff_ms, do: runtime_option(:base_backoff_ms, @default_base_backoff_ms)
456+
defp max_backoff_ms, do: runtime_option(:max_backoff_ms, @default_max_backoff_ms)
457+
defp max_retry_after_ms, do: runtime_option(:max_retry_after_ms, @default_max_retry_after_ms)
458+
459+
defp runtime_option(key, default) do
460+
:web
461+
|> Application.get_env(__MODULE__, [])
462+
|> Keyword.get(key, default)
463+
end
464+
211465
defp normalize_use_options!(opts) when is_list(opts) do
212466
if Keyword.keyword?(opts) do
213467
urls = opts |> Keyword.fetch!(:urls) |> normalize_urls_option!()

0 commit comments

Comments
 (0)