ruby-link-checker 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/coverage.yml +24 -0
  3. data/.github/workflows/danger-comment.yml +17 -0
  4. data/.github/workflows/danger.yml +12 -0
  5. data/.github/workflows/lint.yml +20 -0
  6. data/.github/workflows/release.yml +14 -0
  7. data/.github/workflows/test.yml +25 -0
  8. data/.gitignore +6 -0
  9. data/.rspec +2 -0
  10. data/.rubocop.yml +26 -0
  11. data/.rubocop_todo.yml +332 -0
  12. data/AGENTS.md +23 -0
  13. data/CHANGELOG.md +27 -0
  14. data/CONTRIBUTING.md +4 -0
  15. data/Dangerfile +2 -0
  16. data/Gemfile +5 -1
  17. data/README.md +151 -7
  18. data/RELEASING.md +17 -40
  19. data/Rakefile +8 -0
  20. data/benchmarks/check.rb +27 -20
  21. data/lib/ruby-link-checker/async/http/checker.rb +118 -0
  22. data/lib/ruby-link-checker/async/http/config.rb +39 -0
  23. data/lib/ruby-link-checker/async/http/result.rb +85 -0
  24. data/lib/ruby-link-checker/async/http.rb +3 -0
  25. data/lib/ruby-link-checker/cache.rb +101 -0
  26. data/lib/ruby-link-checker/callbacks.rb +2 -2
  27. data/lib/ruby-link-checker/checker.rb +76 -4
  28. data/lib/ruby-link-checker/config.rb +64 -0
  29. data/lib/ruby-link-checker/errors/connection_error.rb +23 -0
  30. data/lib/ruby-link-checker/errors/redirect_limit_error.rb +15 -0
  31. data/lib/ruby-link-checker/errors.rb +2 -0
  32. data/lib/ruby-link-checker/net/http/checker.rb +4 -1
  33. data/lib/ruby-link-checker/net/http/result.rb +4 -0
  34. data/lib/ruby-link-checker/result.rb +25 -0
  35. data/lib/ruby-link-checker/task.rb +2 -0
  36. data/lib/ruby-link-checker/tasks.rb +43 -7
  37. data/lib/ruby-link-checker/typhoeus/hydra/checker.rb +29 -6
  38. data/lib/ruby-link-checker/typhoeus/hydra/result.rb +4 -0
  39. data/lib/ruby-link-checker/version.rb +1 -1
  40. data/lib/ruby-link-checker.rb +2 -0
  41. data/ruby-link-checker.gemspec +3 -2
  42. data/spec/ruby-link-checker/async/http/checker_spec.rb +184 -0
  43. data/spec/ruby-link-checker/callbacks_spec.rb +33 -0
  44. data/spec/ruby-link-checker/checker_spec.rb +89 -2
  45. data/spec/ruby-link-checker/config_spec.rb +6 -0
  46. data/spec/ruby-link-checker/errors/redirect_loop_error_spec.rb +18 -0
  47. data/spec/ruby-link-checker/net/http/checker_spec.rb +32 -0
  48. data/spec/ruby-link-checker/result_error_spec.rb +34 -0
  49. data/spec/ruby-link-checker/result_spec.rb +41 -0
  50. data/spec/ruby-link-checker/task_spec.rb +20 -0
  51. data/spec/ruby-link-checker/tasks_spec.rb +91 -0
  52. data/spec/ruby-link-checker/typhoeus/hydra/checker_spec.rb +116 -3
  53. data/spec/spec_helper.rb +3 -1
  54. data/spec/support/link_checker.rb +267 -3
  55. metadata +42 -35
  56. data/Gemfile.lock +0 -215
  57. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc.png +0 -0
  58. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc_disabled.png +0 -0
  59. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_both.png +0 -0
  60. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc.png +0 -0
  61. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc_disabled.png +0 -0
  62. data/coverage/assets/0.13.1/application.css +0 -1
  63. data/coverage/assets/0.13.1/application.js +0 -7
  64. data/coverage/assets/0.13.1/colorbox/border.png +0 -0
  65. data/coverage/assets/0.13.1/colorbox/controls.png +0 -0
  66. data/coverage/assets/0.13.1/colorbox/loading.gif +0 -0
  67. data/coverage/assets/0.13.1/colorbox/loading_background.png +0 -0
  68. data/coverage/assets/0.13.1/favicon_green.png +0 -0
  69. data/coverage/assets/0.13.1/favicon_red.png +0 -0
  70. data/coverage/assets/0.13.1/favicon_yellow.png +0 -0
  71. data/coverage/assets/0.13.1/images/ui-bg_flat_0_aaaaaa_40x100.png +0 -0
  72. data/coverage/assets/0.13.1/images/ui-bg_flat_75_ffffff_40x100.png +0 -0
  73. data/coverage/assets/0.13.1/images/ui-bg_glass_55_fbf9ee_1x400.png +0 -0
  74. data/coverage/assets/0.13.1/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  75. data/coverage/assets/0.13.1/images/ui-bg_glass_75_dadada_1x400.png +0 -0
  76. data/coverage/assets/0.13.1/images/ui-bg_glass_75_e6e6e6_1x400.png +0 -0
  77. data/coverage/assets/0.13.1/images/ui-bg_glass_95_fef1ec_1x400.png +0 -0
  78. data/coverage/assets/0.13.1/images/ui-bg_highlight-soft_75_cccccc_1x100.png +0 -0
  79. data/coverage/assets/0.13.1/images/ui-icons_222222_256x240.png +0 -0
  80. data/coverage/assets/0.13.1/images/ui-icons_2e83ff_256x240.png +0 -0
  81. data/coverage/assets/0.13.1/images/ui-icons_454545_256x240.png +0 -0
  82. data/coverage/assets/0.13.1/images/ui-icons_888888_256x240.png +0 -0
  83. data/coverage/assets/0.13.1/images/ui-icons_cd0a0a_256x240.png +0 -0
  84. data/coverage/assets/0.13.1/loading.gif +0 -0
  85. data/coverage/assets/0.13.1/magnify.png +0 -0
  86. data/coverage/index.html +0 -15014
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'tempfile'
7
+ require 'uri'
8
+
9
+ module LinkChecker
10
+ class CachedResult < Result
11
+ attr_reader :code
12
+
13
+ def initialize(data, uri)
14
+ @code = data.fetch('code')
15
+ super(URI(data.fetch('result_uri')), data.fetch('method'), URI(uri.to_s))
16
+ @cached = true
17
+ end
18
+
19
+ def success?
20
+ true
21
+ end
22
+ end
23
+
24
+ class Cache
25
+ VERSION = 1
26
+
27
+ def initialize(options, logger:)
28
+ unless options.is_a?(Hash) && options[:file].is_a?(String) && !options[:file].strip.empty?
29
+ raise ArgumentError, 'Cache requires a file path'
30
+ end
31
+
32
+ @ttl = options[:ttl]
33
+ unless @ttl.is_a?(Numeric) && @ttl.real? && @ttl.finite? && @ttl.positive?
34
+ raise ArgumentError, 'Cache requires a positive, finite TTL in seconds'
35
+ end
36
+
37
+ @file = File.expand_path(options[:file])
38
+ @logger = logger
39
+ end
40
+
41
+ def fetch(key, uri)
42
+ data = read_entries[key]
43
+ return unless fresh?(data) && data['code'].is_a?(Integer) && data['code'].between?(200, 299)
44
+ return unless data['method'].is_a?(String) && data['result_uri'].is_a?(String)
45
+
46
+ parsed = URI(data['result_uri'])
47
+ return unless %w[http https].include?(parsed.scheme) && parsed.host && !parsed.userinfo
48
+
49
+ CachedResult.new(data, uri)
50
+ rescue URI::InvalidURIError
51
+ nil
52
+ end
53
+
54
+ def store(key, result)
55
+ return unless result.success?
56
+ return if URI(result.result_uri.to_s).userinfo
57
+
58
+ directory = File.dirname(@file)
59
+ FileUtils.mkdir_p(directory)
60
+ File.open("#{@file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
61
+ lock.flock(File::LOCK_EX)
62
+ entries = read_entries.select { |_entry_key, data| fresh?(data) }
63
+ entries[key] = {
64
+ 'checked_at' => Time.now.to_f,
65
+ 'method' => result.method,
66
+ 'code' => result.code,
67
+ 'result_uri' => result.result_uri.to_s
68
+ }
69
+ Tempfile.create(['link-checker-', '.json'], directory) do |file|
70
+ file.write(JSON.generate('version' => VERSION, 'entries' => entries))
71
+ file.flush
72
+ File.rename(file.path, @file)
73
+ end
74
+ end
75
+ rescue IOError, SystemCallError, JSON::GeneratorError
76
+ @logger.warn("Unable to write link cache #{@file}; result was not cached")
77
+ end
78
+
79
+ private
80
+
81
+ def fresh?(data)
82
+ return false unless data.is_a?(Hash)
83
+
84
+ timestamp = data['checked_at']
85
+ now = Time.now.to_f
86
+ timestamp.is_a?(Numeric) && timestamp.finite? && timestamp <= now && now - timestamp < @ttl
87
+ end
88
+
89
+ def read_entries
90
+ data = JSON.parse(File.read(@file))
91
+ return {} unless data.is_a?(Hash) && data['version'] == VERSION && data['entries'].is_a?(Hash)
92
+
93
+ data['entries']
94
+ rescue Errno::ENOENT
95
+ {}
96
+ rescue IOError, SystemCallError, JSON::ParserError
97
+ @logger.warn("Unable to read link cache #{@file}; checking links normally")
98
+ {}
99
+ end
100
+ end
101
+ end
@@ -20,9 +20,9 @@ module LinkChecker
20
20
  end
21
21
  end
22
22
 
23
- def method_missing(m, *args, &block)
23
+ def method_missing(m, *, &)
24
24
  if m.to_s[-1] == '!'
25
- callback(m.to_s[...-1].to_sym, *args)
25
+ callback(m.to_s[...-1].to_sym, *)
26
26
  else
27
27
  super
28
28
  end
@@ -6,15 +6,17 @@ module LinkChecker
6
6
  include LinkChecker::Callbacks
7
7
 
8
8
  attr_reader :results
9
- attr_accessor(*Config::ATTRIBUTES)
10
9
 
11
10
  def initialize(options = {})
12
11
  LinkChecker::Config::ATTRIBUTES.each do |key|
13
- send("#{key}=", options[key] || LinkChecker.config.send(key))
12
+ send("#{key}=", options.fetch(key) { LinkChecker.config.send(key) })
14
13
  end
15
14
  raise ArgumentError, "Missing methods." if methods&.none?
16
15
  @logger ||= options[:logger] || LinkChecker::Config.logger || LinkChecker::Logger.default
17
16
  @results = { error: [], failure: [], success: [] } unless options.key?(:results) && !options[:results]
17
+ @pending = {}
18
+ @successful = {}
19
+ @cache_store = Cache.new(cache, logger: @logger) if cache
18
20
  end
19
21
 
20
22
  def task_klass
@@ -25,6 +27,37 @@ module LinkChecker
25
27
  end
26
28
 
27
29
  def check(uri, options = {})
30
+ return execute_check(uri, options) unless deduplicate || @cache_store
31
+
32
+ key = request_key(uri)
33
+ return execute_check(uri, options) unless key
34
+
35
+ subscriber = [uri.to_s.dup, options]
36
+ if deduplicate && (entry = @pending[key])
37
+ entry[:subscribers] << subscriber
38
+ entry[:events].each { |event, result| publish(event, copy_result(result, subscriber)) }
39
+ return
40
+ end
41
+
42
+ cached_result = @successful[key] if deduplicate
43
+ cached_result ||= @cache_store&.fetch(key, uri)
44
+ if cached_result
45
+ @successful[key] ||= cached_result if deduplicate
46
+ result = copy_result(cached_result, subscriber)
47
+ result.cached = true
48
+ publish(:result, result)
49
+ publish(:success, result)
50
+ return
51
+ end
52
+
53
+ entry = { subscribers: [subscriber], events: [] }
54
+ @pending[key] = entry if deduplicate
55
+ execute_check(uri, options) { |event, result| handle_shared_result(key, entry, uri, event, result) }
56
+ end
57
+
58
+ private
59
+
60
+ def execute_check(uri, options, &handler)
28
61
  tasks = Tasks.new(
29
62
  self,
30
63
  task_klass,
@@ -33,10 +66,49 @@ module LinkChecker
33
66
  options
34
67
  )
35
68
  tasks.on do |event, *args|
36
- results[event] << args.first if @results && %i[error failure success].include?(event)
37
- callback event, *args
69
+ handler ? handler.call(event, *args) : publish(event, args.first)
38
70
  end
39
71
  tasks.execute!
40
72
  end
73
+
74
+ def publish(event, result)
75
+ result.checker = self
76
+ results[event] << result if @results && %i[error failure success].include?(event)
77
+ callback(event, result)
78
+ end
79
+
80
+ def handle_shared_result(key, entry, uri, event, result)
81
+ entry[:events] << [event, result]
82
+ subscribers = entry[:subscribers].dup
83
+ if %i[error failure success].include?(event)
84
+ @pending.delete(key) if @pending[key].equal?(entry)
85
+ if event == :success && request_key(uri) == key
86
+ @successful[key] = result if deduplicate
87
+ @cache_store&.store(key, result)
88
+ end
89
+ end
90
+ subscribers.each { |subscriber| publish(event, copy_result(result, subscriber)) }
91
+ end
92
+
93
+ def copy_result(result, subscriber)
94
+ result.dup.tap do |copy|
95
+ copy.uri = URI(subscriber.first)
96
+ copy.result_uri = result.result_uri.dup
97
+ copy.options = subscriber.last
98
+ end
99
+ end
100
+
101
+ def request_key(uri)
102
+ parsed = URI(uri.to_s)
103
+ return unless %w[http https].include?(parsed.scheme) && parsed.host && !parsed.userinfo
104
+
105
+ attributes = Config::ATTRIBUTES - %i[logger cache deduplicate]
106
+ attributes += %i[timeout connecttimeout read_timeout open_timeout protocol].select { |key| respond_to?(key) }
107
+ settings = attributes.to_h { |key| [key, send(key)] }
108
+ settings[:headers] = headers.sort_by { |key, _value| key.to_s }
109
+ Digest::SHA256.hexdigest(JSON.generate([task_klass.name, parsed.to_s, settings]))
110
+ rescue URI::InvalidURIError, JSON::GeneratorError, TypeError
111
+ nil
112
+ end
41
113
  end
42
114
  end
@@ -7,8 +7,18 @@ module LinkChecker
7
7
  ATTRIBUTES = %i[
8
8
  methods
9
9
  user_agent
10
+ headers
10
11
  logger
11
12
  retries
13
+ max_redirects
14
+ retry_status_codes
15
+ retry_after
16
+ retry_backoff
17
+ retry_jitter
18
+ max_retry_delay
19
+ respect_retry_after
20
+ deduplicate
21
+ cache
12
22
  ].freeze
13
23
 
14
24
  attr_accessor(*Config::ATTRIBUTES)
@@ -16,8 +26,18 @@ module LinkChecker
16
26
  def reset
17
27
  self.methods = %w[HEAD GET]
18
28
  self.user_agent = "Ruby Link Checker/#{LinkChecker::VERSION}"
29
+ self.headers = {}
19
30
  self.logger = nil
20
31
  self.retries = 0
32
+ self.max_redirects = 20
33
+ self.retry_status_codes = [408, 425, 429, 500, 502, 503, 504]
34
+ self.retry_after = 0
35
+ self.retry_backoff = 1
36
+ self.retry_jitter = 0
37
+ self.max_retry_delay = 60
38
+ self.respect_retry_after = false
39
+ self.deduplicate = false
40
+ self.cache = nil
21
41
  end
22
42
 
23
43
  def retries=(value)
@@ -25,6 +45,50 @@ module LinkChecker
25
45
 
26
46
  @retries = value
27
47
  end
48
+
49
+ def max_redirects=(value)
50
+ raise ArgumentError, "Invalid redirect limit: #{value}" unless value.is_a?(Integer) && value >= 0
51
+
52
+ @max_redirects = value
53
+ end
54
+
55
+ def retry_status_codes=(value)
56
+ unless value.nil? || (value.is_a?(Array) && value.all? { |code| code.is_a?(Integer) && code.between?(100, 599) })
57
+ raise ArgumentError, "Invalid retry status codes: #{value}"
58
+ end
59
+
60
+ @retry_status_codes = value&.dup
61
+ end
62
+
63
+ def retry_after=(value)
64
+ validate_number!(:retry_after, value, 0)
65
+ @retry_after = value
66
+ end
67
+
68
+ def retry_backoff=(value)
69
+ validate_number!(:retry_backoff, value, 1)
70
+ @retry_backoff = value
71
+ end
72
+
73
+ def retry_jitter=(value)
74
+ validate_number!(:retry_jitter, value, 0)
75
+ raise ArgumentError, "Invalid retry_jitter: #{value}" if value > 1
76
+
77
+ @retry_jitter = value
78
+ end
79
+
80
+ def max_retry_delay=(value)
81
+ validate_number!(:max_retry_delay, value, 0)
82
+ @max_retry_delay = value
83
+ end
84
+
85
+ private
86
+
87
+ def validate_number!(name, value, minimum)
88
+ return if value.is_a?(Numeric) && value.real? && value.finite? && value >= minimum
89
+
90
+ raise ArgumentError, "Invalid #{name}: #{value}"
91
+ end
28
92
  end
29
93
 
30
94
  class << self
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkChecker
4
+ module Errors
5
+ class ConnectionError < BaseError
6
+ TRANSIENT_CODES = %i[
7
+ couldnt_resolve_proxy couldnt_resolve_host couldnt_connect
8
+ partial_file got_nothing send_error recv_error http2 http2_stream
9
+ ].freeze
10
+
11
+ attr_reader :return_code
12
+
13
+ def initialize(return_code, message)
14
+ @return_code = return_code
15
+ super(message)
16
+ end
17
+
18
+ def error_type
19
+ TRANSIENT_CODES.include?(return_code) ? :connection : :other
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinkChecker
4
+ module Errors
5
+ class RedirectLimitError < BaseError
6
+ attr_reader :limit, :urls
7
+
8
+ def initialize(limit, urls)
9
+ @limit = limit
10
+ @urls = urls
11
+ super("Redirect limit of #{limit} exceeded: #{urls.join(' -> ')}.")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,2 +1,4 @@
1
1
  require_relative 'errors/base_error'
2
2
  require_relative 'errors/redirect_loop_error'
3
+ require_relative 'errors/redirect_limit_error'
4
+ require_relative 'errors/connection_error'
@@ -3,11 +3,13 @@ module LinkChecker
3
3
  module HTTP
4
4
  class Task < ::LinkChecker::Task
5
5
  def run!
6
- ::Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
6
+ sleep(delay) if delay.positive?
7
+ ::Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
7
8
  http.read_timeout = checker.read_timeout if checker.read_timeout
8
9
  http.open_timeout = checker.open_timeout if checker.open_timeout
9
10
  request = ::Net::HTTPGenericRequest.new(method, false, true, uri)
10
11
  request['User-Agent'] = checker.user_agent
12
+ checker.headers.each { |key, value| request[key] = value }
11
13
  response = http.request(request)
12
14
  result! Result.new(uri, method, original_uri, request, response, options)
13
15
  end
@@ -16,6 +18,7 @@ module LinkChecker
16
18
 
17
19
  class Checker < LinkChecker::Checker
18
20
  extend ::LinkChecker::Net::HTTP::Config
21
+
19
22
  attr_accessor(*LinkChecker::Net::HTTP::Config::ATTRIBUTES)
20
23
 
21
24
  def initialize(options = {})
@@ -30,6 +30,10 @@ module LinkChecker
30
30
  request
31
31
  end
32
32
 
33
+ def response_headers
34
+ response || {}
35
+ end
36
+
33
37
  def redirect_to
34
38
  return nil unless response
35
39
 
@@ -1,6 +1,10 @@
1
+ require 'timeout'
2
+ require 'socket'
3
+
1
4
  module LinkChecker
2
5
  class Result
3
6
  attr_accessor :uri, :result_uri, :method, :options, :checker
7
+ attr_writer :cached
4
8
 
5
9
  def initialize(current_uri, method, original_uri, options = {})
6
10
  @uri = original_uri
@@ -13,6 +17,10 @@ module LinkChecker
13
17
  false
14
18
  end
15
19
 
20
+ def cached?
21
+ !!@cached
22
+ end
23
+
16
24
  def failure?
17
25
  false
18
26
  end
@@ -33,6 +41,14 @@ module LinkChecker
33
41
  {}
34
42
  end
35
43
 
44
+ def response_headers
45
+ {}
46
+ end
47
+
48
+ def error_type
49
+ nil
50
+ end
51
+
36
52
  def code
37
53
  nil
38
54
  end
@@ -70,5 +86,14 @@ module LinkChecker
70
86
  def code
71
87
  error.class.name
72
88
  end
89
+
90
+ def error_type
91
+ return :timeout if error.is_a?(Timeout::Error)
92
+ return :timeout if defined?(::Async::TimeoutError) && error.is_a?(::Async::TimeoutError)
93
+ return error.error_type if error.is_a?(LinkChecker::Errors::ConnectionError)
94
+ return :connection if [IOError, SystemCallError, SocketError].any? { |klass| error.is_a?(klass) }
95
+
96
+ :other
97
+ end
73
98
  end
74
99
  end
@@ -3,6 +3,7 @@ module LinkChecker
3
3
  include LinkChecker::Callbacks
4
4
 
5
5
  attr_reader :uri, :original_uri, :method, :logger, :options, :checker
6
+ attr_accessor :delay
6
7
 
7
8
  def initialize(checker, uri, method, original_uri, options = {})
8
9
  @checker = checker
@@ -11,6 +12,7 @@ module LinkChecker
11
12
  @original_uri = original_uri || @uri
12
13
  @method = method
13
14
  @options = options
15
+ @delay = 0
14
16
  end
15
17
 
16
18
  def run!
@@ -1,3 +1,5 @@
1
+ require 'time'
2
+
1
3
  module LinkChecker
2
4
  class Tasks
3
5
  include LinkChecker::Callbacks
@@ -7,6 +9,7 @@ module LinkChecker
7
9
  def initialize(checker, task_klass, uri, methods, options = {})
8
10
  @uri = uri
9
11
  @retries_left = checker.retries
12
+ @retry_count = 0
10
13
  @methods_left = methods.dup
11
14
  @methods = methods.dup
12
15
  @task_klass = task_klass
@@ -24,11 +27,13 @@ module LinkChecker
24
27
  def execute!
25
28
  if retry?
26
29
  @retries_left -= 1
30
+ @retry_count += 1
27
31
  retry! @result
28
- _queue_task(uri, method, original_uri || uri, options)
32
+ _queue_task(uri, method, original_uri || uri, options, delay: _retry_delay)
29
33
  elsif methods_left.any?
30
34
  @method = methods_left.shift
31
35
  @uri = URI(@uri) unless @uri.is_a?(URI)
36
+ @redirects = [@uri] if @result.nil?
32
37
  _queue_task(uri, method, original_uri || uri, options)
33
38
  elsif @result && result.error?
34
39
  error! @result
@@ -57,11 +62,37 @@ module LinkChecker
57
62
  end
58
63
 
59
64
  def retry?
60
- !first_time? && retries_left > 0
65
+ !first_time? && retries_left > 0 && retryable?
66
+ end
67
+
68
+ def retryable?
69
+ return false unless result
70
+ return result.error? || result.failure? if checker.retry_status_codes.nil?
71
+ return %i[timeout connection].include?(result.error_type) if result.error?
72
+
73
+ result.failure? && checker.retry_status_codes.include?(result.code)
74
+ end
75
+
76
+ def _retry_delay
77
+ delay = checker.retry_after.zero? ? 0 : checker.retry_after * (checker.retry_backoff**(@retry_count - 1))
78
+ delay = [delay, _server_retry_delay].max if checker.respect_retry_after
79
+ delay *= 1 + (rand * checker.retry_jitter) if checker.retry_jitter.positive?
80
+ [delay, checker.max_retry_delay].min
81
+ end
82
+
83
+ def _server_retry_delay
84
+ headers = result.response_headers
85
+ value = Array(headers['Retry-After'] || headers['retry-after']).first.to_s.strip
86
+ return value.to_i if /\A\d+\z/.match?(value)
87
+
88
+ [Time.httpdate(value) - Time.now, 0].max
89
+ rescue ArgumentError
90
+ 0
61
91
  end
62
92
 
63
- def _queue_task(uri, method, original_uri, options = {})
93
+ def _queue_task(uri, method, original_uri, options = {}, delay: 0)
64
94
  task = new_task(uri, method, original_uri, options)
95
+ task.delay = delay
65
96
  task.on :result do |result|
66
97
  _handle_result result
67
98
  end
@@ -88,14 +119,17 @@ module LinkChecker
88
119
  result! result
89
120
  if result.redirect?
90
121
  redirect! result
91
- redirected_to_uri = URI.join(uri, result.redirect_to)
122
+ redirected_to_uri = URI.join(result.result_uri, result.redirect_to)
92
123
  if redirects.include?(redirected_to_uri)
93
124
  raise LinkChecker::Errors::RedirectLoopError,
94
125
  redirects.push(redirected_to_uri)
95
126
  end
127
+ if redirects.length - 1 >= checker.max_redirects
128
+ raise LinkChecker::Errors::RedirectLimitError.new(checker.max_redirects, redirects + [redirected_to_uri])
129
+ end
96
130
 
97
131
  redirects << redirected_to_uri
98
- _queue_task(redirected_to_uri, result.method, uri, options)
132
+ _queue_task(redirected_to_uri, result.method, result.uri, options)
99
133
  elsif result.success?
100
134
  success! result
101
135
  else
@@ -103,8 +137,10 @@ module LinkChecker
103
137
  execute!
104
138
  end
105
139
  rescue StandardError => e
106
- logger.error("#{self}##{__method__}") { e }
107
- _handle_result ResultError.new(result.uri, result.method, result.result_uri, e, options)
140
+ logger.error("#{self}##{__method__}") { e } rescue nil
141
+ error_result = ResultError.new(result.result_uri, result.method, result.uri, e, options)
142
+ result! error_result
143
+ error! error_result
108
144
  end
109
145
  end
110
146
  end
@@ -5,28 +5,32 @@ module LinkChecker
5
5
  def run!
6
6
  request = ::Typhoeus::Request.new(
7
7
  uri, {
8
- method: method,
8
+ method: method.downcase.to_sym,
9
9
  followlocation: false,
10
10
  timeout: checker.timeout,
11
11
  connecttimeout: checker.connecttimeout,
12
12
  headers: {
13
13
  'User-Agent' => checker.user_agent
14
- }
14
+ }.merge(checker.headers)
15
15
  }
16
16
  )
17
17
  request.on_complete do |response|
18
18
  if response.timed_out?
19
19
  result! ResultError.new(uri, method, original_uri, Timeout::Error.new, options)
20
+ elsif response.code.zero?
21
+ error = LinkChecker::Errors::ConnectionError.new(response.return_code, response.return_message)
22
+ result! ResultError.new(uri, method, original_uri, error, options)
20
23
  else
21
24
  result! Result.new(uri, method, original_uri, request, response, options)
22
25
  end
23
26
  end
24
- checker._queue(request)
27
+ checker._queue(request, delay: delay)
25
28
  end
26
29
  end
27
30
 
28
31
  class Checker < LinkChecker::Checker
29
32
  extend ::LinkChecker::Typhoeus::Hydra::Config
33
+
30
34
  attr_accessor(*LinkChecker::Typhoeus::Hydra::Config::ATTRIBUTES)
31
35
 
32
36
  def initialize(options = {})
@@ -34,15 +38,34 @@ module LinkChecker
34
38
  send("#{key}=", options[key] || LinkChecker::Typhoeus::Hydra::Config.send(key))
35
39
  end
36
40
  @hydra = ::Typhoeus::Hydra.new(options[:hydra] || { max_concurrency: 10 })
41
+ @delayed = []
37
42
  super options
38
43
  end
39
44
 
40
45
  def run
41
- @hydra.run
46
+ loop do
47
+ @hydra.run
48
+ break if @delayed.empty?
49
+
50
+ deadline = @delayed.map(&:first).min
51
+ sleep([deadline - monotonic_time, 0].max)
52
+ ready, @delayed = @delayed.partition { |time, _request| time <= monotonic_time }
53
+ ready.each { |entry| @hydra.queue(entry.last) }
54
+ end
42
55
  end
43
56
 
44
- def _queue(request)
45
- @hydra.queue(request)
57
+ def _queue(request, delay: 0)
58
+ if delay.positive?
59
+ @delayed << [monotonic_time + delay, request]
60
+ else
61
+ @hydra.queue(request)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def monotonic_time
68
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
69
  end
47
70
  end
48
71
  end
@@ -30,6 +30,10 @@ module LinkChecker
30
30
  request.options[:headers]
31
31
  end
32
32
 
33
+ def response_headers
34
+ response&.headers || {}
35
+ end
36
+
33
37
  def redirect_to
34
38
  return nil unless response
35
39
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LinkChecker
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.1'
5
5
  end
@@ -9,5 +9,7 @@ require_relative 'ruby-link-checker/task'
9
9
  require_relative 'ruby-link-checker/tasks'
10
10
  require_relative 'ruby-link-checker/checker'
11
11
  require_relative 'ruby-link-checker/result'
12
+ require_relative 'ruby-link-checker/cache'
12
13
  require_relative 'ruby-link-checker/net/http'
13
14
  require_relative 'ruby-link-checker/typhoeus/hydra'
15
+ require_relative 'ruby-link-checker/async/http'
@@ -10,12 +10,13 @@ Gem::Specification.new do |s|
10
10
  s.email = 'dblock@dblock.org'
11
11
  s.platform = Gem::Platform::RUBY
12
12
  s.required_rubygems_version = '>= 1.3.6'
13
- s.required_ruby_version = '>= 2.7'
14
- s.files = Dir['**/*']
13
+ s.required_ruby_version = '>= 3.3'
14
+ s.files = IO.popen(%w[git ls-files -z], &:read).split("\0")
15
15
  s.require_paths = ['lib']
16
16
  s.homepage = 'http://github.com/dblock/ruby-link-checker'
17
17
  s.licenses = ['MIT']
18
18
  s.summary = 'Fast ruby link checker.'
19
19
  s.metadata['rubygems_mfa_required'] = 'true'
20
+ s.add_development_dependency 'async-http'
20
21
  s.add_development_dependency 'typhoeus'
21
22
  end