Skip to content

Commit 5bb3b9d

Browse files
committed
Align backend with html2rss auto fallback
1 parent d5ea2ac commit 5bb3b9d

18 files changed

Lines changed: 418 additions & 69 deletions

app/web/api/v1/contract.rb

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ module Contract # rubocop:disable Metrics/ModuleLength
1010
CODES = {
1111
unauthorized: Html2rss::Web::UnauthorizedError::CODE,
1212
forbidden: Html2rss::Web::ForbiddenError::CODE,
13-
internal_server_error: Html2rss::Web::InternalServerError::CODE
13+
internal_server_error: Html2rss::Web::InternalServerError::CODE,
14+
extraction_empty: 'EXTRACTION_EMPTY'
1415
}.freeze
1516

1617
ERROR_KINDS = {
@@ -50,7 +51,10 @@ module Contract # rubocop:disable Metrics/ModuleLength
5051

5152
MESSAGES = {
5253
auto_source_disabled: 'Auto source feature is disabled',
53-
health_check_failed: 'Health check failed'
54+
health_check_failed: 'Health check failed',
55+
extraction_empty:
56+
'We could not extract feed items from this page yet. ' \
57+
'Try a more specific listing URL or explicit selectors.'
5458
}.freeze
5559

5660
class << self
@@ -92,14 +96,26 @@ def warning(code:, message:, retryable:, next_action:)
9296
# @param error [StandardError]
9397
# @return [Hash{Symbol=>Object}]
9498
def failure_metadata(error)
95-
case error
96-
when Html2rss::Web::AutoSourceDisabledError, Html2rss::Web::HealthCheckFailedError
97-
non_retryable_server_failure_metadata
98-
when Html2rss::Web::UnauthorizedError then auth_failure_metadata
99-
when Html2rss::Web::BadRequestError, Html2rss::Web::ForbiddenError then input_failure_metadata
100-
else
101-
generic_failure_metadata(error)
102-
end
99+
return input_failure_metadata if input_failure?(error)
100+
return non_retryable_server_failure_metadata if non_retryable_server_failure?(error)
101+
return auth_failure_metadata if error.is_a?(Html2rss::Web::UnauthorizedError)
102+
103+
generic_failure_metadata(error)
104+
end
105+
106+
# @param error [StandardError]
107+
# @return [Boolean]
108+
def input_failure?(error)
109+
Html2rss::Web::ErrorClassification.auto_fallback_exhausted?(error) ||
110+
error.is_a?(Html2rss::Web::BadRequestError) ||
111+
error.is_a?(Html2rss::Web::ForbiddenError)
112+
end
113+
114+
# @param error [StandardError]
115+
# @return [Boolean]
116+
def non_retryable_server_failure?(error)
117+
error.is_a?(Html2rss::Web::AutoSourceDisabledError) ||
118+
error.is_a?(Html2rss::Web::HealthCheckFailedError)
103119
end
104120

105121
# @param error [StandardError]
@@ -147,14 +163,24 @@ def non_retryable_server_failure_metadata
147163
# @param error [StandardError]
148164
# @return [String]
149165
def client_message_for(error)
166+
return MESSAGES[:extraction_empty] if extraction_empty_failure?(error)
167+
150168
error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE
151169
end
152170

153171
# @param error [StandardError]
154172
# @return [String]
155173
def error_code_for(error)
174+
return CODES[:extraction_empty] if extraction_empty_failure?(error)
175+
156176
error.respond_to?(:code) ? error.code : CODES[:internal_server_error]
157177
end
178+
179+
# @param error [StandardError]
180+
# @return [Boolean]
181+
def extraction_empty_failure?(error)
182+
Html2rss::Web::ErrorClassification.auto_fallback_exhausted?(error)
183+
end
158184
end
159185
end
160186
end

app/web/api/v1/feed_status.rb

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,7 @@ def conversion_attributes(result) # rubocop:disable Metrics/MethodLength
8282
warnings: []
8383
}
8484
when :empty
85-
{
86-
readiness_phase: Contract::READINESS_PHASES[:feed_ready],
87-
preview_status: Contract::PREVIEW_STATUSES[:degraded],
88-
warnings: [
89-
Contract.warning(
90-
code: 'preview_partial',
91-
message: 'Preview content could not be fully verified.',
92-
retryable: true,
93-
next_action: Contract::NEXT_ACTIONS[:retry]
94-
)
95-
]
96-
}
85+
empty_conversion_attributes(result)
9786
when :error
9887
if result.error_kind == :network
9988
transient_conversion_attributes
@@ -105,6 +94,46 @@ def conversion_attributes(result) # rubocop:disable Metrics/MethodLength
10594
end
10695
end
10796

97+
# @param result [Html2rss::Web::Feeds::Contracts::RenderResult]
98+
# @return [Hash{Symbol=>Object}]
99+
def empty_conversion_attributes(result)
100+
return extraction_empty_conversion_attributes if result.error_kind == :extraction_empty
101+
102+
degraded_empty_conversion_attributes
103+
end
104+
105+
# @return [Hash{Symbol=>Object}]
106+
def degraded_empty_conversion_attributes # rubocop:disable Metrics/MethodLength
107+
{
108+
readiness_phase: Contract::READINESS_PHASES[:feed_ready],
109+
preview_status: Contract::PREVIEW_STATUSES[:degraded],
110+
warnings: [
111+
Contract.warning(
112+
code: 'preview_partial',
113+
message: 'Preview content could not be fully verified.',
114+
retryable: true,
115+
next_action: Contract::NEXT_ACTIONS[:retry]
116+
)
117+
]
118+
}
119+
end
120+
121+
# @return [Hash{Symbol=>Object}]
122+
def extraction_empty_conversion_attributes # rubocop:disable Metrics/MethodLength
123+
{
124+
readiness_phase: Contract::READINESS_PHASES[:preview_unavailable],
125+
preview_status: Contract::PREVIEW_STATUSES[:unavailable],
126+
warnings: [
127+
Contract.warning(
128+
code: 'preview_partial',
129+
message: Contract::MESSAGES[:extraction_empty],
130+
retryable: false,
131+
next_action: Contract::NEXT_ACTIONS[:correct_input]
132+
)
133+
]
134+
}
135+
end
136+
108137
# @return [Hash{Symbol=>Object}]
109138
def transient_conversion_attributes # rubocop:disable Metrics/MethodLength
110139
{

app/web/domain/auto_source.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ def enabled?
1919
# @param name [String, nil]
2020
# @param url [String]
2121
# @param token_data [Hash{Symbol=>Object}] authenticated account data.
22-
# @param strategy [String]
22+
# @param strategy [String, nil]
2323
# @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata, nil]
24-
def create_stable_feed(name, url, token_data, strategy = Html2rss::RequestService.default_strategy_name.to_s)
24+
def create_stable_feed(name, url, token_data, strategy = nil)
2525
return nil unless token_data && FeedAccess.url_allowed_for_username?(token_data[:username], url)
2626

2727
feed_token = Auth.generate_feed_token(token_data[:username], url, strategy: strategy)
@@ -35,7 +35,7 @@ def create_stable_feed(name, url, token_data, strategy = Html2rss::RequestServic
3535
# @param name [String, nil]
3636
# @param url [String]
3737
# @param token_data [Hash{Symbol=>Object}]
38-
# @param strategy [String]
38+
# @param strategy [String, nil]
3939
# @param feed_token [String]
4040
# @return [Hash{Symbol=>Object}]
4141
def metadata_attributes(name, url, token_data, strategy, feed_token)

app/web/error_classification.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ def network_error?(error)
2323
unwrap(error).any? { |candidate| NETWORK_ERROR_CLASS_NAMES.include?(candidate.class.name) }
2424
end
2525

26+
# @param error [StandardError, nil]
27+
# @return [Boolean]
28+
def auto_fallback_exhausted?(error)
29+
return false unless defined?(::Html2rss::NoFeedItemsExtracted)
30+
31+
unwrap(error).any?(::Html2rss::NoFeedItemsExtracted)
32+
end
33+
2634
private
2735

2836
# @param error [StandardError, nil]

app/web/errors/error_responder.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,33 @@ def error_log_line(request, error)
8787
# @param error [StandardError]
8888
# @return [String]
8989
def resolve_error_code(error)
90+
return Api::V1::Contract::CODES[:extraction_empty] if extraction_empty_failure?(error)
91+
9092
error.respond_to?(:code) ? error.code : INTERNAL_ERROR_CODE
9193
end
9294

9395
# @param error [StandardError]
9496
# @return [Integer]
9597
def resolve_status(error)
98+
return 422 if extraction_empty_failure?(error)
99+
96100
error.respond_to?(:status) ? error.status : 500
97101
end
98102

99103
# @param error [StandardError]
100104
# @return [String]
101105
def client_message_for(error)
106+
return Api::V1::Contract::MESSAGES[:extraction_empty] if extraction_empty_failure?(error)
107+
102108
error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE
103109
end
104110

111+
# @param error [StandardError]
112+
# @return [Boolean]
113+
def extraction_empty_failure?(error)
114+
Html2rss::Web::ErrorClassification.auto_fallback_exhausted?(error)
115+
end
116+
105117
# @param request [Rack::Request]
106118
# @param error [StandardError]
107119
# @return [void]

app/web/feeds/responder.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def render_result(result, representation)
9393
# @return [void]
9494
def emit_result(target_kind:, identifier:, resolved_source:, result:)
9595
return emit_success(target_kind:, identifier:, resolved_source:) if result.status == :ok
96-
return emit_empty(target_kind:, identifier:, resolved_source:) if result.status == :empty
96+
return emit_empty(target_kind:, identifier:, resolved_source:, result:) if result.status == :empty
9797

9898
emit_failure(
9999
target_kind:,
@@ -110,9 +110,10 @@ def emit_result(target_kind:, identifier:, resolved_source:, result:)
110110
# @return [void]
111111
def emit_success(target_kind:, identifier:, resolved_source:)
112112
details = {
113-
strategy: resolved_source.generator_input[:strategy],
114113
url: resolved_source.generator_input.dig(:channel, :url)
115114
}
115+
strategy = resolved_source.generator_input[:strategy]
116+
details[:strategy] = strategy if strategy
116117
details[:feed_name] = identifier if target_kind == :static
117118

118119
Observability.emit(event_name: 'feed.render', outcome: 'success', details:, level: :info)
@@ -121,18 +122,28 @@ def emit_success(target_kind:, identifier:, resolved_source:)
121122
# @param target_kind [Symbol]
122123
# @param identifier [String]
123124
# @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource]
125+
# @param result [Html2rss::Web::Feeds::Contracts::RenderResult]
124126
# @return [void]
125-
def emit_empty(target_kind:, identifier:, resolved_source:)
127+
def emit_empty(target_kind:, identifier:, resolved_source:, result:)
126128
details = {
127-
strategy: resolved_source.generator_input[:strategy],
128129
url: resolved_source.generator_input.dig(:channel, :url),
129-
reason: 'content_extraction_empty'
130+
reason: empty_reason_for(result)
130131
}
132+
strategy = resolved_source.generator_input[:strategy]
133+
details[:strategy] = strategy if strategy
131134
details[:feed_name] = identifier if target_kind == :static
132135

133136
Observability.emit(event_name: 'feed.render', outcome: 'failure', details:, level: :warn)
134137
end
135138

139+
# @param result [Html2rss::Web::Feeds::Contracts::RenderResult]
140+
# @return [String]
141+
def empty_reason_for(result)
142+
return 'content_extraction_empty' if result.error_kind == :extraction_empty
143+
144+
'feed_empty'
145+
end
146+
136147
# @param target_kind [Symbol]
137148
# @param identifier [String]
138149
# @param error [StandardError]

app/web/feeds/service.rb

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def build_result(resolved_source, cache_key)
3030
feed = Html2rss.feed(resolved_source.generator_input)
3131
success_result(feed, resolved_source, cache_key)
3232
rescue StandardError => error
33+
return empty_result(error, resolved_source, cache_key) if extraction_empty_error?(error)
34+
3335
error_result(error, resolved_source, cache_key)
3436
end
3537

@@ -69,7 +71,7 @@ def payload_for(feed, resolved_source)
6971
feed: feed,
7072
site_title: site_title_for(feed, resolved_source.generator_input.dig(:channel, :url)),
7173
url: resolved_source.generator_input.dig(:channel, :url),
72-
strategy: resolved_source.generator_input[:strategy].to_s
74+
strategy: resolved_source.generator_input[:strategy]&.to_s
7375
)
7476
end
7577

@@ -98,6 +100,39 @@ def error_result(error, resolved_source, cache_key)
98100
error_kind: Html2rss::Web::ErrorClassification.network_error?(error) ? :network : :server
99101
)
100102
end
103+
104+
# @param error [Html2rss::NoFeedItemsExtracted]
105+
# @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource]
106+
# @param cache_key [String]
107+
# @return [Html2rss::Web::Feeds::Contracts::RenderResult]
108+
def empty_result(error, resolved_source, cache_key)
109+
Contracts::RenderResult.new(
110+
status: :empty,
111+
payload: payload_for_empty_result(resolved_source),
112+
message: nil,
113+
ttl_seconds: resolved_source.ttl_seconds,
114+
cache_key: cache_key,
115+
error_message: error.message,
116+
error_kind: :extraction_empty
117+
)
118+
end
119+
120+
# @param error [StandardError]
121+
# @return [Boolean]
122+
def extraction_empty_error?(error)
123+
Html2rss::Web::ErrorClassification.auto_fallback_exhausted?(error)
124+
end
125+
126+
# @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource]
127+
# @return [Html2rss::Web::Feeds::Contracts::RenderPayload]
128+
def payload_for_empty_result(resolved_source)
129+
Contracts::RenderPayload.new(
130+
feed: nil,
131+
site_title: resolved_source.generator_input.dig(:channel, :url).to_s,
132+
url: resolved_source.generator_input.dig(:channel, :url),
133+
strategy: resolved_source.generator_input[:strategy]&.to_s
134+
)
135+
end
101136
end
102137
end
103138
end

app/web/feeds/source_resolver.rb

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ def static_cache_identity(feed_name, params)
6969
def static_generator_input(config, params)
7070
generator_input = config.dup
7171
generator_input[:params] = merged_static_params(config, params)
72-
generator_input[:strategy] ||= Html2rss::RequestService.default_strategy_name.to_sym
7372
generator_input
7473
end
7574

@@ -96,27 +95,34 @@ def ensure_auto_source_enabled!
9695
end
9796

9897
# @param feed_token [Html2rss::Web::FeedToken]
99-
# @return [String]
98+
# @return [String, nil]
10099
def resolved_strategy(feed_token)
101100
strategy = feed_token.strategy.to_s.strip
102-
strategy = Html2rss::RequestService.default_strategy_name.to_s if strategy.empty?
101+
return nil if strategy.empty?
102+
return strategy if strategy == default_strategy_name
103+
103104
supported = Html2rss::RequestService.strategy_names.map(&:to_s)
104105
raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported.include?(strategy)
105106

106107
strategy
107108
end
108109

109110
# @param url [String]
110-
# @param strategy [String]
111+
# @param strategy [String, nil]
111112
# @return [Hash{Symbol=>Object}]
112113
def token_generator_input(url, strategy)
113-
LocalConfig.global
114-
.slice(:stylesheets, :headers)
115-
.merge(
116-
strategy: strategy.to_sym,
117-
channel: { url: url },
118-
auto_source: {}
119-
)
114+
global_config = LocalConfig.global
115+
base_input = global_config.slice(:stylesheets, :headers)
116+
generator_input = base_input.merge(channel: { url: url }, auto_source: {})
117+
generator_input[:strategy] = strategy.to_sym if strategy
118+
generator_input
119+
end
120+
121+
# @return [String]
122+
def default_strategy_name
123+
return Html2rss::Config.default_strategy_name.to_s if Html2rss::Config.respond_to?(:default_strategy_name)
124+
125+
'auto'
120126
end
121127
end
122128
end

app/web/security/auth.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ def authenticate(request)
2424

2525
# @param username [String]
2626
# @param url [String]
27-
# @param strategy [String]
27+
# @param strategy [String, nil]
2828
# @param expires_in [Integer] seconds (default: 10 years)
2929
# @return [String, nil] signed feed token when generation succeeds.
30-
def generate_feed_token(username, url, strategy:, expires_in: FeedToken::DEFAULT_EXPIRY)
30+
def generate_feed_token(username, url, strategy: nil, expires_in: FeedToken::DEFAULT_EXPIRY)
3131
token = FeedToken.create_with_validation(
3232
username: username,
3333
url: url,

0 commit comments

Comments
 (0)