From 52a23b1a3a2dc0c2ebc3e12cb7a33d0698be4084 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:49:30 +0200 Subject: [PATCH] fix(github-sync): retry transient network errors during GraphQL batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's GraphQL endpoint occasionally closes connections mid-response, raising EOFError (and siblings like Errno::ECONNRESET / OpenSSL::SSL::SSLError) out of Net::HTTP. The retry loop in User::GithubSyncable#github_graphql_request only caught Net::OpenTimeout/Net::ReadTimeout, so any connection reset escaped the rescue and failed the entire batch of users without retrying — observed in production as EOFError "end of file reached" from UpdateGithubDataJob. Extract the full set of transient network error classes into TRANSIENT_NETWORK_ERRORS and rescue them uniformly so they trigger the existing backoff/retry instead of aborting the batch. Added regression tests covering both paths: retry-then-succeed and retries-exhausted. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/models/concerns/user/github_syncable.rb | 19 ++++++- .../concerns/user/github_syncable_test.rb | 51 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 test/models/concerns/user/github_syncable_test.rb diff --git a/app/models/concerns/user/github_syncable.rb b/app/models/concerns/user/github_syncable.rb index ea392a8..d8ead15 100644 --- a/app/models/concerns/user/github_syncable.rb +++ b/app/models/concerns/user/github_syncable.rb @@ -1,11 +1,26 @@ require "net/http" require "json" +require "openssl" +require "socket" module User::GithubSyncable extend ActiveSupport::Concern GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql" + TRANSIENT_NETWORK_ERRORS = [ + Net::OpenTimeout, + Net::ReadTimeout, + Net::WriteTimeout, + EOFError, + IOError, + SocketError, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::EPIPE, + OpenSSL::SSL::SSLError + ].freeze + # Sync from OAuth callback data def sync_github_data_from_oauth!(auth_data) api_token = auth_data.credentials.token @@ -155,12 +170,12 @@ def github_graphql_request(query, api_token, retries: 3) else return { errors: [ "HTTP #{response.code}: #{response.message}" ] } end - rescue Net::OpenTimeout, Net::ReadTimeout => e + rescue *TRANSIENT_NETWORK_ERRORS => e if attempt < retries - 1 sleep(2 ** (attempt + 1)) next else - return { errors: [ "Request timed out: #{e.message}" ] } + return { errors: [ "Network error: #{e.class}: #{e.message}" ] } end end end diff --git a/test/models/concerns/user/github_syncable_test.rb b/test/models/concerns/user/github_syncable_test.rb new file mode 100644 index 0000000..1609d90 --- /dev/null +++ b/test/models/concerns/user/github_syncable_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +class User::GithubSyncableTest < ActiveSupport::TestCase + setup do + WebMock.disable_net_connect!(allow_localhost: true) + @user = users(:user_with_testimonial) + @user.update_columns(username: "octocat") + end + + teardown do + WebMock.reset! + WebMock.allow_net_connect! + end + + # Skip the exponential backoff so tests run fast. + setup { User.singleton_class.send(:define_method, :sleep) { |_| } } + teardown { User.singleton_class.send(:remove_method, :sleep) } + + test "batch_sync_github_data! retries on EOFError and succeeds on second attempt" do + stub_request(:post, User::GithubSyncable::GITHUB_GRAPHQL_ENDPOINT) + .to_raise(EOFError.new("end of file reached")) + .then.to_return( + status: 200, + body: { + data: { + user_0: { login: "octocat", name: "The Octocat", email: nil, bio: nil, company: nil, websiteUrl: nil, twitterUsername: nil, location: nil, avatarUrl: "https://example.com/a.png" }, + repos_0: { nodes: [] } + } + }.to_json + ) + + result = User.batch_sync_github_data!([ @user ], api_token: "token") + + assert_equal 1, result[:updated], result[:errors].inspect + assert_equal 0, result[:failed] + end + + test "batch_sync_github_data! returns a network error after exhausting retries" do + stub_request(:post, User::GithubSyncable::GITHUB_GRAPHQL_ENDPOINT) + .to_raise(EOFError.new("end of file reached")) + + result = User.batch_sync_github_data!([ @user ], api_token: "token") + + assert_equal 0, result[:updated] + assert_equal 1, result[:failed] + assert_match(/EOFError/, result[:errors].first.to_s) + end +end