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.
Files changed (113) 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 +15 -0
  14. data/CONTRIBUTING.md +4 -0
  15. data/Dangerfile +0 -0
  16. data/Gemfile +3 -0
  17. data/LICENSE.md +0 -0
  18. data/README.md +78 -4
  19. data/RELEASING.md +17 -40
  20. data/Rakefile +8 -0
  21. data/benchmarks/check.rb +0 -0
  22. data/benchmarks/data/opensearch.org/links.txt +0 -0
  23. data/benchmarks/data/opensearch.org/small.txt +0 -0
  24. data/lib/ruby-link-checker/async/http/checker.rb +32 -6
  25. data/lib/ruby-link-checker/async/http/config.rb +4 -0
  26. data/lib/ruby-link-checker/async/http/result.rb +4 -0
  27. data/lib/ruby-link-checker/async/http.rb +0 -0
  28. data/lib/ruby-link-checker/cache.rb +101 -0
  29. data/lib/ruby-link-checker/callbacks.rb +0 -0
  30. data/lib/ruby-link-checker/checker.rb +76 -4
  31. data/lib/ruby-link-checker/config.rb +62 -0
  32. data/lib/ruby-link-checker/errors/base_error.rb +0 -0
  33. data/lib/ruby-link-checker/errors/connection_error.rb +23 -0
  34. data/lib/ruby-link-checker/errors/redirect_limit_error.rb +15 -0
  35. data/lib/ruby-link-checker/errors/redirect_loop_error.rb +0 -0
  36. data/lib/ruby-link-checker/errors.rb +2 -0
  37. data/lib/ruby-link-checker/logger.rb +0 -0
  38. data/lib/ruby-link-checker/net/http/checker.rb +2 -1
  39. data/lib/ruby-link-checker/net/http/config.rb +0 -0
  40. data/lib/ruby-link-checker/net/http/result.rb +4 -0
  41. data/lib/ruby-link-checker/net/http.rb +0 -0
  42. data/lib/ruby-link-checker/result.rb +25 -0
  43. data/lib/ruby-link-checker/task.rb +2 -0
  44. data/lib/ruby-link-checker/tasks.rb +40 -6
  45. data/lib/ruby-link-checker/typhoeus/hydra/checker.rb +27 -5
  46. data/lib/ruby-link-checker/typhoeus/hydra/config.rb +0 -0
  47. data/lib/ruby-link-checker/typhoeus/hydra/result.rb +4 -0
  48. data/lib/ruby-link-checker/typhoeus/hydra.rb +0 -0
  49. data/lib/ruby-link-checker/version.rb +1 -1
  50. data/lib/ruby-link-checker.rb +1 -0
  51. data/lib/ruby_link_checker.rb +0 -0
  52. data/ruby-link-checker.gemspec +1 -1
  53. data/spec/fixtures/200.yml +0 -0
  54. data/spec/fixtures/301_200.yml +0 -0
  55. data/spec/fixtures/301_301.yml +0 -0
  56. data/spec/fixtures/301_400_301_200.yml +0 -0
  57. data/spec/fixtures/301_403.yml +0 -0
  58. data/spec/fixtures/308_429.yml +0 -0
  59. data/spec/fixtures/404.yml +0 -0
  60. data/spec/fixtures/404_200.yml +0 -0
  61. data/spec/fixtures/404_404.yml +0 -0
  62. data/spec/fixtures/429_200.yml +0 -0
  63. data/spec/fixtures/429_429_200.yml +0 -0
  64. data/spec/ruby-link-checker/async/http/checker_spec.rb +86 -1
  65. data/spec/ruby-link-checker/callbacks_spec.rb +0 -0
  66. data/spec/ruby-link-checker/checker_spec.rb +89 -2
  67. data/spec/ruby-link-checker/config_spec.rb +6 -0
  68. data/spec/ruby-link-checker/errors/redirect_loop_error_spec.rb +0 -0
  69. data/spec/ruby-link-checker/net/http/checker_spec.rb +15 -0
  70. data/spec/ruby-link-checker/result_error_spec.rb +17 -0
  71. data/spec/ruby-link-checker/result_spec.rb +0 -0
  72. data/spec/ruby-link-checker/task_spec.rb +0 -0
  73. data/spec/ruby-link-checker/tasks_spec.rb +44 -0
  74. data/spec/ruby-link-checker/typhoeus/hydra/checker_spec.rb +88 -1
  75. data/spec/ruby-link-checker/version_spec.rb +0 -0
  76. data/spec/spec_helper.rb +0 -0
  77. data/spec/support/config.rb +0 -0
  78. data/spec/support/link_checker.rb +243 -3
  79. data/spec/support/vcr.rb +0 -0
  80. data/spec/support/with_result.rb +0 -0
  81. data/spec/support/with_url.rb +0 -0
  82. metadata +21 -34
  83. data/Gemfile.lock +0 -334
  84. data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_asc.png +0 -0
  85. data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_asc_disabled.png +0 -0
  86. data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_both.png +0 -0
  87. data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_desc.png +0 -0
  88. data/coverage/assets/0.13.2/DataTables-1.10.20/images/sort_desc_disabled.png +0 -0
  89. data/coverage/assets/0.13.2/application.css +0 -1
  90. data/coverage/assets/0.13.2/application.js +0 -7
  91. data/coverage/assets/0.13.2/colorbox/border.png +0 -0
  92. data/coverage/assets/0.13.2/colorbox/controls.png +0 -0
  93. data/coverage/assets/0.13.2/colorbox/loading.gif +0 -0
  94. data/coverage/assets/0.13.2/colorbox/loading_background.png +0 -0
  95. data/coverage/assets/0.13.2/favicon_green.png +0 -0
  96. data/coverage/assets/0.13.2/favicon_red.png +0 -0
  97. data/coverage/assets/0.13.2/favicon_yellow.png +0 -0
  98. data/coverage/assets/0.13.2/images/ui-bg_flat_0_aaaaaa_40x100.png +0 -0
  99. data/coverage/assets/0.13.2/images/ui-bg_flat_75_ffffff_40x100.png +0 -0
  100. data/coverage/assets/0.13.2/images/ui-bg_glass_55_fbf9ee_1x400.png +0 -0
  101. data/coverage/assets/0.13.2/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  102. data/coverage/assets/0.13.2/images/ui-bg_glass_75_dadada_1x400.png +0 -0
  103. data/coverage/assets/0.13.2/images/ui-bg_glass_75_e6e6e6_1x400.png +0 -0
  104. data/coverage/assets/0.13.2/images/ui-bg_glass_95_fef1ec_1x400.png +0 -0
  105. data/coverage/assets/0.13.2/images/ui-bg_highlight-soft_75_cccccc_1x100.png +0 -0
  106. data/coverage/assets/0.13.2/images/ui-icons_222222_256x240.png +0 -0
  107. data/coverage/assets/0.13.2/images/ui-icons_2e83ff_256x240.png +0 -0
  108. data/coverage/assets/0.13.2/images/ui-icons_454545_256x240.png +0 -0
  109. data/coverage/assets/0.13.2/images/ui-icons_888888_256x240.png +0 -0
  110. data/coverage/assets/0.13.2/images/ui-icons_cd0a0a_256x240.png +0 -0
  111. data/coverage/assets/0.13.2/loading.gif +0 -0
  112. data/coverage/assets/0.13.2/magnify.png +0 -0
  113. data/coverage/index.html +0 -10746
File without changes
@@ -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'
File without changes
@@ -3,7 +3,8 @@ 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)
File without changes
@@ -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
 
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 _queue_task(uri, method, original_uri, options = {})
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(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
@@ -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.uri, result.method, result.result_uri, e, options)
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
- @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
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
- def _queue(request)
46
- @hydra.queue(request)
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
@@ -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
 
File without changes
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LinkChecker
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.1'
5
5
  end
@@ -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'
File without changes
@@ -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 = Dir['**/*']
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']
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
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, unlike the other checkers' adapters.
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
- context 'config' do
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 { LinkChecker::Checker.new(methods: []) }.to raise_error ArgumentError, 'Missing methods.'
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
 
@@ -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