0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-07 13:19:19 +08:00
discourse/spec/lib/onebox/mixins/github_api_spec.rb
Régis Hanol 36a8a51ef0
FEATURE: Route all GitHub API requests through one rate-limited client (#40637)
GitHub oneboxes and the discourse-github plugin talked to GitHub's REST
and
GraphQL API with no rate-limit awareness. On busy instances this
exhausted
GitHub's limits (60 requests/hour unauthenticated, 5000 authenticated),
and
because there was no backoff every render kept hitting GitHub and
re-failing
-- which GitHub's docs warn can get an integration banned. The recently
added PR-status onebox multiplied the number of calls and made it far
worse.

GitHub access was also fragmented: the core onebox engines used OpenURI,
the
discourse-github plugin used Octokit, and the discourse-ai bot tools
used
FinalDestination::HTTP -- three HTTP stacks, three tokens, and
inconsistent
(or entirely missing) error and rate-limit handling.

This introduces a single client, Discourse::GithubApi, that every GitHub
data-API request now flows through. It is built on Faraday with the
SSRF-safe
FinalDestination adapter and:

- authenticates per token (Bearer) and returns plain string-keyed Hashes
(get/post) or raw bodies (raw_get) -- one response shape, no
Octokit/Sawyer
- only ever sends the access token to api.github.com and
  raw.githubusercontent.com, rejecting any other absolute URL, so a
  user-derived path can never leak a token to an arbitrary host
- backs off on rate limits both reactively (403/429) and proactively
(when
X-RateLimit-Remaining hits 0), honouring Retry-After /
X-RateLimit-Reset,
via a shared Redis flag (GithubRateLimit) keyed per token so each
token's
  budget and the shared unauthenticated/IP budget back off independently
- short-circuits while backing off without ever sleeping, so onebox
rendering
  and post baking degrade to a plain link instead of blocking a request
- caches ETags and sends If-None-Match, so unchanged resources return
304s
  that do not count against the rate limit

Every caller was moved onto it:

- the 6 core GitHub onebox engines, via a slimmed
Onebox::Mixins::GithubApi
adapter that keeps their public methods and translates client errors
back
to the OpenURI::HTTPError vocabulary they already rescue (engines
unchanged)
- the github_blob raw.githubusercontent.com fetch
- the discourse-github plugin (badges, linkback, permalinks, token
validator),
which no longer uses the octokit and sawyer gems (they stay in the
Gemfile for
the discourse-code-review official plugin, which still depends on them)
- the discourse-ai bot's GitHub tools (search code, diff, file content,
  search files)

Also adds a GithubOneboxBackoff admin problem check that surfaces while
one of
the onebox token identities is backing off -- scoped to the tokens
resolved by
Onebox::GithubAccess (each configured github_onebox_access_tokens entry
plus the
unauthenticated client) so a backoff on the AI bot or linkback token is
not
misattributed to onebox. Its message points admins at the relevant
setting with
the {{setting:...}} link marker, which problem-check messages now expand
too.
Onebox token resolution is centralised in Onebox::GithubAccess, and the
onebox
cache TTL for transient GitHub failures is shortened so they recover
quickly.

GitHub OAuth login, theme git-clone, the inbound webhook, and the
Oneboxer
FinalDestination URL-resolution special-cases for github.com are
intentionally
out of scope -- they are different concerns, not the rate-limited data
API.
2026-06-15 10:59:10 +02:00

182 lines
6.1 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe Onebox::Mixins::GithubApi do
let(:pr_link) { "https://github.com/discourse/discourse/pull/1253" }
let(:pr_api) { "https://api.github.com/repos/discourse/discourse/pulls/1253" }
let(:reset_at) { 30.minutes.from_now.to_i }
def rate_limit_headers(remaining: "0", reset: reset_at, retry_after: nil)
headers = {
"x-ratelimit-limit" => "60",
"x-ratelimit-remaining" => remaining,
"x-ratelimit-reset" => reset.to_s,
}
headers["retry-after"] = retry_after.to_s if retry_after
headers
end
def render_pr
Onebox::Engine::GithubPullRequestOnebox.new(pr_link).to_html
rescue OpenURI::HTTPError
nil
end
context "when GitHub returns a primary rate-limit 403" do
before do
stub_request(:get, pr_api).to_return(
status: [403, "Forbidden"],
headers: rate_limit_headers,
body: '{"message":"API rate limit exceeded for 1.2.3.4"}',
)
end
it "records a backoff (until x-ratelimit-reset) and skips further GitHub requests" do
render_pr
expect(a_request(:get, pr_api)).to have_been_made.once
ttl = Discourse.redis.without_namespace.ttl("onebox_github_backoff_unauthenticated")
expect(ttl).to be_between(1, 1800)
render_pr
expect(a_request(:get, pr_api)).to have_been_made.once
end
it "short-circuits a different GitHub URL/engine sharing the same identity" do
render_pr
issue_link = "https://github.com/discourse/discourse/issues/999"
issue_api = "https://api.github.com/repos/discourse/discourse/issues/999"
stub_request(:get, issue_api).to_return(status: 200, body: "{}")
begin
Onebox::Engine::GithubIssueOnebox.new(issue_link).to_html
rescue StandardError
nil
end
expect(a_request(:get, issue_api)).not_to have_been_made
end
end
context "when GitHub sends a Retry-After header (secondary rate limit)" do
before do
stub_request(:get, pr_api).to_return(
status: [429, "Too Many Requests"],
headers: rate_limit_headers(remaining: "42", retry_after: 120),
body: "{}",
)
end
it "backs off for the Retry-After duration" do
render_pr
ttl = Discourse.redis.without_namespace.ttl("onebox_github_backoff_unauthenticated")
expect(ttl).to be_between(1, 120)
end
end
context "when GitHub returns a 403 that is NOT a rate limit (e.g. private repo)" do
before do
stub_request(:get, pr_api).to_return(
status: [403, "Forbidden"],
headers: rate_limit_headers(remaining: "57"),
body: '{"message":"Must have admin rights to Repository."}',
)
end
it "does not back off, so subsequent requests still reach GitHub" do
render_pr
expect(
Discourse.redis.without_namespace.get("onebox_github_backoff_unauthenticated"),
).to be_nil
render_pr
expect(a_request(:get, pr_api)).to have_been_made.twice
end
end
context "when an org access token is configured" do
before { SiteSetting.github_onebox_access_tokens = "discourse|gh_token_xyz" }
it "scopes the backoff per token, not globally" do
stub_request(:get, pr_api).with(
headers: {
"Authorization" => "Bearer gh_token_xyz",
},
).to_return(status: [403, "Forbidden"], headers: rate_limit_headers, body: "{}")
render_pr
token_key = "onebox_github_backoff_#{Digest::SHA1.hexdigest("gh_token_xyz")}"
expect(Discourse.redis.without_namespace.get(token_key)).to be_present
expect(
Discourse.redis.without_namespace.get("onebox_github_backoff_unauthenticated"),
).to be_nil
end
end
context "when a successful response reports the rate-limit budget is exhausted" do
before do
SiteSetting.stubs(:github_pr_status_enabled).returns(false)
stub_request(:get, pr_api).to_return(
status: 200,
headers: rate_limit_headers(remaining: "0"),
body: onebox_response("githubpullrequest"),
)
end
it "proactively backs off until reset, without waiting for a 403" do
render_pr
ttl = Discourse.redis.without_namespace.ttl("onebox_github_backoff_unauthenticated")
expect(ttl).to be_between(1, 1800)
WebMock::RequestRegistry.instance.reset!
render_pr
expect(a_request(:get, pr_api)).not_to have_been_made
end
end
context "when any GitHub API engine hits the rate limit on its primary fetch" do
before do
stub_request(:get, /api\.github\.com/).to_return(
status: [403, "Forbidden"],
headers: rate_limit_headers,
body: "{}",
)
end
[
["pull request", Onebox::Engine::GithubPullRequestOnebox, "https://github.com/d/d/pull/1"],
["commit", Onebox::Engine::GithubCommitOnebox, "https://github.com/d/d/commit/#{"a" * 40}"],
["issue", Onebox::Engine::GithubIssueOnebox, "https://github.com/d/d/issues/1"],
["repo", Onebox::Engine::GithubRepoOnebox, "https://github.com/d/d"],
["actions", Onebox::Engine::GithubActionsOnebox, "https://github.com/d/d/actions/runs/1"],
["gist", Onebox::Engine::GithubGistOnebox, "https://gist.github.com/d/abc123def456"],
].each do |name, klass, link|
it "records a backoff when the #{name} engine is rate-limited" do
begin
klass.new(link).to_html
rescue StandardError
nil
end
expect(
Discourse.redis.without_namespace.get("onebox_github_backoff_unauthenticated"),
).to be_present
end
end
end
context "when a default access token is configured" do
it "authenticates gist API calls with the default token" do
SiteSetting.github_onebox_access_tokens = "default|gh_default_token"
stub =
stub_request(:get, "https://api.github.com/gists/abc123def456").with(
headers: {
"Authorization" => "Bearer gh_default_token",
},
).to_return(status: 200, body: '{"files":{}}')
Onebox::Engine::GithubGistOnebox.new("https://gist.github.com/d/abc123def456").to_html
expect(stub).to have_been_requested
end
end
end