@@ -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