html2rss 0.32.2 → 0.32.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e095abe52bc258d262e3a24909e0feb480b2eb1d537e57580fc271203b0c6dbc
4
- data.tar.gz: 8fdabe7ed5afee08c46ab94c2b6f80c1f8c66df9ffec9439e40e80fa897bfa96
3
+ metadata.gz: 8d37540b2f300af8a1db631e16cf6cabf4cfa76069000a71c7723737225bd6c3
4
+ data.tar.gz: bd8f656ae629587336257d790da183c759b9c709c1ba411e5c0041156d35dd6b
5
5
  SHA512:
6
- metadata.gz: fdcce48a2a4fb3a8c3ea02fd8f4bb799bd8ed7acb4e66e74da3cde693f6a51cc3aa72d363670b13f077d1521d97321e0676d14b59c6b61f1c685fdc4db64cb67
7
- data.tar.gz: ce1c1cac4c38f4660e61cb7c9506991d8c0d847c8bdfbb583c678680854b7327a16b9aa637af641aaae42bed9f15b9330cc344ee922252da16863a517569fad4
6
+ metadata.gz: 802b88abad9bbadfa6753a5c62694a66c6eec3774e84d85191beabdfdd2bc8df3e37fdf325bc14c1d6fda0b90528aca2484df15d0d985b6e328c07f4c27a49b9
7
+ data.tar.gz: 827d398c34732c10600ced1002525702392448fcb4e8b585f7c27048c8fae38e9c2652307705c8a91e82fba295dbbfa8bfb0cc4dc3a71c4d70cdf9773722a4cf
@@ -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` or `BlockedSurfaceDetected`). `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`. `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,26 @@ 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
+ ].freeze
37
+
30
38
  ##
31
39
  # Mutable run state for one auto-fallback chain: attempts plus selected result.
32
40
  class AttemptState
33
- attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution
41
+ attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution,
42
+ :terminal_transport_error
34
43
 
35
44
  def initialize
36
45
  @attempts = []
37
46
  @result = nil
38
47
  @last_response = nil
39
48
  @last_error = nil
49
+ @terminal_transport_error = nil
40
50
  @entry_resolution = nil
41
51
  @resolution_tried = false
42
52
  @aborted = false
@@ -76,6 +86,9 @@ module Html2rss
76
86
  attempt[:transport_meta] = meta if meta&.any?
77
87
  @attempts << attempt
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
@@ -85,7 +98,7 @@ module Html2rss
85
98
  def record_items(strategy:, items_count:, transport_meta: nil)
86
99
  @last_error = nil
87
100
  attempt = { strategy:, items_count:, error_class: nil }
88
- attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
101
+ attempt[:transport_meta] = transport_meta if transport_meta&.any?
89
102
  @attempts << attempt
90
103
  end
91
104
 
@@ -183,7 +196,9 @@ module Html2rss
183
196
 
184
197
  # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
185
198
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
186
- articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
199
+ articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
200
+ config:, response:, request_session:
201
+ )
187
202
  state.remember_response(response)
188
203
  items_count = articles.size
189
204
  state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
@@ -204,7 +219,7 @@ module Html2rss
204
219
  state:)
205
220
  end
206
221
 
207
- if deterministic_status?(response)
222
+ if DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
208
223
  state.abort!
209
224
  Log.debug("#{self.class}: deterministic HTTP #{response.status}; aborting auto fallback")
210
225
  return
@@ -213,14 +228,6 @@ module Html2rss
213
228
  log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
214
229
  end
215
230
 
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
231
  # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- success kwargs + structured selected log
225
232
  def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
226
233
  attempt_count = state.attempts.size
@@ -243,57 +250,46 @@ module Html2rss
243
250
  end
244
251
 
245
252
  def finalize_failure(state)
246
- if raise_last_error?(state)
247
- error = state.last_error
253
+ error = failure_error(state)
254
+ if error
248
255
  error.attempts = state.attempts if error.is_a?(Html2rss::Error)
249
256
  raise error
250
257
  end
251
258
 
259
+ response = state.last_response
252
260
  raise NoFeedItemsExtracted.new(
253
261
  attempts: state.attempts,
254
- surface_category: surface_category_for(state.last_response)
262
+ surface_category: (PageRecon.surface_category_for(response:, url: response.url) if response)
255
263
  )
256
264
  end
257
265
 
258
- def raise_last_error?(state)
259
- error = state.last_error
260
- return false if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
266
+ # Prefer a remembered timeout/blocked-surface over a later zero-item hop so
267
+ # callers do not cache EXTRACTION_EMPTY for a transport failure.
268
+ def failure_error(state)
269
+ return state.terminal_transport_error if state.terminal_transport_error
261
270
 
262
- true
263
- end
264
-
265
- def surface_category_for(response)
266
- return unless response
271
+ error = state.last_error
272
+ return if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
267
273
 
268
- PageRecon.surface_category_for(response:, url: response.url)
274
+ error
269
275
  end
270
276
 
271
- # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
277
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
272
278
  def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
273
279
  host = request_session.url.host
274
280
  detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
275
281
  "budget_remaining=#{budget_remaining_label}"
276
- if error.is_a?(RequestService::RequestTimedOut)
277
- Log.info({
278
- event_name: 'auto_fallback.timeout',
279
- host:,
280
- strategy: strategy.to_s,
281
- next_strategy: next_strategy.to_s,
282
- error_class: error.class.name,
283
- message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
284
- "after timeout=#{error.class} #{detail}"
285
- }.to_json)
286
- else
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
282
+ timed_out = error.is_a?(RequestService::RequestTimedOut)
283
+ payload = {
284
+ event_name: timed_out ? 'auto_fallback.timeout' : 'auto_fallback.error',
285
+ host:,
286
+ strategy: strategy.to_s,
287
+ next_strategy: next_strategy.to_s,
288
+ error_class: error.class.name,
289
+ message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
290
+ "after #{timed_out ? "timeout=#{error.class}" : "error=#{error.class}"} #{detail}"
291
+ }.to_json
292
+ timed_out ? Log.info(payload) : Log.warn(payload)
297
293
  Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
298
294
  end
299
295
 
@@ -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
- # Bounds passed to the retries plugin and every +session.with+.
25
- # HTTPX retries stay inside one {RequestService::Budget} slot and the shared
26
- # wall-clock deadline; do not consume a slot per attempt.
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 = request_with_terminal_redirect_retry(deadline:)
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 HTTPX execute (including internal MAX_RETRIES) so a hanging
64
- # :default tier cannot consume the full shared auto-fallback budget.
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
- monotonic_now + [budget_timeout, attempt_timeout_ceiling].min
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
- def request_with_terminal_redirect_retry(deadline:)
85
- raw_response = execute_http_request(deadline:)
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 - monotonic_now
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.32.2'
5
+ VERSION = '0.32.3'
6
6
  public_constant :VERSION
7
7
  end
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.2
4
+ version: 0.32.3
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.2
481
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.3
482
482
  rubygems_mfa_required: 'true'
483
483
  rdoc_options: []
484
484
  require_paths: