Skip to content

Commit 6b0b2c0

Browse files
thatbudakguyclaude
andcommitted
Retry failed harvest requests, and fail loudly when they don't recover
Both response classes rescued every error while paging, nil'd the response, and left their loop, so #index returned normally after a single transient failure: a harvest that indexed 6 of 15 documents exited 0 and looked like a success. Individual document failures were dropped the same way, by compacting them out of the page. Requests now go through retries with an exponential backoff for failures that tend to be transient -- connection resets, broken pipes, timeouts, TLS errors, 5xx, rate limiting -- and a request that still can't be completed raises GeoCombine::Exceptions::HarvestError. A 404 for a single document is logged and skipped instead, since a record can be indexed but unreadable. Responses are checked rather than trusted, since a status code alone can't tell a page of records from a rejection: - a 200 carrying HTML, or any body that isn't a JSON object, is treated as a failure worth retrying rather than parsed as a document - every page is validated as a page of search results, not just the first one through the response factory; a 200 carrying JSON that isn't one used to read as "no more results" and end the harvest - a result with no link to itself, and a page whose every document was skipped, are logged rather than quietly dropped Closes #208 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7c58bcd commit 6b0b2c0

4 files changed

Lines changed: 398 additions & 45 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ GeoCombine::GeoBlacklightHarvester.configure do
171171
crawl_delay: 1, # All sites
172172
debug: true,
173173
headers: { 'User-Agent' => 'GeoCombine harvester (you@example.edu)' }, # All sites
174+
max_retries: 3, # All sites
175+
retry_delay: 2, # All sites
174176
SITE1: {
175177
crawl_delay: 2, # SITE1 only
176178
headers: { 'X-Api-Key' => 'secret' }, # SITE1 only
@@ -201,6 +203,12 @@ Be aware that this makes a harvest take considerably longer than it did when the
201203

202204
Headers can be configured either globally for all sites or on a per-site basis, and are sent with every request the harvester makes; headers configured for a site are merged over the global ones. This is one way to get the harvester past a firewall or bot detection (at Stanford, for example, requests carrying a particular header skip Turnstile), and it can also be used to authenticate the harvester. Configuring a `User-Agent` is worthwhile even if you need neither: it identifies your harvester to the sites you harvest, and lets GeoBlacklight's `crawler_detector` recognize it as a bot.
203205

206+
##### Retries (default: 3 retries, starting with a 2 second delay)
207+
208+
Requests that fail in ways that tend to be transient are retried with an exponential backoff: connection resets, broken pipes, timeouts, TLS errors, 5xx responses, rate limiting, and responses that come back with a 200 but aren't JSON (bot mitigation often answers a crawler with a page of HTML). How many times to retry and the delay to start doubling from can be configured globally or per site with `max_retries` and `retry_delay`.
209+
210+
A request that still can't be completed raises `GeoCombine::Exceptions::HarvestError`, so a harvest that couldn't finish exits non-zero instead of looking like a success that happened to index part of the site. Every page of results is checked, not just the first: a 200 whose JSON isn't a page of search results, which is how a WAF or API gateway rejection often arrives, raises rather than reading as the end of the results. The one failure that is logged and skipped instead is a 404 for an individual document, since a record can be indexed but unreadable.
211+
204212
##### Solr's commitWithin (default: 5000 milliseconds)
205213

206214
Solr's commitWithin option can be configured (in milliseconds) by passing a value under the commit_within key.

lib/geo_combine/exceptions.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,13 @@ class InvalidGeometry < StandardError
1010

1111
class InvalidSchemaVersion < StandardError
1212
end
13+
14+
# A harvest could not be completed and should not be treated as a success
15+
class HarvestError < StandardError
16+
end
17+
18+
# An individual document could not be found and can be skipped
19+
class DocumentNotFound < HarvestError
20+
end
1321
end
1422
end

lib/geo_combine/geo_blacklight_harvester.rb

Lines changed: 130 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

33
require 'net/http'
4+
require 'openssl'
5+
require 'geo_combine/exceptions'
46
require 'geo_combine/logger'
57

68
module GeoCombine
@@ -16,6 +18,7 @@ module GeoCombine
1618
# - A debug parameter to print out details of what is being harvested and indexed
1719
# - crawl delays between requests (globally or on a per site basis)
1820
# - headers to send with every request (globally or on a per site basis)
21+
# - retries and backoff for failed requests (globally or on a per site basis)
1922
# - Solr's commitWithin parameter (defaults to 5000)
2023
# - A document transformer proc to modify a document before indexing (defaults to removing _version_, score, and timestamp)
2124
# Example: GeoCombine::GeoBlacklightHarvester.new('SITE').index
@@ -82,25 +85,46 @@ def each_document(&block)
8285
# one; a request that is both paced and freshly connected is much less
8386
# likely to be turned away by a WAF or other bot mitigation.
8487
class HttpClient
85-
attr_reader :crawl_delay, :headers
86-
87-
def initialize(crawl_delay: nil, headers: {}, logger: GeoCombine::Logger.logger)
88+
# Failures that are worth retrying rather than ending a harvest over
89+
RETRIABLE_ERRORS = [
90+
Errno::ECONNRESET,
91+
Errno::EPIPE,
92+
EOFError,
93+
Net::OpenTimeout,
94+
Net::ReadTimeout,
95+
OpenSSL::SSL::SSLError,
96+
SocketError
97+
].freeze
98+
99+
DEFAULT_MAX_RETRIES = 3
100+
DEFAULT_RETRY_DELAY = 2
101+
102+
# Raised internally to signal a response that is worth retrying
103+
class RetriableResponse < StandardError; end
104+
105+
attr_reader :crawl_delay, :headers, :max_retries, :retry_delay
106+
107+
def initialize(crawl_delay: nil, headers: {}, max_retries: nil, retry_delay: nil,
108+
logger: GeoCombine::Logger.logger)
88109
@crawl_delay = crawl_delay&.to_f
89110
@headers = headers.to_h { |name, value| [name.to_s, value.to_s] }
111+
@max_retries = (max_retries || DEFAULT_MAX_RETRIES).to_i
112+
@retry_delay = (retry_delay || DEFAULT_RETRY_DELAY).to_f
90113
@logger = logger
91114
end
92115

93-
# Fetch a URL and parse the JSON response body
116+
# Fetch a URL and parse the JSON response body, retrying failures that
117+
# look transient and raising if the harvest cannot go on
94118
def get_json(url)
95-
JSON.parse(get(url))
119+
with_retries(url) { parse_json(get(url), url) }
96120
end
97121

98122
private
99123

100-
# Fetch a URL and return the response body
124+
# Fetch a URL and return the response
101125
def get(url)
102126
throttle
103-
Net::HTTP.get_response(URI(url), headers).body
127+
Net::HTTP.get_response(URI(url), headers)
104128
end
105129

106130
# Wait out the crawl delay, if one is configured
@@ -110,6 +134,51 @@ def throttle
110134
@logger.debug "waiting #{crawl_delay}s before the next request"
111135
sleep(crawl_delay)
112136
end
137+
138+
# Retry transient failures with an exponential backoff. A request that
139+
# still cannot be completed ends the harvest rather than quietly
140+
# truncating it.
141+
def with_retries(url)
142+
attempts = 0
143+
144+
begin
145+
attempts += 1
146+
yield
147+
rescue *RETRIABLE_ERRORS, RetriableResponse => e
148+
raise Exceptions::HarvestError, "request for #{url} failed after #{attempts} attempts: #{e.message}" if attempts > max_retries
149+
150+
delay = retry_delay * (2**(attempts - 1))
151+
@logger.warn "request for #{url} failed with #{e.message}; retrying in #{delay}s"
152+
sleep(delay)
153+
retry
154+
end
155+
end
156+
157+
# Parse a response body, or raise if it isn't a document we can use.
158+
# Bot mitigation often answers with a 200 and a page of HTML, so the
159+
# status code alone isn't enough to tell a good response from a bad one.
160+
def parse_json(response, url)
161+
raise Exceptions::DocumentNotFound, "#{url} was not found" if response.is_a?(Net::HTTPNotFound)
162+
raise RetriableResponse, status(response) if retriable?(response)
163+
164+
raise Exceptions::HarvestError, "request for #{url} failed with #{status(response)}" unless response.is_a?(Net::HTTPSuccess)
165+
166+
parsed = JSON.parse(response.body.to_s)
167+
raise RetriableResponse, 'a JSON body that is not an object' unless parsed.is_a?(Hash)
168+
169+
parsed
170+
rescue JSON::ParserError
171+
raise RetriableResponse, "a body that is not JSON (content type: #{response.content_type || 'none'})"
172+
end
173+
174+
# Server errors and rate limiting are worth waiting out and retrying
175+
def retriable?(response)
176+
response.is_a?(Net::HTTPServerError) || response.is_a?(Net::HTTPTooManyRequests)
177+
end
178+
179+
def status(response)
180+
"#{response.code} #{response.message}"
181+
end
113182
end
114183

115184
##
@@ -150,18 +219,23 @@ def documents
150219

151220
self.page += 1
152221
@logger.debug "fetching page #{page} @ #{url}"
153-
154-
begin
155-
self.response = client.get_json(url)
156-
rescue StandardError => e
157-
@logger.error "request for #{url} failed with #{e}"
158-
self.response = nil
159-
end
222+
self.response = client.get_json(url)
223+
validate_page!
160224
end
161225
end
162226

163227
private
164228

229+
# Only the first page of a harvest goes through the response factory. A
230+
# 200 carrying JSON that isn't a page of search results -- a WAF or API
231+
# gateway rejection, say -- would otherwise read as the end of the
232+
# results and finish the harvest as though nothing had gone wrong.
233+
def validate_page!
234+
return if current_page && total_pages
235+
236+
raise Exceptions::HarvestError, "response for #{url} was not a page of search results"
237+
end
238+
165239
def url
166240
"#{base_url}&page=#{page}"
167241
end
@@ -193,7 +267,7 @@ def documents
193267
return enum_for(:documents) unless block_given?
194268

195269
while response && response['data'].any?
196-
document_urls = response['data'].collect { |data| data.dig('links', 'self') }.compact
270+
document_urls = document_urls_from(response['data'])
197271

198272
yield documents_from_urls(document_urls)
199273

@@ -203,26 +277,47 @@ def documents
203277
url = "#{url}&format=json"
204278
self.page += 1
205279
@logger.debug "fetching page #{page} @ #{url}"
206-
begin
207-
self.response = client.get_json(url)
208-
rescue StandardError => e
209-
@logger.error "Request for #{url} failed with #{e}"
210-
self.response = nil
211-
end
280+
self.response = client.get_json(url)
281+
validate_page!(url)
212282
end
213283
end
214284

215285
private
216286

287+
# Only the first page of a harvest goes through the response factory. A
288+
# 200 carrying JSON that isn't a page of search results -- a WAF or API
289+
# gateway rejection, say -- would otherwise read as the end of the
290+
# results and finish the harvest as though nothing had gone wrong.
291+
def validate_page!(url)
292+
return if response['data'].is_a?(Array)
293+
294+
raise Exceptions::HarvestError, "response for #{url} was not a page of search results"
295+
end
296+
297+
# A result with no link to itself can't be fetched; say which one rather
298+
# than letting it disappear from the harvest
299+
def document_urls_from(data)
300+
data.filter_map do |result|
301+
url = result.dig('links', 'self')
302+
@logger.warn "skipping result with no self link: #{result.inspect}" unless url
303+
304+
url
305+
end
306+
end
307+
217308
def documents_from_urls(urls)
218309
@logger.debug "fetching #{urls.count} documents for page #{page}"
219-
urls.map do |url|
310+
documents = urls.map do |url|
220311
client.get_json("#{url}/raw")
221-
rescue StandardError => e
222-
@logger.error "fetching \"#{url}/raw\" failed with #{e}"
312+
rescue Exceptions::DocumentNotFound => e
313+
# A record can be indexed but unreadable; log it and move on
314+
@logger.warn "skipping document: #{e.message}"
223315

224316
nil
225317
end.compact
318+
@logger.error "skipped every document on page #{page}" if documents.empty? && urls.any?
319+
320+
documents
226321
end
227322
end
228323

@@ -243,7 +338,7 @@ def each_page
243338

244339
# The client used to make requests for this site
245340
def client
246-
@client ||= HttpClient.new(crawl_delay:, headers:, logger: @logger)
341+
@client ||= HttpClient.new(crawl_delay:, headers:, max_retries:, retry_delay:, logger: @logger)
247342
end
248343

249344
def base_url
@@ -264,6 +359,16 @@ def crawl_delay
264359
site[:crawl_delay] || self.class.config[:crawl_delay]
265360
end
266361

362+
# How many times to retry a request that fails transiently
363+
def max_retries
364+
site[:max_retries] || self.class.config[:max_retries]
365+
end
366+
367+
# How long to wait before the first retry; it doubles with each attempt
368+
def retry_delay
369+
site[:retry_delay] || self.class.config[:retry_delay]
370+
end
371+
267372
# Headers to send with every request, e.g. to identify the harvester to a
268373
# WAF or bot detection, or to authenticate it. Headers configured for the
269374
# site are merged over any configured globally.

0 commit comments

Comments
 (0)