mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-09-25 07:24:55 -04:00
* Add rake tasks to pull crops and a member's data from production crops:pull_from_production copies approved crops, and members:pull_from_production copies one member's gardens, plantings and planting photos, from growstuff.org into a local database. Both refuse to run in a production environment. They are built to be gentle on a server that has been struggling: one request at a time with a pause between requests, exponential backoff (honouring Retry-After) on timeouts, 429s and 5xx, and a hard stop rather than endless retries. The client also stops if a server keeps sending the same page, and will not follow a next link to another host. Gardens and plantings come from the site's own JSON, which carries every column; the JSON:API only exposes a garden's name, and its related resource endpoints return the wrong records. Only the crops that a member's plantings use are fetched. Flickr photos are stored as records pointing at Flickr's image URLs, as on production, so nothing is downloaded from Flickr. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Add ACTIVE_ONLY option to the member import ACTIVE_ONLY=true leaves out the site's all=1 flag, so production lists only the member's active gardens and current (not finished, not failed) plantings. The server does the filtering, so it also means fewer requests, and no crops or photos are fetched for plantings that are skipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
161 lines
5.5 KiB
Ruby
161 lines
5.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# A deliberately gentle client for another Growstuff instance's public JSON
|
|
# (production by default), for use by the local import tasks.
|
|
#
|
|
# Built for a server that may already be struggling:
|
|
# * one request at a time, with a pause before every request after the first
|
|
# * exponential backoff (or Retry-After, if the server sends it) on timeouts,
|
|
# 429s and 5xx responses
|
|
# * a hard stop (Aborted) rather than endless retries when a request keeps failing
|
|
# * a hard stop if the server keeps sending the same page, or too many pages
|
|
#
|
|
# Two kinds of endpoint are supported:
|
|
# * JSON:API (/api/v1/...), paged by following the server's own `links.next`
|
|
# * the site's plain JSON (/members/x/gardens.json), paged with ?page=N
|
|
class RemoteApiClient
|
|
class Aborted < StandardError; end
|
|
|
|
DEFAULT_SOURCE_URL = 'https://www.growstuff.org'
|
|
USER_AGENT = 'Growstuff local import (https://github.com/Growstuff/growstuff)'
|
|
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
MAX_RETRY_AFTER = 120
|
|
MAX_PAGES = 500
|
|
|
|
# rubocop:disable-next Metrics/ParameterLists
|
|
def initialize(source_url: DEFAULT_SOURCE_URL, page_size: 50, delay: 2, backoff: 5, max_retries: 3,
|
|
out: $stdout, connection: nil, sleeper: ->(seconds) { sleep(seconds) })
|
|
@page_size = page_size
|
|
@delay = delay
|
|
@backoff = backoff
|
|
@max_retries = max_retries
|
|
@out = out
|
|
@sleeper = sleeper
|
|
@connection = connection || build_connection(source_url)
|
|
@requests_made = 0
|
|
end
|
|
|
|
# JSON:API list. Yields each page of resources and a 1-based page count.
|
|
# The server may cap the page size below what we ask for, so we go by its
|
|
# `links.next` rather than by counting.
|
|
def each_page(path, params = {}, max_pages: nil)
|
|
url = path
|
|
query = { 'page[limit]' => @page_size, 'sort' => 'id' }.merge(params)
|
|
seen_urls = Set.new
|
|
fetched = 0
|
|
|
|
loop do
|
|
body = request(url, query)
|
|
resources = jsonapi_data(body, url)
|
|
fetched += 1
|
|
yield resources, fetched
|
|
|
|
next_url = body.dig('links', 'next')
|
|
break if resources.empty? || next_url.blank? || (max_pages && fetched >= max_pages)
|
|
|
|
guard_against_loops!(url, seen_urls, next_url, fetched)
|
|
url = next_url
|
|
query = nil
|
|
end
|
|
end
|
|
|
|
def all(path, params = {})
|
|
resources = []
|
|
each_page(path, params) { |page_resources, _page| resources.concat(page_resources) }
|
|
resources
|
|
end
|
|
|
|
# A single JSON:API resource, or nil if the server says it does not exist.
|
|
def get_resource(path)
|
|
body = request(path, {}, allow_missing: true)
|
|
body && jsonapi_data(body, path)
|
|
end
|
|
|
|
# The site's plain JSON lists (an array per page, ?page=N). Yields each page
|
|
# of rows and the page number, stopping at the first empty (or missing) page.
|
|
def each_site_page(path, params = {}, max_pages: nil)
|
|
previous_ids = nil
|
|
page = 1
|
|
|
|
loop do
|
|
rows = request(path, params.merge('page' => page), allow_missing: page > 1)
|
|
break if rows.blank?
|
|
raise Aborted, "#{path} sent the same page twice; stopping." if rows.pluck('id') == previous_ids
|
|
|
|
yield rows, page
|
|
previous_ids = rows.pluck('id')
|
|
page += 1
|
|
break if max_pages && page > max_pages
|
|
raise Aborted, "#{path} went past #{MAX_PAGES} pages; stopping." if page > MAX_PAGES
|
|
end
|
|
end
|
|
|
|
def say(message)
|
|
@out.puts message
|
|
end
|
|
|
|
private
|
|
|
|
def build_connection(source_url)
|
|
Faraday.new(url: source_url, headers: { 'User-Agent' => USER_AGENT, 'Accept' => 'application/vnd.api+json' }) do |f|
|
|
f.options.open_timeout = 5
|
|
f.options.timeout = 15
|
|
f.adapter Faraday.default_adapter
|
|
end
|
|
end
|
|
|
|
def jsonapi_data(body, url)
|
|
raise Aborted, "#{url} returned an unexpected response." unless body.is_a?(Hash) && body.key?('data')
|
|
|
|
body['data']
|
|
end
|
|
|
|
def guard_against_loops!(url, seen_urls, next_url, fetched)
|
|
raise Aborted, "#{url} keeps linking to the same next page; stopping." if next_url == url || !seen_urls.add?(next_url)
|
|
raise Aborted, "#{url} went past #{MAX_PAGES} pages; stopping." if fetched >= MAX_PAGES
|
|
|
|
host = @connection.url_prefix.host
|
|
return if host.nil? || URI.parse(next_url).host == host
|
|
|
|
raise Aborted, "#{url} linked to another host (#{next_url}); not following it."
|
|
end
|
|
|
|
def request(url, query, allow_missing: false)
|
|
@sleeper.call(@delay) if @requests_made.positive?
|
|
@requests_made += 1
|
|
|
|
(0..@max_retries).each do |attempt|
|
|
response = get(url, query)
|
|
return JSON.parse(response.body) if response&.success?
|
|
return nil if allow_missing && response&.status == 404
|
|
|
|
problem = response ? "HTTP #{response.status}" : 'network error'
|
|
raise Aborted, "#{url} failed with #{problem}. Not retrying." unless response.nil? || RETRYABLE_STATUSES.include?(response.status)
|
|
|
|
if attempt == @max_retries
|
|
raise Aborted, "#{url} still failing (#{problem}) after #{@max_retries} retries. " \
|
|
'Leaving the server alone; try again later.'
|
|
end
|
|
|
|
wait = retry_delay(response, attempt)
|
|
say "#{url}: #{problem}, waiting #{wait}s before retry #{attempt + 1}/#{@max_retries}"
|
|
@sleeper.call(wait)
|
|
end
|
|
rescue JSON::ParserError
|
|
raise Aborted, "#{url} returned something that is not JSON."
|
|
end
|
|
|
|
def get(url, query)
|
|
@connection.get(url, query)
|
|
rescue Faraday::TimeoutError, Faraday::ConnectionFailed
|
|
nil
|
|
end
|
|
|
|
def retry_delay(response, attempt)
|
|
retry_after = response&.headers&.[]('Retry-After').to_i
|
|
return [retry_after, MAX_RETRY_AFTER].min if retry_after.positive?
|
|
|
|
@backoff * (2**attempt)
|
|
end
|
|
end
|