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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e095abe52bc258d262e3a24909e0feb480b2eb1d537e57580fc271203b0c6dbc
4
- data.tar.gz: 8fdabe7ed5afee08c46ab94c2b6f80c1f8c66df9ffec9439e40e80fa897bfa96
3
+ metadata.gz: a53d8374b6b4ce7551c9f1bffd0e14960995f7df7380554f1292e0a70317cf58
4
+ data.tar.gz: 90e91d4275c03c120924cca66230a7aecb3606f2f50c6224d019c138cae244e2
5
5
  SHA512:
6
- metadata.gz: fdcce48a2a4fb3a8c3ea02fd8f4bb799bd8ed7acb4e66e74da3cde693f6a51cc3aa72d363670b13f077d1521d97321e0676d14b59c6b61f1c685fdc4db64cb67
7
- data.tar.gz: ce1c1cac4c38f4660e61cb7c9506991d8c0d847c8bdfbb583c678680854b7327a16b9aa637af641aaae42bed9f15b9330cc344ee922252da16863a517569fad4
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.
@@ -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
- attempt[:transport_meta] = meta if meta&.any?
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
- attempt = { strategy:, items_count:, error_class: nil }
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
- finalize_failure(state)
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 = fetch_response(request_session:, strategy:, next_strategy:, state:)
166
- return unless response
167
-
168
- process_response(response:, strategy:, next_strategy:, request_session:, state:)
169
- rescue RequestService::UnsupportedResponseContentType => error
170
- state.record_error(strategy:, error:)
171
- log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
172
- end
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 = articles_for(response:, request_session:)
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 deterministic_status?(response)
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 finalize_failure(state)
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
- true
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
- 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
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
- # 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
@@ -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.2'
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.2
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.2
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: