html2rss 0.32.2 → 0.32.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/html2rss/error.rb +49 -0
- data/lib/html2rss/feed_pipeline/README.md +4 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +85 -86
- data/lib/html2rss/request_service/httpx_strategy.rb +34 -15
- data/lib/html2rss/request_service/local_file_strategy.rb +3 -0
- data/lib/html2rss/request_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- data/lib/html2rss/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a53d8374b6b4ce7551c9f1bffd0e14960995f7df7380554f1292e0a70317cf58
|
|
4
|
+
data.tar.gz: 90e91d4275c03c120924cca66230a7aecb3606f2f50c6224d019c138cae244e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2543d4431b2790c89c19ab207d0cdbbc7416493ea1184347746bc26e21068415d68f025e0cc253082224ca5191a3984163e3e48ef3e727cb3b78c19cddd3d64
|
|
7
|
+
data.tar.gz: ac588fece229b58ab2ea4de91bb467fdbfaf199f43febfd8be6b39d817fc9b9b2867c21cb472949e886ad4dd50b436d9c5c149c0976a87b8cd67d09a6a62f9f6
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -3,11 +3,60 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
# The Html2rss::Error base class.
|
|
5
5
|
class Error < StandardError
|
|
6
|
+
# Upstream transport_meta keys promoted into error diagnostics.
|
|
7
|
+
TRANSPORT_META_KEYS = %i[request_id strategy_used render_ms error_category timeout_phase].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# Builds a sparse diagnostics hash from attempt hashes and optional error metadata.
|
|
12
|
+
#
|
|
13
|
+
# @param attempts [Array<Hash>, nil] tier attempt diagnostics
|
|
14
|
+
# @param transport_meta [Hash, nil] direct error transport metadata
|
|
15
|
+
# @param timeout_phase [String, nil] timeout stage when known
|
|
16
|
+
# @param surface_category [Symbol, nil] page surface classification when known
|
|
17
|
+
# @return [Hash{Symbol => Object}]
|
|
18
|
+
def diagnostics_from_attempts(attempts, transport_meta: nil, timeout_phase: nil, surface_category: nil)
|
|
19
|
+
list = Array(attempts)
|
|
20
|
+
meta = list.filter_map { |item| item[:transport_meta] || item['transport_meta'] }.last || transport_meta
|
|
21
|
+
fields = extract_transport_fields(meta)
|
|
22
|
+
fields[:timeout_phase] ||= timeout_phase
|
|
23
|
+
fields[:surface_category] = surface_category
|
|
24
|
+
{ strategy_attempts: (list if list.any?), **fields }.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def extract_transport_fields(meta)
|
|
30
|
+
return {} unless meta
|
|
31
|
+
|
|
32
|
+
TRANSPORT_META_KEYS.to_h { |key| [key, meta[key] || meta[key.to_s]] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
6
36
|
# @return [Array<Hash{Symbol => Object}>, nil] tier attempt diagnostics when set by AutoFallback
|
|
7
37
|
attr_accessor :attempts
|
|
8
38
|
|
|
9
39
|
# @return [Hash{String => Object}, nil] allowlisted upstream transport telemetry when attached
|
|
10
40
|
attr_accessor :transport_meta
|
|
41
|
+
|
|
42
|
+
# @return [String, nil] scrape-api timeout stage when known (overridden by RequestTimedOut)
|
|
43
|
+
def timeout_phase = nil
|
|
44
|
+
|
|
45
|
+
# @return [Symbol, nil] surface classification when known (overridden by NoFeedItemsExtracted)
|
|
46
|
+
def surface_category = nil
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Sparse telemetry hash for observability consumers.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash{Symbol => Object}]
|
|
52
|
+
def diagnostics
|
|
53
|
+
self.class.diagnostics_from_attempts(
|
|
54
|
+
attempts,
|
|
55
|
+
transport_meta:,
|
|
56
|
+
timeout_phase:,
|
|
57
|
+
surface_category:
|
|
58
|
+
)
|
|
59
|
+
end
|
|
11
60
|
end
|
|
12
61
|
|
|
13
62
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
@@ -35,6 +35,10 @@ These abort immediately (`AutoFallback::NON_FALLBACK_ERRORS`): unknown strategy,
|
|
|
35
35
|
|
|
36
36
|
Retries share the feed's request/session policy. Pin a concrete strategy when you need a single-hop budget profile.
|
|
37
37
|
|
|
38
|
+
### Terminal fallback outcome
|
|
39
|
+
|
|
40
|
+
A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut`, `BlockedSurfaceDetected`, or `ConnectionFailed`). `AutoFallback` remembers that error across later hops and, when no later hop yields items, re-raises it with `attempts` attached. A later hop that extracts items still succeeds. Two zero-item hops with no terminal transport error still raise `NoFeedItemsExtracted`. True empty stays the web policy already in force: listing `EXTRACTION_EMPTY` is cacheable; `app_shell` and `high_entropy_surface` are not. `html2rss-web` `ErrorClassifier` maps the raised error to HTTP/cache policy; it does not invent a second cache rule for empty extraction.
|
|
41
|
+
|
|
38
42
|
## Success signals
|
|
39
43
|
|
|
40
44
|
- `Html2rss::RequestService::Response` includes transport metadata for the strategy that produced it.
|
|
@@ -27,16 +27,27 @@ module Html2rss
|
|
|
27
27
|
RequestService::RedirectLimitReached
|
|
28
28
|
].freeze
|
|
29
29
|
|
|
30
|
+
# Transport failures preserved across a later hop that extracts zero items.
|
|
31
|
+
# Re-raised from finalize_failure so web maps them to uncacheable gateway/blocked
|
|
32
|
+
# decisions instead of EXTRACTION_EMPTY.
|
|
33
|
+
TERMINAL_TRANSPORT_ERRORS = [
|
|
34
|
+
RequestService::RequestTimedOut,
|
|
35
|
+
RequestService::BlockedSurfaceDetected,
|
|
36
|
+
RequestService::ConnectionFailed
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
30
39
|
##
|
|
31
40
|
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
32
41
|
class AttemptState
|
|
33
|
-
attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution
|
|
42
|
+
attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution,
|
|
43
|
+
:terminal_transport_error
|
|
34
44
|
|
|
35
45
|
def initialize
|
|
36
46
|
@attempts = []
|
|
37
47
|
@result = nil
|
|
38
48
|
@last_response = nil
|
|
39
49
|
@last_error = nil
|
|
50
|
+
@terminal_transport_error = nil
|
|
40
51
|
@entry_resolution = nil
|
|
41
52
|
@resolution_tried = false
|
|
42
53
|
@aborted = false
|
|
@@ -69,24 +80,25 @@ module Html2rss
|
|
|
69
80
|
|
|
70
81
|
# @param strategy [Symbol] strategy that raised
|
|
71
82
|
# @param error [Exception] caught error
|
|
83
|
+
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
72
84
|
# @return [void]
|
|
73
|
-
def record_error(strategy:, error:)
|
|
74
|
-
attempt = { strategy:, items_count: nil, error_class: error.class.name }
|
|
85
|
+
def record_error(strategy:, error:, elapsed_ms: nil)
|
|
75
86
|
meta = error.is_a?(Html2rss::Error) ? error.transport_meta : nil
|
|
76
|
-
|
|
77
|
-
@attempts << attempt
|
|
87
|
+
append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
|
|
78
88
|
@last_error = error
|
|
89
|
+
return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
90
|
+
|
|
91
|
+
@terminal_transport_error = error
|
|
79
92
|
end
|
|
80
93
|
|
|
81
94
|
# @param strategy [Symbol] strategy that returned a response
|
|
82
95
|
# @param items_count [Integer] extracted article count
|
|
83
96
|
# @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
|
|
97
|
+
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
84
98
|
# @return [void]
|
|
85
|
-
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
99
|
+
def record_items(strategy:, items_count:, transport_meta: nil, elapsed_ms: nil)
|
|
86
100
|
@last_error = nil
|
|
87
|
-
|
|
88
|
-
attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
|
|
89
|
-
@attempts << attempt
|
|
101
|
+
append_attempt(strategy:, items_count:, transport_meta:, elapsed_ms:)
|
|
90
102
|
end
|
|
91
103
|
|
|
92
104
|
# @param response [RequestService::Response] last response that reached extraction
|
|
@@ -118,6 +130,37 @@ module Html2rss
|
|
|
118
130
|
entry_resolution:
|
|
119
131
|
)
|
|
120
132
|
end
|
|
133
|
+
|
|
134
|
+
# Raises the terminal transport error, last error, or NoFeedItemsExtracted with attempts attached.
|
|
135
|
+
#
|
|
136
|
+
# @return [void] always raises
|
|
137
|
+
# @raise [Html2rss::Error, StandardError]
|
|
138
|
+
def raise_failure!
|
|
139
|
+
if (error = failure_error)
|
|
140
|
+
error.attempts = attempts if error.is_a?(Html2rss::Error)
|
|
141
|
+
raise error
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
response = @last_response
|
|
145
|
+
surface_category = PageRecon.surface_category_for(response:, url: response.url) if response
|
|
146
|
+
raise NoFeedItemsExtracted.new(attempts:, surface_category:)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def append_attempt(strategy:, items_count: nil, error_class: nil, transport_meta: nil, elapsed_ms: nil)
|
|
152
|
+
attempt = { strategy:, items_count:, error_class: }
|
|
153
|
+
attempt[:elapsed_ms] = elapsed_ms unless elapsed_ms.nil?
|
|
154
|
+
attempt[:transport_meta] = transport_meta if transport_meta&.any?
|
|
155
|
+
@attempts << attempt
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def failure_error
|
|
159
|
+
return @terminal_transport_error if @terminal_transport_error
|
|
160
|
+
return if @last_error.nil? || @last_error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
161
|
+
|
|
162
|
+
@last_error
|
|
163
|
+
end
|
|
121
164
|
end
|
|
122
165
|
|
|
123
166
|
##
|
|
@@ -142,7 +185,7 @@ module Html2rss
|
|
|
142
185
|
state = run_attempts
|
|
143
186
|
return state.result if state.succeeded?
|
|
144
187
|
|
|
145
|
-
|
|
188
|
+
state.raise_failure!
|
|
146
189
|
end
|
|
147
190
|
|
|
148
191
|
private
|
|
@@ -158,35 +201,33 @@ module Html2rss
|
|
|
158
201
|
end
|
|
159
202
|
end
|
|
160
203
|
|
|
204
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- hop timing + NON_FALLBACK rescue stay co-located
|
|
161
205
|
def attempt(strategy:, next_strategy:, state:)
|
|
206
|
+
started_ms = monotonic_ms
|
|
162
207
|
request_session = pipeline.request_session_for(
|
|
163
208
|
config, strategy:, resources:, scrape_url: scrape_target.effective_url
|
|
164
209
|
)
|
|
165
|
-
response =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
175
|
-
request_session.fetch_initial_response
|
|
176
|
-
rescue *NON_FALLBACK_ERRORS
|
|
177
|
-
raise
|
|
210
|
+
response = request_session.fetch_initial_response
|
|
211
|
+
process_response(
|
|
212
|
+
response:, strategy:, next_strategy:, request_session:, state:,
|
|
213
|
+
elapsed_ms: monotonic_ms - started_ms
|
|
214
|
+
)
|
|
215
|
+
rescue *NON_FALLBACK_ERRORS => error
|
|
216
|
+
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
217
|
+
state.abort!
|
|
178
218
|
rescue StandardError => error
|
|
179
|
-
state.record_error(strategy:, error:)
|
|
180
|
-
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
181
|
-
nil
|
|
219
|
+
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
220
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
|
|
182
221
|
end
|
|
183
222
|
|
|
184
|
-
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
|
|
185
|
-
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
186
|
-
articles, dedup_dropped, admission_drops =
|
|
223
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists -- extract + resolution delegate
|
|
224
|
+
def process_response(response:, strategy:, next_strategy:, request_session:, state:, elapsed_ms: nil)
|
|
225
|
+
articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
|
|
226
|
+
config:, response:, request_session:
|
|
227
|
+
)
|
|
187
228
|
state.remember_response(response)
|
|
188
229
|
items_count = articles.size
|
|
189
|
-
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
230
|
+
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta, elapsed_ms:)
|
|
190
231
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
191
232
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
192
233
|
"budget_remaining=#{budget_remaining_label}")
|
|
@@ -204,7 +245,7 @@ module Html2rss
|
|
|
204
245
|
state:)
|
|
205
246
|
end
|
|
206
247
|
|
|
207
|
-
if
|
|
248
|
+
if DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
|
|
208
249
|
state.abort!
|
|
209
250
|
Log.debug("#{self.class}: deterministic HTTP #{response.status}; aborting auto fallback")
|
|
210
251
|
return
|
|
@@ -213,14 +254,6 @@ module Html2rss
|
|
|
213
254
|
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
214
255
|
end
|
|
215
256
|
|
|
216
|
-
def deterministic_status?(response)
|
|
217
|
-
DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def articles_for(response:, request_session:)
|
|
221
|
-
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
257
|
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- success kwargs + structured selected log
|
|
225
258
|
def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
|
|
226
259
|
attempt_count = state.attempts.size
|
|
@@ -242,58 +275,24 @@ module Html2rss
|
|
|
242
275
|
}.to_json)
|
|
243
276
|
end
|
|
244
277
|
|
|
245
|
-
def
|
|
246
|
-
if raise_last_error?(state)
|
|
247
|
-
error = state.last_error
|
|
248
|
-
error.attempts = state.attempts if error.is_a?(Html2rss::Error)
|
|
249
|
-
raise error
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
raise NoFeedItemsExtracted.new(
|
|
253
|
-
attempts: state.attempts,
|
|
254
|
-
surface_category: surface_category_for(state.last_response)
|
|
255
|
-
)
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def raise_last_error?(state)
|
|
259
|
-
error = state.last_error
|
|
260
|
-
return false if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
278
|
+
def monotonic_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
261
279
|
|
|
262
|
-
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def surface_category_for(response)
|
|
266
|
-
return unless response
|
|
267
|
-
|
|
268
|
-
PageRecon.surface_category_for(response:, url: response.url)
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
280
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
|
|
272
281
|
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
273
282
|
host = request_session.url.host
|
|
274
283
|
detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
275
284
|
"budget_remaining=#{budget_remaining_label}"
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
Log.warn({
|
|
288
|
-
event_name: 'auto_fallback.error',
|
|
289
|
-
host:,
|
|
290
|
-
strategy: strategy.to_s,
|
|
291
|
-
next_strategy: next_strategy.to_s,
|
|
292
|
-
error_class: error.class.name,
|
|
293
|
-
message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
294
|
-
"after error=#{error.class} #{detail}"
|
|
295
|
-
}.to_json)
|
|
296
|
-
end
|
|
285
|
+
timed_out = error.is_a?(RequestService::RequestTimedOut)
|
|
286
|
+
payload = {
|
|
287
|
+
event_name: timed_out ? 'auto_fallback.timeout' : 'auto_fallback.error',
|
|
288
|
+
host:,
|
|
289
|
+
strategy: strategy.to_s,
|
|
290
|
+
next_strategy: next_strategy.to_s,
|
|
291
|
+
error_class: error.class.name,
|
|
292
|
+
message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
293
|
+
"after #{timed_out ? "timeout=#{error.class}" : "error=#{error.class}"} #{detail}"
|
|
294
|
+
}.to_json
|
|
295
|
+
timed_out ? Log.info(payload) : Log.warn(payload)
|
|
297
296
|
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
|
|
298
297
|
end
|
|
299
298
|
|
|
@@ -21,9 +21,9 @@ module Html2rss
|
|
|
21
21
|
# combined with ping! on a reused closed selector that never decrements retries,
|
|
22
22
|
# that overflows the stack and kills Falcon workers.
|
|
23
23
|
RETRY_AFTER_SECONDS = 0.25
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# Base-session retries plugin defaults (stack-overflow guard for ping reconnects).
|
|
25
|
+
# Per-request +session.with+ sets +max_retries: 0+; Ruby owns TimeoutError retries
|
|
26
|
+
# against the monotonic deadline so HTTPX cannot stack read timeouts past the cap.
|
|
27
27
|
# Do not reload +:fiber_concurrency+: a reused closed selector made +ping!+
|
|
28
28
|
# skip decrementing retries and recurse until +SystemStackError+.
|
|
29
29
|
#
|
|
@@ -54,19 +54,19 @@ module Html2rss
|
|
|
54
54
|
def perform_execute
|
|
55
55
|
deadline = request_deadline
|
|
56
56
|
reset_redirect_tracking!
|
|
57
|
-
raw_response =
|
|
57
|
+
raw_response = request_with_deadline_retries(deadline:)
|
|
58
58
|
build_response(raw_response)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
private
|
|
62
62
|
|
|
63
|
-
# Caps one
|
|
64
|
-
#
|
|
63
|
+
# Caps one strategy execute (Ruby retries included) so a hanging :default tier
|
|
64
|
+
# cannot consume the full shared auto-fallback budget.
|
|
65
65
|
#
|
|
66
66
|
# @return [Float] monotonic deadline
|
|
67
67
|
def request_deadline
|
|
68
68
|
budget_timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
69
|
-
|
|
69
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + [budget_timeout, attempt_timeout_ceiling].min
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
# @return [Float] connect + read ceiling for a single attempt
|
|
@@ -81,8 +81,30 @@ module Html2rss
|
|
|
81
81
|
@current_url = request_url
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
# Retries HTTPX timeouts in Ruby while the shared monotonic deadline has time left.
|
|
85
|
+
# First try consumes the budget slot; retries do not.
|
|
86
|
+
def request_with_deadline_retries(deadline:)
|
|
87
|
+
state = { retries_remaining: MAX_RETRIES, consume_budget: true }
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
request_with_terminal_redirect_retry(deadline:, consume_budget: state[:consume_budget])
|
|
91
|
+
rescue HTTPX::TimeoutError => error
|
|
92
|
+
prepare_deadline_retry!(deadline:, state:, error:)
|
|
93
|
+
retry
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prepare_deadline_retry!(deadline:, state:, error:)
|
|
98
|
+
raise error if state[:retries_remaining].zero?
|
|
99
|
+
|
|
100
|
+
remaining_timeout_seconds(deadline)
|
|
101
|
+
state[:retries_remaining] -= 1
|
|
102
|
+
state[:consume_budget] = false
|
|
103
|
+
reset_redirect_tracking!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def request_with_terminal_redirect_retry(deadline:, consume_budget: true)
|
|
107
|
+
raw_response = execute_http_request(deadline:, consume_budget:)
|
|
86
108
|
return raw_response unless redirect_limit_reached?(raw_response)
|
|
87
109
|
|
|
88
110
|
unless terminal_redirect_retryable?(raw_response)
|
|
@@ -164,6 +186,7 @@ module Html2rss
|
|
|
164
186
|
session.with(session_options(deadline))
|
|
165
187
|
end
|
|
166
188
|
|
|
189
|
+
# Plugin retries stay off per request; Ruby owns deadline-bounded TimeoutError retries.
|
|
167
190
|
def session_options(deadline)
|
|
168
191
|
remaining = remaining_timeout_seconds(deadline)
|
|
169
192
|
{
|
|
@@ -173,7 +196,7 @@ module Html2rss
|
|
|
173
196
|
max_response_body_size: ctx.policy.max_response_bytes,
|
|
174
197
|
resolver_class: :system,
|
|
175
198
|
redirect_on: redirect_callback,
|
|
176
|
-
**RETRY_OPTIONS
|
|
199
|
+
**RETRY_OPTIONS.merge(max_retries: 0)
|
|
177
200
|
}
|
|
178
201
|
end
|
|
179
202
|
|
|
@@ -219,16 +242,12 @@ module Html2rss
|
|
|
219
242
|
end
|
|
220
243
|
|
|
221
244
|
def remaining_timeout_seconds(deadline)
|
|
222
|
-
remaining = deadline -
|
|
245
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
223
246
|
raise RequestTimedOut, 'Request timed out' if remaining <= 0
|
|
224
247
|
|
|
225
248
|
remaining
|
|
226
249
|
end
|
|
227
250
|
|
|
228
|
-
def monotonic_now
|
|
229
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
251
|
def timeout_error?(error)
|
|
233
252
|
error.is_a?(HTTPX::TimeoutError) || super
|
|
234
253
|
end
|
|
@@ -15,6 +15,9 @@ module Html2rss
|
|
|
15
15
|
|
|
16
16
|
def skip_postflight? = true
|
|
17
17
|
|
|
18
|
+
# Local files never open a socket; keep filesystem errors as wire classes.
|
|
19
|
+
def connection_error?(_error) = false
|
|
20
|
+
|
|
18
21
|
##
|
|
19
22
|
# @return [Response] the mock response wrapped around the file contents
|
|
20
23
|
# @raise [ArgumentError] if the local file path is missing
|
|
@@ -133,9 +133,9 @@ module Html2rss
|
|
|
133
133
|
|
|
134
134
|
# @param error [StandardError]
|
|
135
135
|
# @return [void]
|
|
136
|
-
# @raise [
|
|
136
|
+
# @raise [ConnectionFailed]
|
|
137
137
|
def translate_connection_error(error)
|
|
138
|
-
raise error
|
|
138
|
+
raise ConnectionFailed, error.message
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
# @param error [StandardError]
|
|
@@ -28,6 +28,8 @@ module Html2rss
|
|
|
28
28
|
class RedirectLimitReached < Html2rss::Error; end
|
|
29
29
|
# Raised when blocked content surfaces are detected.
|
|
30
30
|
class BlockedSurfaceDetected < Html2rss::Error; end
|
|
31
|
+
# Raised when the target connection fails before a response document exists.
|
|
32
|
+
class ConnectionFailed < Html2rss::Error; end
|
|
31
33
|
|
|
32
34
|
# Raised when a request times out.
|
|
33
35
|
class RequestTimedOut < Html2rss::Error
|
|
@@ -76,6 +78,12 @@ module Html2rss
|
|
|
76
78
|
# @return [Symbol]
|
|
77
79
|
DEFAULT_STRATEGY_NAME = :default
|
|
78
80
|
|
|
81
|
+
# Maximum distinct [strategy, host] deprecation warnings tracked per process.
|
|
82
|
+
MAX_DEPRECATION_WARNINGS = 256
|
|
83
|
+
# Mutex guarding process-local deprecation warning deduplication.
|
|
84
|
+
DEPRECATION_MUTEX = Mutex.new
|
|
85
|
+
private_constant :DEPRECATION_MUTEX
|
|
86
|
+
|
|
79
87
|
class << self
|
|
80
88
|
# @return [Symbol] the default strategy name
|
|
81
89
|
def default_strategy_name = DEFAULT_STRATEGY_NAME
|
|
@@ -107,6 +115,13 @@ module Html2rss
|
|
|
107
115
|
strategy_class.new(ctx).execute
|
|
108
116
|
end
|
|
109
117
|
|
|
118
|
+
##
|
|
119
|
+
# Clears process-local deprecation warning state (used by specs).
|
|
120
|
+
# @return [void]
|
|
121
|
+
def reset_deprecation_warnings!
|
|
122
|
+
DEPRECATION_MUTEX.synchronize { @warned_deprecations = Set.new }
|
|
123
|
+
end
|
|
124
|
+
|
|
110
125
|
private
|
|
111
126
|
|
|
112
127
|
##
|
|
@@ -125,14 +140,15 @@ module Html2rss
|
|
|
125
140
|
end
|
|
126
141
|
|
|
127
142
|
##
|
|
128
|
-
# Emits a structured +strategy_deprecated+ warning once per
|
|
143
|
+
# Emits a structured +strategy_deprecated+ warning once per +(strategy, host)+ per process
|
|
144
|
+
# on initial session requests (+relation == :initial+). Multi-worker servers may emit once per worker.
|
|
129
145
|
# @param strategy [Symbol] deprecated strategy alias
|
|
130
146
|
# @param replacement [Symbol] preferred strategy name
|
|
131
147
|
# @param relation [Symbol] request relation
|
|
132
148
|
# @param host [String, nil] request host
|
|
133
149
|
# @return [void]
|
|
134
150
|
def warn_migration_strategy(strategy, replacement, relation:, host:)
|
|
135
|
-
return unless relation == :initial
|
|
151
|
+
return unless relation == :initial && claim_deprecation_warning?(strategy, host)
|
|
136
152
|
|
|
137
153
|
Log.warn({
|
|
138
154
|
event_name: 'strategy_deprecated',
|
|
@@ -142,6 +158,14 @@ module Html2rss
|
|
|
142
158
|
message: "#{name}: strategy=#{strategy.inspect} is deprecated; using #{replacement.inspect} instead."
|
|
143
159
|
}.to_json)
|
|
144
160
|
end
|
|
161
|
+
|
|
162
|
+
def claim_deprecation_warning?(strategy, host)
|
|
163
|
+
DEPRECATION_MUTEX.synchronize do
|
|
164
|
+
@warned_deprecations ||= Set.new
|
|
165
|
+
@warned_deprecations.clear if @warned_deprecations.size >= MAX_DEPRECATION_WARNINGS
|
|
166
|
+
!@warned_deprecations.add?([strategy, host]).nil?
|
|
167
|
+
end
|
|
168
|
+
end
|
|
145
169
|
end
|
|
146
170
|
end
|
|
147
171
|
end
|
data/lib/html2rss/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: html2rss
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.32.
|
|
4
|
+
version: 0.32.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gil Desmarais
|
|
@@ -478,7 +478,7 @@ licenses:
|
|
|
478
478
|
- MIT
|
|
479
479
|
metadata:
|
|
480
480
|
allowed_push_host: https://rubygems.org
|
|
481
|
-
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.
|
|
481
|
+
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.4
|
|
482
482
|
rubygems_mfa_required: 'true'
|
|
483
483
|
rdoc_options: []
|
|
484
484
|
require_paths:
|