discourse/plugins/discourse-patreon/lib/api.rb
Rafael dos Santos Silva bbc2fed22b
FIX: Patreon API pagination bugs causing missing pledges (#39058)
## Summary

- **Nil patron data crash**: Patreon V1 API can return pledge entries
where `relationships.patron.data` is null (deleted/deactivated
accounts). This caused `extract` to raise `NoMethodError`, preventing
`save!` from completing — leaving the pledge store stuck at stale data
(exactly 100 pledges from a previous sync). Fixed with `dig` for safe
nil traversal, skipping entries with missing IDs.
- **Double base URL**: Pagination `next` links from Patreon are absolute
URLs, but `Api.get` was prepending the base URL again
(`https://api.patreon.comhttps://api.patreon.com/...`). Fixed by
detecting absolute URIs and using them directly.
- **Non-incremental saves**: All pages were collected before a single
`save!`. If any page failed, nothing was persisted. Now each page is
saved incrementally — first page clears stale data, subsequent pages
append. Also handles the edge case where the API returns empty data by
explicitly clearing the store so ex-patrons don't keep group access.
2026-04-01 17:18:51 -03:00

69 lines
2 KiB
Ruby

# frozen_string_literal: true
require "json"
module Patreon
class InvalidApiResponse < ::StandardError
end
class Api
ACCESS_TOKEN_INVALID = "dashboard.patreon.access_token_invalid"
INVALID_RESPONSE = "patreon.error.invalid_response"
def self.campaign_data
adapter = ApiVersion.current
get(adapter.campaign_data_url, base_url: adapter.api_base_url)
end
def self.get(uri, base_url: ApiVersion.current.api_base_url)
limiter_hr =
RateLimiter.new(nil, "patreon_api_hr", SiteSetting.max_patreon_api_reqs_per_hr, 1.hour)
limiter_day =
RateLimiter.new(nil, "patreon_api_day", SiteSetting.max_patreon_api_reqs_per_day, 1.day)
limiter_hr.performed! unless limiter_hr.can_perform?
limiter_day.performed! unless limiter_day.can_perform?
if uri.start_with?("http")
full_url = uri
base_url = nil
else
full_url = "#{base_url}#{uri}"
end
Rails.logger.warn("Patreon API request: GET #{full_url}") if SiteSetting.patreon_verbose_log
response =
Faraday.new(
url: base_url,
headers: {
"Authorization" => "Bearer #{SiteSetting.patreon_creator_access_token}",
"User-Agent" => "Discourse Patreon Plugin/#{Discourse::VERSION::STRING}",
},
).get(full_url)
limiter_hr.performed!
limiter_day.performed!
if SiteSetting.patreon_verbose_log
Rails.logger.warn(
"Patreon API response: status=#{response.status} body_size=#{response.body&.size || 0}",
)
end
case response.status
when 200
return JSON.parse response.body
when 401
ProblemCheckTracker[:access_token_invalid].problem!
else
e = Patreon::InvalidApiResponse.new(response.body.presence || "")
e.set_backtrace(caller)
Discourse.warn_exception(e, message: I18n.t(INVALID_RESPONSE), env: { api_uri: uri })
end
{ error: I18n.t(INVALID_RESPONSE) }
end
end
end