-
Notifications
You must be signed in to change notification settings - Fork 24
GET fallback for link checker #1164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8465f11
Fallback to a GET request if HEAD fails when checking links
fbacall b1a21ce
Use cache to avoid duplicate URL checks. Refactor
fbacall 4075277
Update lib/link_checker.rb
fbacall 8b4e298
Lower open/read timeouts to 5 seconds for HEAD requests
fbacall 9777792
Share URL cache between materials/events in `check_resource_urls` task
fbacall File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| class LinkChecker | ||
| TIMEOUT = 5 | ||
| attr_reader :log | ||
|
|
||
| def initialize(log: true) | ||
| @log = log | ||
| @cache = {} | ||
| end | ||
|
|
||
| def check(collection) | ||
| collection.find_each do |record| | ||
| check_record(record) | ||
| end | ||
| end | ||
|
|
||
| def check_record(record) | ||
| [record, *record.external_resources].each do |item| | ||
| next if item.url.blank? | ||
| code = @cache.fetch(item.url) { bad_response(item.url) } # Cache the result, there could be multiple resources linked to same URL | ||
| if code | ||
| puts " #{code} - #{item.class.name} #{item.id}: #{item.url}" if log | ||
| if item.link_monitor | ||
| item.link_monitor.fail!(code) | ||
| else | ||
| item.create_link_monitor(url: item.url, code: code) | ||
| end | ||
| else | ||
| if item.link_monitor | ||
| item.link_monitor.success! | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # The fake return codes on an exception are so the LinkMonitor object has something | ||
| # to store as "code" which might be tracked back to a particular problem. | ||
| def bad_response(url) | ||
| begin | ||
| host = URI.parse(url).host rescue nil | ||
| if @prev_host == host | ||
| n = rand(4) + 1 # Add some delay between requests to the same host to prevent flooding | ||
| sleep(n) | ||
| end | ||
| @prev_host = host | ||
| code = HTTParty.head(url, verify: false, open_timeout: TIMEOUT, read_timeout: TIMEOUT).code | ||
| code = get_code(url) if code == 400 || code == 405 # Try a GET if HEAD not allowed (or generic 400 error) | ||
| return nil if code >= 200 && code < 400 # Success or redirects are OK | ||
| return code | ||
| rescue EOFError => e | ||
| puts " #{e.class.name}: #{e}" if log | ||
| return 490 | ||
| rescue SocketError => e | ||
| puts " #{e.class.name}: #{e}" if log | ||
| return 491 | ||
| rescue Timeout::Error => e | ||
|
fbacall marked this conversation as resolved.
|
||
| puts " #{e.class.name}: #{e}" if log | ||
| return 492 | ||
| rescue StandardError => e | ||
| puts " #{e.class.name}: #{e}" if log | ||
| return 493 | ||
| end | ||
| end | ||
|
|
||
| # Gets a response code using a GET request, for the case where HEAD is not supported. | ||
| def get_code(url, redirect_limit: 5, open_timeout: TIMEOUT, read_timeout: TIMEOUT, use_range: true) | ||
| raise StandardError, 'too many redirects' if redirect_limit <= 0 | ||
|
|
||
| uri = URI(url) | ||
|
|
||
| # Prepare the HTTP connection | ||
| http = Net::HTTP.new(uri.host, uri.port) | ||
| http.use_ssl = (uri.scheme == 'https') | ||
| http.open_timeout = open_timeout | ||
| http.read_timeout = read_timeout | ||
| http.verify_mode = OpenSSL::SSL::VERIFY_NONE | ||
|
|
||
| # Build GET request (not HEAD) | ||
| req = Net::HTTP::Get.new(uri) | ||
| req['Range'] = 'bytes=0-0' if use_range # fetch just the first byte if supported | ||
|
|
||
| http.start do |connection| | ||
| connection.request(req) do |res| | ||
| case res | ||
| when Net::HTTPRedirection | ||
| location = res['location'] | ||
| new_uri = URI.join(uri, location).to_s | ||
| return get_code(new_uri, | ||
| redirect_limit: redirect_limit - 1, | ||
| open_timeout: open_timeout, | ||
| read_timeout: read_timeout, | ||
| use_range: use_range) | ||
| else | ||
| code = res.code.to_i | ||
| # Do not call res.read_body — we don't want to download the content | ||
| return code | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| require 'test_helper' | ||
|
|
||
| class LinkCheckerTest < ActiveSupport::TestCase | ||
|
|
||
| setup do | ||
| @link_checker = LinkChecker.new(log: false) | ||
| end | ||
|
|
||
| test 'check collection' do | ||
| event = events(:two) | ||
| WebMock.stub_request(:head, event.url).to_return(status: 200) | ||
| assert_nil event.link_monitor | ||
|
|
||
| assert_no_difference('LinkMonitor.count') do | ||
| @link_checker.check(Event.where(id: event.id)) | ||
| end | ||
|
|
||
| assert_nil event.link_monitor | ||
| end | ||
|
|
||
| test 'check passing event url' do | ||
| event = events(:two) | ||
| WebMock.stub_request(:head, event.url).to_return(status: 200) | ||
| assert_nil event.link_monitor | ||
|
|
||
| assert_no_difference('LinkMonitor.count') do | ||
| @link_checker.check_record(event) | ||
| end | ||
|
|
||
| assert_nil event.link_monitor | ||
| end | ||
|
|
||
| test 'check failing event url' do | ||
| event = events(:two) | ||
| WebMock.stub_request(:head, event.url).to_return(status: 404) | ||
| assert_nil event.link_monitor | ||
|
|
||
| assert_difference('LinkMonitor.count', 1) do | ||
| @link_checker.check_record(event) | ||
| end | ||
|
|
||
| assert event.link_monitor | ||
| assert event.link_monitor.failed_at | ||
| assert_equal 404, event.link_monitor.code | ||
| end | ||
|
|
||
| test 'check failing event url that only responds to get' do | ||
| event = events(:two) | ||
| WebMock.stub_request(:head, event.url).to_return(status: 405) | ||
| WebMock.stub_request(:get, event.url).to_return(status: 200) | ||
| assert_nil event.link_monitor | ||
|
|
||
| assert_no_difference('LinkMonitor.count') do | ||
| @link_checker.check_record(event) | ||
| end | ||
|
|
||
| assert_nil event.link_monitor | ||
| end | ||
|
|
||
| test 'follows redirect' do | ||
| event = events(:two) | ||
| WebMock.stub_request(:head, event.url).to_return(status: 405) | ||
| WebMock.stub_request(:get, event.url).to_return(status: 302, headers: { location: 'http://website.com' }) | ||
| WebMock.stub_request(:get, 'http://website.com').to_return(status: 404) | ||
| assert_nil event.link_monitor | ||
|
|
||
| assert_difference('LinkMonitor.count', 1) do | ||
| @link_checker.check_record(event) | ||
| end | ||
|
|
||
| assert event.link_monitor | ||
| assert event.link_monitor.failed_at | ||
| assert_equal 404, event.link_monitor.code | ||
| end | ||
|
|
||
| test 'avoids redirect loop' do | ||
| event = events(:two) | ||
| WebMock.stub_request(:head, event.url).to_return(status: 405) | ||
| WebMock.stub_request(:get, event.url).to_return(status: 302, headers: { location: 'http://website.com' }) | ||
| WebMock.stub_request(:get, 'http://website.com').to_return(status: 302, headers: { location: event.url }) | ||
| assert_nil event.link_monitor | ||
|
|
||
| assert_difference('LinkMonitor.count', 1) do | ||
| @link_checker.check_record(event) | ||
| end | ||
|
|
||
| assert event.link_monitor | ||
| assert event.link_monitor.failed_at | ||
| assert_equal 493, event.link_monitor.code | ||
| end | ||
|
|
||
| test 'check material with external resources' do | ||
| WebMock.stub_request(:head, 'http://myurl.com/123').to_return(status: 200) | ||
| WebMock.stub_request(:head, 'https://tess.elixir-uk.org/').to_return(status: 200) | ||
| WebMock.stub_request(:head, 'https://bio.tools/tool/SR-Tesseler').to_return(status: 404) | ||
| WebMock.stub_request(:head, 'https://fairsharing.org/bsg-p123456').to_return(status: 404) | ||
| material = materials(:material_with_external_resource) | ||
|
|
||
| assert_difference('LinkMonitor.count', 2) do | ||
| @link_checker.check_record(material) | ||
| end | ||
| end | ||
|
|
||
| end |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The comment mentions trying GET for '400 or generic 400 error' which is redundant. Consider revising to clarify why 400 is included alongside 405. A clearer comment would be: 'Try a GET if HEAD returns Method Not Allowed (405) or Bad Request (400), as some servers reject HEAD requests'.