ruby-link-checker 0.4.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.
- checksums.yaml +4 -4
- data/.github/workflows/coverage.yml +24 -0
- data/.github/workflows/danger-comment.yml +17 -0
- data/.github/workflows/danger.yml +12 -0
- data/.github/workflows/lint.yml +20 -0
- data/.github/workflows/release.yml +14 -0
- data/.github/workflows/test.yml +25 -0
- data/.gitignore +6 -0
- data/.rspec +2 -0
- data/.rubocop.yml +26 -0
- data/.rubocop_todo.yml +332 -0
- data/AGENTS.md +23 -0
- data/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +4 -0
- data/Dangerfile +0 -0
- data/Gemfile +3 -0
- data/LICENSE.md +0 -0
- data/README.md +78 -4
- data/RELEASING.md +17 -40
- data/Rakefile +8 -0
- data/benchmarks/check.rb +0 -0
- data/benchmarks/data/opensearch.org/links.txt +0 -0
- data/benchmarks/data/opensearch.org/small.txt +0 -0
- data/lib/ruby-link-checker/async/http/checker.rb +32 -6
- data/lib/ruby-link-checker/async/http/config.rb +4 -0
- data/lib/ruby-link-checker/async/http/result.rb +4 -0
- data/lib/ruby-link-checker/async/http.rb +0 -0
- data/lib/ruby-link-checker/cache.rb +101 -0
- data/lib/ruby-link-checker/callbacks.rb +0 -0
- data/lib/ruby-link-checker/checker.rb +76 -4
- data/lib/ruby-link-checker/config.rb +62 -0
- data/lib/ruby-link-checker/errors/base_error.rb +0 -0
- data/lib/ruby-link-checker/errors/connection_error.rb +23 -0
- data/lib/ruby-link-checker/errors/redirect_limit_error.rb +15 -0
- data/lib/ruby-link-checker/errors/redirect_loop_error.rb +0 -0
- data/lib/ruby-link-checker/errors.rb +2 -0
- data/lib/ruby-link-checker/logger.rb +0 -0
- data/lib/ruby-link-checker/net/http/checker.rb +2 -1
- data/lib/ruby-link-checker/net/http/config.rb +0 -0
- data/lib/ruby-link-checker/net/http/result.rb +4 -0
- data/lib/ruby-link-checker/net/http.rb +0 -0
- data/lib/ruby-link-checker/result.rb +25 -0
- data/lib/ruby-link-checker/task.rb +2 -0
- data/lib/ruby-link-checker/tasks.rb +40 -6
- data/lib/ruby-link-checker/typhoeus/hydra/checker.rb +27 -5
- data/lib/ruby-link-checker/typhoeus/hydra/config.rb +0 -0
- data/lib/ruby-link-checker/typhoeus/hydra/result.rb +4 -0
- data/lib/ruby-link-checker/typhoeus/hydra.rb +0 -0
- data/lib/ruby-link-checker/version.rb +1 -1
- data/lib/ruby-link-checker.rb +1 -0
- data/lib/ruby_link_checker.rb +0 -0
- data/ruby-link-checker.gemspec +1 -1
- data/spec/fixtures/200.yml +0 -0
- data/spec/fixtures/301_200.yml +0 -0
- data/spec/fixtures/301_301.yml +0 -0
- data/spec/fixtures/301_400_301_200.yml +0 -0
- data/spec/fixtures/301_403.yml +0 -0
- data/spec/fixtures/308_429.yml +0 -0
- data/spec/fixtures/404.yml +0 -0
- data/spec/fixtures/404_200.yml +0 -0
- data/spec/fixtures/404_404.yml +0 -0
- data/spec/fixtures/429_200.yml +0 -0
- data/spec/fixtures/429_429_200.yml +0 -0
- data/spec/ruby-link-checker/async/http/checker_spec.rb +86 -1
- data/spec/ruby-link-checker/callbacks_spec.rb +0 -0
- data/spec/ruby-link-checker/checker_spec.rb +89 -2
- data/spec/ruby-link-checker/config_spec.rb +6 -0
- data/spec/ruby-link-checker/errors/redirect_loop_error_spec.rb +0 -0
- data/spec/ruby-link-checker/net/http/checker_spec.rb +15 -0
- data/spec/ruby-link-checker/result_error_spec.rb +17 -0
- data/spec/ruby-link-checker/result_spec.rb +0 -0
- data/spec/ruby-link-checker/task_spec.rb +0 -0
- data/spec/ruby-link-checker/tasks_spec.rb +44 -0
- data/spec/ruby-link-checker/typhoeus/hydra/checker_spec.rb +88 -1
- data/spec/ruby-link-checker/version_spec.rb +0 -0
- data/spec/spec_helper.rb +0 -0
- data/spec/support/config.rb +0 -0
- data/spec/support/link_checker.rb +243 -3
- data/spec/support/vcr.rb +0 -0
- data/spec/support/with_result.rb +0 -0
- data/spec/support/with_url.rb +0 -0
- metadata +21 -34
- data/Gemfile.lock +0 -334
- data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_asc.png +0 -0
- data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_asc_disabled.png +0 -0
- data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_both.png +0 -0
- data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_desc.png +0 -0
- data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_desc_disabled.png +0 -0
- data/coverage/assets/0.13.2/application.css +0 -1
- data/coverage/assets/0.13.2/application.js +0 -7
- data/coverage/assets/0.13.2/colorbox/border.png +0 -0
- data/coverage/assets/0.13.2/colorbox/controls.png +0 -0
- data/coverage/assets/0.13.2/colorbox/loading.gif +0 -0
- data/coverage/assets/0.13.2/colorbox/loading_background.png +0 -0
- data/coverage/assets/0.13.2/favicon_green.png +0 -0
- data/coverage/assets/0.13.2/favicon_red.png +0 -0
- data/coverage/assets/0.13.2/favicon_yellow.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_flat_0_aaaaaa_40x100.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_flat_75_ffffff_40x100.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_glass_55_fbf9ee_1x400.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_glass_75_dadada_1x400.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_glass_75_e6e6e6_1x400.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_glass_95_fef1ec_1x400.png +0 -0
- data/coverage/assets/0.13.2/images/ui-bg_highlight-soft_75_cccccc_1x100.png +0 -0
- data/coverage/assets/0.13.2/images/ui-icons_222222_256x240.png +0 -0
- data/coverage/assets/0.13.2/images/ui-icons_2e83ff_256x240.png +0 -0
- data/coverage/assets/0.13.2/images/ui-icons_454545_256x240.png +0 -0
- data/coverage/assets/0.13.2/images/ui-icons_888888_256x240.png +0 -0
- data/coverage/assets/0.13.2/images/ui-icons_cd0a0a_256x240.png +0 -0
- data/coverage/assets/0.13.2/loading.gif +0 -0
- data/coverage/assets/0.13.2/magnify.png +0 -0
- data/coverage/index.html +0 -10746
|
File without changes
|
|
File without changes
|
|
@@ -3,7 +3,8 @@ module LinkChecker
|
|
|
3
3
|
module HTTP
|
|
4
4
|
class Task < ::LinkChecker::Task
|
|
5
5
|
def run!
|
|
6
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
@@ -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)
|
|
61
74
|
end
|
|
62
75
|
|
|
63
|
-
def
|
|
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
|
|
91
|
+
end
|
|
92
|
+
|
|
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(
|
|
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
|
|
@@ -104,7 +138,7 @@ module LinkChecker
|
|
|
104
138
|
end
|
|
105
139
|
rescue StandardError => e
|
|
106
140
|
logger.error("#{self}##{__method__}") { e } rescue nil
|
|
107
|
-
error_result = ResultError.new(result.
|
|
141
|
+
error_result = ResultError.new(result.result_uri, result.method, result.uri, e, options)
|
|
108
142
|
result! error_result
|
|
109
143
|
error! error_result
|
|
110
144
|
end
|
|
@@ -5,7 +5,7 @@ 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,
|
|
@@ -17,11 +17,14 @@ module LinkChecker
|
|
|
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
|
|
|
@@ -35,15 +38,34 @@ module LinkChecker
|
|
|
35
38
|
send("#{key}=", options[key] || LinkChecker::Typhoeus::Hydra::Config.send(key))
|
|
36
39
|
end
|
|
37
40
|
@hydra = ::Typhoeus::Hydra.new(options[:hydra] || { max_concurrency: 10 })
|
|
41
|
+
@delayed = []
|
|
38
42
|
super options
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def run
|
|
42
|
-
|
|
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
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def _queue(request, delay: 0)
|
|
58
|
+
if delay.positive?
|
|
59
|
+
@delayed << [monotonic_time + delay, request]
|
|
60
|
+
else
|
|
61
|
+
@hydra.queue(request)
|
|
62
|
+
end
|
|
43
63
|
end
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def monotonic_time
|
|
68
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
47
69
|
end
|
|
48
70
|
end
|
|
49
71
|
end
|
|
File without changes
|
|
File without changes
|
data/lib/ruby-link-checker.rb
CHANGED
|
@@ -9,6 +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'
|
|
14
15
|
require_relative 'ruby-link-checker/async/http'
|
data/lib/ruby_link_checker.rb
CHANGED
|
File without changes
|
data/ruby-link-checker.gemspec
CHANGED
|
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
|
|
|
11
11
|
s.platform = Gem::Platform::RUBY
|
|
12
12
|
s.required_rubygems_version = '>= 1.3.6'
|
|
13
13
|
s.required_ruby_version = '>= 3.3'
|
|
14
|
-
s.files =
|
|
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']
|
data/spec/fixtures/200.yml
CHANGED
|
File without changes
|
data/spec/fixtures/301_200.yml
CHANGED
|
File without changes
|
data/spec/fixtures/301_301.yml
CHANGED
|
File without changes
|
|
File without changes
|
data/spec/fixtures/301_403.yml
CHANGED
|
File without changes
|
data/spec/fixtures/308_429.yml
CHANGED
|
File without changes
|
data/spec/fixtures/404.yml
CHANGED
|
File without changes
|
data/spec/fixtures/404_200.yml
CHANGED
|
File without changes
|
data/spec/fixtures/404_404.yml
CHANGED
|
File without changes
|
data/spec/fixtures/429_200.yml
CHANGED
|
File without changes
|
|
File without changes
|
|
@@ -21,9 +21,10 @@ describe LinkChecker::Async::HTTP::Checker do
|
|
|
21
21
|
config.hook_into :webmock
|
|
22
22
|
# async-http's WebMock adapter replays responses through a real HTTP/1
|
|
23
23
|
# parser, which rejects a Transfer-Encoding header alongside a
|
|
24
|
-
# Content-Length or an empty body
|
|
24
|
+
# Content-Length or an empty body. Let the adapter set Content-Length.
|
|
25
25
|
config.before_playback do |interaction|
|
|
26
26
|
interaction.response.headers.delete('Transfer-Encoding')
|
|
27
|
+
interaction.response.headers.delete_if { |key, _value| key.downcase == 'content-length' }
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
end
|
|
@@ -32,6 +33,43 @@ describe LinkChecker::Async::HTTP::Checker do
|
|
|
32
33
|
LinkChecker::Async::HTTP::Config.reset
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
describe 'concurrency' do
|
|
37
|
+
let(:checker) { described_class.new(max_concurrency: 2) }
|
|
38
|
+
let(:counts) { [] }
|
|
39
|
+
let(:completed) { [] }
|
|
40
|
+
|
|
41
|
+
before do
|
|
42
|
+
active = 0
|
|
43
|
+
5.times do
|
|
44
|
+
checker._queue do
|
|
45
|
+
counts << (active += 1)
|
|
46
|
+
sleep(0.005)
|
|
47
|
+
active -= 1
|
|
48
|
+
completed << active
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'bounds active work and waits for queued tasks' do
|
|
54
|
+
checker.run
|
|
55
|
+
expect(counts.max).to eq(2)
|
|
56
|
+
expect(counts.size).to eq(5)
|
|
57
|
+
expect(completed.size).to eq(5)
|
|
58
|
+
expect(completed.last).to eq(0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'rejects invalid limits' do
|
|
62
|
+
expect { described_class.new(max_concurrency: 0) }.to raise_error ArgumentError
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'reuses clients for the same endpoint' do
|
|
66
|
+
expect(checker._client_for(URI('https://www.example.org/one'))).to equal(
|
|
67
|
+
checker._client_for(URI('https://www.example.org/two'))
|
|
68
|
+
)
|
|
69
|
+
checker.run
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
35
73
|
describe AsyncTestLinkChecker::LinkChecker do
|
|
36
74
|
it_behaves_like 'a link checker'
|
|
37
75
|
|
|
@@ -60,6 +98,53 @@ describe LinkChecker::Async::HTTP::Checker do
|
|
|
60
98
|
end
|
|
61
99
|
end
|
|
62
100
|
|
|
101
|
+
context 'with a protocol option', vcr: { cassette_name: '200' } do
|
|
102
|
+
before do
|
|
103
|
+
LinkChecker::Async::HTTP.configure do |config|
|
|
104
|
+
config.protocol = protocol
|
|
105
|
+
end
|
|
106
|
+
expect(Async::HTTP::Endpoint).to receive(:parse).with(
|
|
107
|
+
URI(url).to_s,
|
|
108
|
+
hash_including(protocol: expected_protocol)
|
|
109
|
+
).and_call_original
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
include_context 'with url'
|
|
113
|
+
|
|
114
|
+
context 'http1' do
|
|
115
|
+
let(:protocol) { :http1 }
|
|
116
|
+
let(:expected_protocol) { Async::HTTP::Protocol::HTTP1 }
|
|
117
|
+
|
|
118
|
+
it 'creates requests using HTTP/1' do
|
|
119
|
+
expect(result.success?).to be true
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
context 'http2' do
|
|
124
|
+
let(:protocol) { :http2 }
|
|
125
|
+
let(:expected_protocol) { Async::HTTP::Protocol::HTTP2 }
|
|
126
|
+
|
|
127
|
+
it 'creates requests using HTTP/2' do
|
|
128
|
+
expect(result.success?).to be true
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
context 'with an unsupported protocol option' do
|
|
134
|
+
before do
|
|
135
|
+
LinkChecker::Async::HTTP.configure do |config|
|
|
136
|
+
config.protocol = :http3
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
include_context 'with url'
|
|
141
|
+
|
|
142
|
+
it 'raises an ArgumentError' do
|
|
143
|
+
expect(result.error?).to be true
|
|
144
|
+
expect(result.error.message).to eq('Unsupported protocol: :http3, expected :http1 or :http2')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
63
148
|
context 'timeout' do
|
|
64
149
|
before do
|
|
65
150
|
stub_request(:get, 'https://www.example.org/').to_timeout
|
|
File without changes
|
|
@@ -2,9 +2,96 @@
|
|
|
2
2
|
require 'spec_helper'
|
|
3
3
|
|
|
4
4
|
describe LinkChecker::Checker do
|
|
5
|
-
|
|
5
|
+
describe LinkChecker::Cache do
|
|
6
|
+
let(:directory) { Dir.mktmpdir('link-checker-spec-') }
|
|
7
|
+
let(:file) { File.join(directory, 'cache.json') }
|
|
8
|
+
let(:cache) { described_class.new({ file: file, ttl: 60 }, logger: LinkChecker::Logger.default) }
|
|
9
|
+
let(:result) do
|
|
10
|
+
LinkChecker::Net::HTTP::Result.new(
|
|
11
|
+
URI('https://www.example.org/final'), 'GET', URI('https://www.example.org/'),
|
|
12
|
+
{}, Net::HTTPOK.new('1.1', '200', 'OK'), { location: 'private.html' }
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
after do
|
|
17
|
+
FileUtils.remove_entry(directory)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'expires successful entries using the configured TTL' do
|
|
21
|
+
cache.store('key', result)
|
|
22
|
+
allow(Time).to receive(:now).and_return(Time.now + 61)
|
|
23
|
+
expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'retains the final URL and status without persisting metadata or headers' do
|
|
27
|
+
cache.store('key', result)
|
|
28
|
+
restored = cache.fetch('key', 'https://www.example.org/')
|
|
29
|
+
expect(restored.result_uri.to_s).to eq('https://www.example.org/final')
|
|
30
|
+
expect(restored.code).to eq(200)
|
|
31
|
+
data = JSON.parse(File.read(file)).fetch('entries').fetch('key')
|
|
32
|
+
expect(data.keys).to contain_exactly('checked_at', 'method', 'code', 'result_uri')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'merges writes from separate cache instances' do
|
|
36
|
+
other_cache = described_class.new({ file: file, ttl: 60 }, logger: LinkChecker::Logger.default)
|
|
37
|
+
cache.store('one', result)
|
|
38
|
+
other_cache.store('two', result)
|
|
39
|
+
expect(cache.fetch('one', 'https://www.example.org/')).not_to be_nil
|
|
40
|
+
expect(cache.fetch('two', 'https://www.example.org/')).not_to be_nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'recovers from corrupted cache files' do
|
|
44
|
+
File.write(file, 'invalid JSON')
|
|
45
|
+
expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
|
|
46
|
+
cache.store('key', result)
|
|
47
|
+
expect(cache.fetch('key', 'https://www.example.org/').code).to eq(200)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'ignores malformed cache entries' do
|
|
51
|
+
File.write(file, JSON.generate('version' => 1, 'entries' => { 'key' => true }))
|
|
52
|
+
expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'does not store credentials in redirect URLs' do
|
|
56
|
+
result.result_uri = URI('https://user:password@www.example.org/')
|
|
57
|
+
cache.store('key', result)
|
|
58
|
+
expect(File).not_to exist(file)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'requires an explicit file and TTL' do
|
|
62
|
+
expect { described_class.new({}, logger: LinkChecker::Logger.default) }.to raise_error ArgumentError
|
|
63
|
+
expect { described_class.new({ file: file }, logger: LinkChecker::Logger.default) }.to raise_error ArgumentError
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'does not turn a cache write failure into a failed link' do
|
|
67
|
+
allow(FileUtils).to receive(:mkdir_p).and_raise(Errno::EACCES)
|
|
68
|
+
expect { cache.store('key', result) }.not_to raise_error
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'config' do
|
|
6
73
|
it 'requires at least one method' do
|
|
7
|
-
expect {
|
|
74
|
+
expect { described_class.new(methods: []) }.to raise_error ArgumentError, 'Missing methods.'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'rejects an invalid redirect limit' do
|
|
78
|
+
expect { described_class.new(max_redirects: -1) }.to raise_error ArgumentError, 'Invalid redirect limit: -1'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'validates retry delays and backoff' do
|
|
82
|
+
expect { described_class.new(retry_after: -1) }.to raise_error ArgumentError
|
|
83
|
+
expect { described_class.new(retry_backoff: 0.5) }.to raise_error ArgumentError
|
|
84
|
+
expect { described_class.new(retry_jitter: 2) }.to raise_error ArgumentError
|
|
85
|
+
expect { described_class.new(max_retry_delay: Float::INFINITY) }.to raise_error ArgumentError
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'rejects invalid retry status codes' do
|
|
89
|
+
expect { described_class.new(retry_status_codes: ['503']) }.to raise_error ArgumentError
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'allows per-instance disabling of global Retry-After handling' do
|
|
93
|
+
LinkChecker.config.respect_retry_after = true
|
|
94
|
+
expect(described_class.new(respect_retry_after: false).respect_retry_after).to be false
|
|
8
95
|
end
|
|
9
96
|
end
|
|
10
97
|
end
|
|
@@ -19,6 +19,12 @@ describe LinkChecker::Config do
|
|
|
19
19
|
it 'requires a positive integer' do
|
|
20
20
|
expect { LinkChecker.config.retries = -1 }.to raise_error ArgumentError, 'Invalid number of retries: -1'
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
context 'max_redirects' do
|
|
24
|
+
it 'rejects negative limits' do
|
|
25
|
+
expect { LinkChecker.config.max_redirects = -1 }.to raise_error ArgumentError, 'Invalid redirect limit: -1'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
22
28
|
end
|
|
23
29
|
end
|
|
24
30
|
|
|
File without changes
|
|
@@ -15,6 +15,21 @@ describe LinkChecker::Net::HTTP::Checker do
|
|
|
15
15
|
|
|
16
16
|
it_behaves_like 'a link checker'
|
|
17
17
|
|
|
18
|
+
context 'with an HTTP URL' do
|
|
19
|
+
let(:checker) { described_class.new(methods: ['GET']) }
|
|
20
|
+
|
|
21
|
+
around do |example|
|
|
22
|
+
VCR.turned_off { example.run }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'does not enable TLS' do
|
|
26
|
+
stub_request(:get, 'http://www.example.org/').to_return(status: 200)
|
|
27
|
+
checker.check('http://www.example.org/')
|
|
28
|
+
expect(checker.results[:success].first.code).to eq(200)
|
|
29
|
+
expect(a_request(:get, 'http://www.example.org/')).to have_been_made.once
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
18
33
|
describe '.config' do
|
|
19
34
|
it 'returns the Config module' do
|
|
20
35
|
expect(LinkChecker::Net::HTTP.config).to eq LinkChecker::Net::HTTP::Config
|
|
@@ -14,4 +14,21 @@ describe LinkChecker::ResultError do
|
|
|
14
14
|
it 'returns the error class name as code' do
|
|
15
15
|
expect(result.code).to eq 'StandardError'
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
it 'classifies native timeouts without replacing the exception' do
|
|
19
|
+
error = Net::ReadTimeout.new('timeout')
|
|
20
|
+
result.error = error
|
|
21
|
+
expect(result.error_type).to eq(:timeout)
|
|
22
|
+
expect(result.error).to equal(error)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'classifies native connection failures' do
|
|
26
|
+
result.error = Errno::ECONNREFUSED.new
|
|
27
|
+
expect(result.error_type).to eq(:connection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'does not classify programming errors as transient' do
|
|
31
|
+
result.error = ArgumentError.new
|
|
32
|
+
expect(result.error_type).to eq(:other)
|
|
33
|
+
end
|
|
17
34
|
end
|
|
File without changes
|
|
File without changes
|
|
@@ -3,6 +3,50 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe LinkChecker::Tasks do
|
|
6
|
+
describe 'retry delays' do
|
|
7
|
+
let(:checker) { LinkChecker::Net::HTTP::Checker.new }
|
|
8
|
+
let(:tasks) { described_class.new(checker, LinkChecker::Net::HTTP::Task, 'https://www.example.org', ['GET']) }
|
|
9
|
+
let(:headers) { { 'Retry-After' => '60' } }
|
|
10
|
+
let(:result) { instance_double(LinkChecker::Net::HTTP::Result, response_headers: headers) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
allow(tasks).to receive(:result).and_return(result)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'parses delta-seconds' do
|
|
17
|
+
expect(tasks.send(:_server_retry_delay)).to eq(60)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
context 'with a past HTTP date' do
|
|
21
|
+
let(:headers) { { 'Retry-After' => (Time.now - 60).httpdate } }
|
|
22
|
+
|
|
23
|
+
it 'does not delay a retry' do
|
|
24
|
+
expect(tasks.send(:_server_retry_delay)).to eq(0)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context 'with an invalid negative delay' do
|
|
29
|
+
let(:headers) { { 'Retry-After' => '-5' } }
|
|
30
|
+
|
|
31
|
+
it 'does not delay a retry' do
|
|
32
|
+
expect(tasks.send(:_server_retry_delay)).to eq(0)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'caps configured delays' do
|
|
37
|
+
checker.retry_after = 120
|
|
38
|
+
checker.max_retry_delay = 10
|
|
39
|
+
expect(tasks.send(:_retry_delay)).to eq(10)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'adds jitter without reducing the minimum delay' do
|
|
43
|
+
checker.retry_after = 2
|
|
44
|
+
checker.retry_jitter = 0.5
|
|
45
|
+
allow(tasks).to receive(:rand).and_return(0.5)
|
|
46
|
+
expect(tasks.send(:_retry_delay)).to eq(2.5)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
6
50
|
describe '#_handle_result' do
|
|
7
51
|
# Reproduces issue #12: SystemStackError when logging causes repeated errors
|
|
8
52
|
# https://github.com/dblock/ruby-link-checker/issues/12
|