html2rss 0.32.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d37540b2f300af8a1db631e16cf6cabf4cfa76069000a71c7723737225bd6c3
4
- data.tar.gz: bd8f656ae629587336257d790da183c759b9c709c1ba411e5c0041156d35dd6b
3
+ metadata.gz: a53d8374b6b4ce7551c9f1bffd0e14960995f7df7380554f1292e0a70317cf58
4
+ data.tar.gz: 90e91d4275c03c120924cca66230a7aecb3606f2f50c6224d019c138cae244e2
5
5
  SHA512:
6
- metadata.gz: 802b88abad9bbadfa6753a5c62694a66c6eec3774e84d85191beabdfdd2bc8df3e37fdf325bc14c1d6fda0b90528aca2484df15d0d985b6e328c07f4c27a49b9
7
- data.tar.gz: 827d398c34732c10600ced1002525702392448fcb4e8b585f7c27048c8fae38e9c2652307705c8a91e82fba295dbbfa8bfb0cc4dc3a71c4d70cdf9773722a4cf
6
+ metadata.gz: d2543d4431b2790c89c19ab207d0cdbbc7416493ea1184347746bc26e21068415d68f025e0cc253082224ca5191a3984163e3e48ef3e727cb3b78c19cddd3d64
7
+ data.tar.gz: ac588fece229b58ab2ea4de91bb467fdbfaf199f43febfd8be6b39d817fc9b9b2867c21cb472949e886ad4dd50b436d9c5c149c0976a87b8cd67d09a6a62f9f6
@@ -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.
@@ -37,7 +37,7 @@ Retries share the feed's request/session policy. Pin a concrete strategy when yo
37
37
 
38
38
  ### Terminal fallback outcome
39
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.
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
41
 
42
42
  ## Success signals
43
43
 
@@ -32,7 +32,8 @@ module Html2rss
32
32
  # decisions instead of EXTRACTION_EMPTY.
33
33
  TERMINAL_TRANSPORT_ERRORS = [
34
34
  RequestService::RequestTimedOut,
35
- RequestService::BlockedSurfaceDetected
35
+ RequestService::BlockedSurfaceDetected,
36
+ RequestService::ConnectionFailed
36
37
  ].freeze
37
38
 
38
39
  ##
@@ -79,12 +80,11 @@ module Html2rss
79
80
 
80
81
  # @param strategy [Symbol] strategy that raised
81
82
  # @param error [Exception] caught error
83
+ # @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
82
84
  # @return [void]
83
- def record_error(strategy:, error:)
84
- attempt = { strategy:, items_count: nil, error_class: error.class.name }
85
+ def record_error(strategy:, error:, elapsed_ms: nil)
85
86
  meta = error.is_a?(Html2rss::Error) ? error.transport_meta : nil
86
- attempt[:transport_meta] = meta if meta&.any?
87
- @attempts << attempt
87
+ append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
88
88
  @last_error = error
89
89
  return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
90
90
 
@@ -94,12 +94,11 @@ module Html2rss
94
94
  # @param strategy [Symbol] strategy that returned a response
95
95
  # @param items_count [Integer] extracted article count
96
96
  # @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
97
+ # @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
97
98
  # @return [void]
98
- def record_items(strategy:, items_count:, transport_meta: nil)
99
+ def record_items(strategy:, items_count:, transport_meta: nil, elapsed_ms: nil)
99
100
  @last_error = nil
100
- attempt = { strategy:, items_count:, error_class: nil }
101
- attempt[:transport_meta] = transport_meta if transport_meta&.any?
102
- @attempts << attempt
101
+ append_attempt(strategy:, items_count:, transport_meta:, elapsed_ms:)
103
102
  end
104
103
 
105
104
  # @param response [RequestService::Response] last response that reached extraction
@@ -131,6 +130,37 @@ module Html2rss
131
130
  entry_resolution:
132
131
  )
133
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
134
164
  end
135
165
 
136
166
  ##
@@ -155,7 +185,7 @@ module Html2rss
155
185
  state = run_attempts
156
186
  return state.result if state.succeeded?
157
187
 
158
- finalize_failure(state)
188
+ state.raise_failure!
159
189
  end
160
190
 
161
191
  private
@@ -171,37 +201,33 @@ module Html2rss
171
201
  end
172
202
  end
173
203
 
204
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- hop timing + NON_FALLBACK rescue stay co-located
174
205
  def attempt(strategy:, next_strategy:, state:)
206
+ started_ms = monotonic_ms
175
207
  request_session = pipeline.request_session_for(
176
208
  config, strategy:, resources:, scrape_url: scrape_target.effective_url
177
209
  )
178
- response = fetch_response(request_session:, strategy:, next_strategy:, state:)
179
- return unless response
180
-
181
- process_response(response:, strategy:, next_strategy:, request_session:, state:)
182
- rescue RequestService::UnsupportedResponseContentType => error
183
- state.record_error(strategy:, error:)
184
- log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
185
- end
186
-
187
- def fetch_response(request_session:, strategy:, next_strategy:, state:)
188
- request_session.fetch_initial_response
189
- rescue *NON_FALLBACK_ERRORS
190
- 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!
191
218
  rescue StandardError => error
192
- state.record_error(strategy:, error:)
193
- log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
194
- 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
195
221
  end
196
222
 
197
- # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
198
- def process_response(response:, strategy:, next_strategy:, request_session:, state:)
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)
199
225
  articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
200
226
  config:, response:, request_session:
201
227
  )
202
228
  state.remember_response(response)
203
229
  items_count = articles.size
204
- 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:)
205
231
  Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
206
232
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
207
233
  "budget_remaining=#{budget_remaining_label}")
@@ -249,30 +275,7 @@ module Html2rss
249
275
  }.to_json)
250
276
  end
251
277
 
252
- def finalize_failure(state)
253
- error = failure_error(state)
254
- if error
255
- error.attempts = state.attempts if error.is_a?(Html2rss::Error)
256
- raise error
257
- end
258
-
259
- response = state.last_response
260
- raise NoFeedItemsExtracted.new(
261
- attempts: state.attempts,
262
- surface_category: (PageRecon.surface_category_for(response:, url: response.url) if response)
263
- )
264
- end
265
-
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
270
-
271
- error = state.last_error
272
- return if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
273
-
274
- error
275
- end
278
+ def monotonic_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
276
279
 
277
280
  # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
278
281
  def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
@@ -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 [StandardError]
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 session (+ :initial + only).
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.32.3'
5
+ VERSION = '0.32.4'
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.3
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.3
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: