rack-timeout 0.4.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,7 +3,6 @@ require "securerandom"
3
3
  require_relative "support/monotonic_time"
4
4
  require_relative "support/scheduler"
5
5
  require_relative "support/timeout"
6
- require_relative "legacy"
7
6
 
8
7
  module Rack
9
8
  class Timeout
@@ -31,6 +30,7 @@ module Rack
31
30
  :service, # time rack spent processing the request (updated ~ every second)
32
31
  :timeout, # the actual computed timeout to be used for this request
33
32
  :state, # the request's current state, see VALID_STATES below
33
+ :term,
34
34
  ) {
35
35
  def ms(k) # helper method used for formatting values in milliseconds
36
36
  "%.fms" % (self[k] * 1000) if self[k]
@@ -43,14 +43,18 @@ module Rack
43
43
  :timed_out, # This request has run for too long and we're raising a timeout error in it
44
44
  :completed, # We're done with this request (also set after having timed out a request)
45
45
  ]
46
- ENV_INFO_KEY = "rack-timeout.info" # key under which each request's RequestDetails instance is stored in its env.
46
+ ENV_INFO_KEY = "rack-timeout.info".freeze # key under which each request's RequestDetails instance is stored in its env.
47
+ HTTP_X_REQUEST_ID = "HTTP_X_REQUEST_ID".freeze # key where request id is stored if generated by upstream client/proxy
48
+ ACTION_DISPATCH_REQUEST_ID = "action_dispatch.request_id".freeze # key where request id is stored if generated by action dispatch
47
49
 
48
50
  # helper methods to read timeout properties. Ensure they're always positive numbers or false. When set to false (or 0), their behaviour is disabled.
49
51
  def read_timeout_property value, default
50
52
  case value
51
- when nil ; default
53
+ when nil ; read_timeout_property default, default
52
54
  when false ; false
53
55
  when 0 ; false
56
+ when String
57
+ read_timeout_property value.to_i, default
54
58
  else
55
59
  value.is_a?(Numeric) && value > 0 or raise ArgumentError, "value #{value.inspect} should be false, zero, or a positive number."
56
60
  value
@@ -61,13 +65,21 @@ module Rack
61
65
  :service_timeout, # How long the application can take to complete handling the request once it's passed down to it.
62
66
  :wait_timeout, # How long the request is allowed to have waited before reaching rack. If exceeded, the request is 'expired', i.e. dropped entirely without being passed down to the application.
63
67
  :wait_overtime, # Additional time over @wait_timeout for requests with a body, like POST requests. These may take longer to be received by the server before being passed down to the application, but should not be expired.
64
- :service_past_wait # when false, reduces the request's computed timeout from the service_timeout value if the complete request lifetime (wait + service) would have been longer than wait_timeout (+ wait_overtime when applicable). When true, always uses the service_timeout value. we default to false under the assumption that the router would drop a request that's not responded within wait_timeout, thus being there no point in servicing beyond seconds_service_left (see code further down) up until service_timeout.
65
-
66
- def initialize(app, service_timeout:nil, wait_timeout:nil, wait_overtime:nil, service_past_wait:false)
67
- @service_timeout = read_timeout_property service_timeout, 15
68
- @wait_timeout = read_timeout_property wait_timeout, 30
69
- @wait_overtime = read_timeout_property wait_overtime, 60
70
- @service_past_wait = service_past_wait
68
+ :service_past_wait, # when false, reduces the request's computed timeout from the service_timeout value if the complete request lifetime (wait + service) would have been longer than wait_timeout (+ wait_overtime when applicable). When true, always uses the service_timeout value. we default to false under the assumption that the router would drop a request that's not responded within wait_timeout, thus being there no point in servicing beyond seconds_service_left (see code further down) up until service_timeout.
69
+ :term_on_timeout
70
+
71
+ def initialize(app, service_timeout:nil, wait_timeout:nil, wait_overtime:nil, service_past_wait:"not_specified", term_on_timeout: nil)
72
+ @term_on_timeout = read_timeout_property term_on_timeout, ENV.fetch("RACK_TIMEOUT_TERM_ON_TIMEOUT", false)
73
+ @service_timeout = read_timeout_property service_timeout, ENV.fetch("RACK_TIMEOUT_SERVICE_TIMEOUT", 15).to_i
74
+ @wait_timeout = read_timeout_property wait_timeout, ENV.fetch("RACK_TIMEOUT_WAIT_TIMEOUT", 30).to_i
75
+ @wait_overtime = read_timeout_property wait_overtime, ENV.fetch("RACK_TIMEOUT_WAIT_OVERTIME", 60).to_i
76
+ @service_past_wait = service_past_wait == "not_specified" ? ENV.fetch("RACK_TIMEOUT_SERVICE_PAST_WAIT", false).to_s != "false" : service_past_wait
77
+
78
+ Thread.main['RACK_TIMEOUT_COUNT'] ||= 0
79
+ if @term_on_timeout
80
+ raise "term_on_timeout must be an integer but is #{@term_on_timeout.class}: #{@term_on_timeout}" unless @term_on_timeout.is_a?(Numeric)
81
+ raise "Current Runtime does not support processes" unless ::Process.respond_to?(:fork)
82
+ end
71
83
  @app = app
72
84
  end
73
85
 
@@ -75,7 +87,7 @@ module Rack
75
87
  RT = self # shorthand reference
76
88
  def call(env)
77
89
  info = (env[ENV_INFO_KEY] ||= RequestDetails.new)
78
- info.id ||= env["HTTP_X_REQUEST_ID"] || SecureRandom.hex
90
+ info.id ||= env[HTTP_X_REQUEST_ID] || env[ACTION_DISPATCH_REQUEST_ID] || SecureRandom.uuid
79
91
 
80
92
  time_started_service = Time.now # The wall time the request started being processed by rack
81
93
  ts_started_service = fsecs # The monotonic time the request started being processed by rack
@@ -89,7 +101,9 @@ module Rack
89
101
  seconds_waited = 0 if seconds_waited < 0 # make up for potential time drift between the routing server and the application server
90
102
  final_wait_timeout = wait_timeout + effective_overtime # how long the request will be allowed to have waited
91
103
  seconds_service_left = final_wait_timeout - seconds_waited # first calculation of service timeout (relevant if request doesn't get expired, may be overriden later)
92
- info.wait, info.timeout = seconds_waited, final_wait_timeout # updating the info properties; info.timeout will be the wait timeout at this point
104
+ info.wait = seconds_waited # updating the info properties; info.timeout will be the wait timeout at this point
105
+ info.timeout = final_wait_timeout
106
+
93
107
  if seconds_service_left <= 0 # expire requests that have waited for too long in the queue (as they are assumed to have been dropped by the web server / routing layer at this point)
94
108
  RT._set_state! env, :expired
95
109
  raise RequestExpiryError.new(env), "Request older than #{info.ms(:timeout)}."
@@ -102,7 +116,7 @@ module Rack
102
116
  # compute actual timeout to be used for this request; if service_past_wait is true, this is just service_timeout. If false (the default), and wait time was determined, we'll use the shortest value between seconds_service_left and service_timeout. See comment above at service_past_wait for justification.
103
117
  info.timeout = service_timeout # nice and simple, when service_past_wait is true, not so much otherwise:
104
118
  info.timeout = seconds_service_left if !service_past_wait && seconds_service_left && seconds_service_left > 0 && seconds_service_left < service_timeout
105
-
119
+ info.term = term_on_timeout
106
120
  RT._set_state! env, :ready # we're good to go, but have done nothing yet
107
121
 
108
122
  heartbeat_event = nil # init var so it's in scope for following proc
@@ -115,7 +129,22 @@ module Rack
115
129
 
116
130
  timeout = RT::Scheduler::Timeout.new do |app_thread| # creates a timeout instance responsible for timing out the request. the given block runs if timed out
117
131
  register_state_change.call :timed_out
118
- app_thread.raise(RequestTimeoutException.new(env), "Request #{"waited #{info.ms(:wait)}, then " if info.wait}ran for longer than #{info.ms(:timeout)}")
132
+
133
+ message = "Request "
134
+ message << "waited #{info.ms(:wait)}, then " if info.wait
135
+ message << "ran for longer than #{info.ms(:timeout)} "
136
+ if term_on_timeout
137
+ Thread.main['RACK_TIMEOUT_COUNT'] += 1
138
+
139
+ if Thread.main['RACK_TIMEOUT_COUNT'] >= @term_on_timeout
140
+ message << ", sending SIGTERM to process #{Process.pid}"
141
+ Process.kill("SIGTERM", Process.pid)
142
+ else
143
+ message << ", #{Thread.main['RACK_TIMEOUT_COUNT']}/#{term_on_timeout} timeouts allowed before SIGTERM for process #{Process.pid}"
144
+ end
145
+ end
146
+
147
+ app_thread.raise(RequestTimeoutException.new(env), message)
119
148
  end
120
149
 
121
150
  response = timeout.timeout(info.timeout) do # perform request with timeout
@@ -146,8 +175,9 @@ module Rack
146
175
  # This is a code extraction for readability, this method is only called from a single point.
147
176
  RX_NGINX_X_REQUEST_START = /^(?:t=)?(\d+)\.(\d{3})$/
148
177
  RX_HEROKU_X_REQUEST_START = /^(\d+)$/
178
+ HTTP_X_REQUEST_START = "HTTP_X_REQUEST_START".freeze
149
179
  def self._read_x_request_start(env)
150
- return unless s = env["HTTP_X_REQUEST_START"]
180
+ return unless s = env[HTTP_X_REQUEST_START]
151
181
  return unless m = s.match(RX_HEROKU_X_REQUEST_START) || s.match(RX_NGINX_X_REQUEST_START)
152
182
  Time.at(m[1,2].join.to_f / 1000)
153
183
  end
@@ -189,6 +219,5 @@ module Rack
189
219
  def self.notify_state_change_observers(env)
190
220
  @state_change_observers.values.each { |observer| observer.call(env) }
191
221
  end
192
-
193
222
  end
194
223
  end
@@ -35,5 +35,4 @@ module Rack::Timeout::Logger
35
35
  @level = new_level || ::Logger::INFO
36
36
  self.logger = ::Rack::Timeout::StateChangeLoggingObserver.mk_logger(device, level)
37
37
  end
38
-
39
38
  end
@@ -8,6 +8,9 @@ class Rack::Timeout::StateChangeLoggingObserver
8
8
  :timed_out => :error,
9
9
  :completed => :info,
10
10
  }
11
+ def initialize
12
+ @logger = nil
13
+ end
11
14
 
12
15
  # returns the Proc to be used as the observer callback block
13
16
  def callback
@@ -45,9 +48,9 @@ class Rack::Timeout::StateChangeLoggingObserver
45
48
  s << " wait=" << info.ms(:wait) if info.wait
46
49
  s << " timeout=" << info.ms(:timeout) if info.timeout
47
50
  s << " service=" << info.ms(:service) if info.service
51
+ s << " term_on_timeout=" << info.term.to_s if info.term
48
52
  s << " state=" << info.state.to_s if info.state
49
53
  s
50
54
  end
51
55
  end
52
-
53
56
  end
@@ -3,6 +3,11 @@ require_relative "base"
3
3
  class Rack::Timeout::Railtie < Rails::Railtie
4
4
  initializer("rack-timeout.prepend") do |app|
5
5
  next if Rails.env.test?
6
- app.config.middleware.insert_before Rack::Runtime, Rack::Timeout
6
+
7
+ if defined?(ActionDispatch::RequestId)
8
+ app.config.middleware.insert_after(ActionDispatch::RequestId, Rack::Timeout)
9
+ else
10
+ app.config.middleware.insert_before(Rack::Runtime, Rack::Timeout)
11
+ end
7
12
  end
8
- end
13
+ end
@@ -1,53 +1 @@
1
- require_relative "core"
2
-
3
- # Groups timeout exceptions in rollbar by exception class, http method, and url.
4
- #
5
- # Usage: after requiring rollbar (say, in your rollbar initializer file), call:
6
- #
7
- # require "rack/timeout/rollbar"
8
- #
9
- # Ruby 2.0 is required as we use `Module.prepend`.
10
- #
11
- # To use a custom fingerprint for grouping:
12
- #
13
- # Rack::Timeout::Rollbar.fingerprint do |exception, env|
14
- # # … return some kind of string derived from exception and env
15
- # end
16
-
17
- module Rack::Timeout::Rollbar
18
-
19
- def self.fingerprint(&block)
20
- define_method(:rack_timeout_fingerprint) { |exception, env| block[exception, env] }
21
- end
22
-
23
- def self.default_rack_timeout_fingerprint(exception, env)
24
- request = ::Rack::Request.new(env)
25
- [ exception.class.name,
26
- request.request_method,
27
- request.path
28
- ].join(" ")
29
- end
30
-
31
- fingerprint &method(:default_rack_timeout_fingerprint)
32
-
33
-
34
- def build_payload(level, message, exception, extra)
35
- payload = super(level, message, exception, extra)
36
-
37
- return payload unless exception.is_a?(::Rack::Timeout::ExceptionWithEnv) \
38
- && payload.respond_to?(:[]) \
39
- && payload.respond_to?(:[]=)
40
-
41
- data = payload["data"]
42
- return payload unless data.respond_to?(:[]=)
43
-
44
- payload = payload.dup
45
- data = data.dup
46
- data["fingerprint"] = rack_timeout_fingerprint(exception, exception.env)
47
- payload["data"] = data
48
-
49
- return payload
50
- end
51
- end
52
-
53
- ::Rollbar::Notifier.prepend ::Rack::Timeout::Rollbar
1
+ warn 'DEPRECATION WARNING: The Rollbar module was removed from rack-timeout. For more details check the README on heroku/rack-timeout'
@@ -25,5 +25,4 @@ module Rack::Timeout::MonotonicTime
25
25
  when RUBY_PLATFORM == "java" ; alias fsecs fsecs_java
26
26
  else ; alias fsecs fsecs_ruby
27
27
  end
28
-
29
28
  end
@@ -21,6 +21,11 @@ class Rack::Timeout::Scheduler
21
21
 
22
22
  # stores a proc to run later, and the time it should run at
23
23
  class RunEvent < Struct.new(:monotime, :proc)
24
+ def initialize(*args)
25
+ @cancelled = false
26
+ super(*args)
27
+ end
28
+
24
29
  def cancel!
25
30
  @cancelled = true
26
31
  end
@@ -51,6 +56,7 @@ class Rack::Timeout::Scheduler
51
56
  end
52
57
 
53
58
  def initialize
59
+ @runner = nil
54
60
  @events = [] # array of `RunEvent`s
55
61
  @mx_events = Mutex.new # mutex to change said array
56
62
  @mx_runner = Mutex.new # mutex for creating a runner thread
@@ -145,5 +151,4 @@ class Rack::Timeout::Scheduler
145
151
  instance_methods(false).each do |m|
146
152
  define_singleton_method(m) { |*a, &b| singleton.send(m, *a, &b) }
147
153
  end
148
-
149
154
  end
@@ -25,5 +25,4 @@ class Rack::Timeout::Scheduler::Timeout
25
25
  def self.timeout(secs, &block)
26
26
  (@singleton ||= new).timeout(secs, &block)
27
27
  end
28
-
29
28
  end
data/lib/rack-timeout.rb CHANGED
@@ -1,2 +1,2 @@
1
1
  require_relative "rack/timeout/base"
2
- require_relative "rack/timeout/rails" if defined?(Rails) && [3,4].include?(Rails::VERSION::MAJOR)
2
+ require_relative "rack/timeout/rails" if defined?(Rails) && [3,4,5,6].include?(Rails::VERSION::MAJOR)
@@ -0,0 +1,23 @@
1
+ require "test_helper"
2
+
3
+ class BasicTest < RackTimeoutTest
4
+ def test_ok
5
+ self.settings = { service_timeout: 1 }
6
+ get "/"
7
+ assert last_response.ok?
8
+ end
9
+
10
+ def test_timeout
11
+ self.settings = { service_timeout: 1 }
12
+ assert_raises(Rack::Timeout::RequestTimeoutError) do
13
+ get "/sleep"
14
+ end
15
+ end
16
+
17
+ def test_wait_timeout
18
+ self.settings = { service_timeout: 1, wait_timeout: 15 }
19
+ assert_raises(Rack::Timeout::RequestExpiryError) do
20
+ get "/", "", 'HTTP_X_REQUEST_START' => time_in_msec(Time.now - 100)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ require 'test_helper'
2
+
3
+ class EnvSettingsTest < RackTimeoutTest
4
+
5
+ def test_service_timeout
6
+ with_env(RACK_TIMEOUT_SERVICE_TIMEOUT: 1) do
7
+ assert_raises(Rack::Timeout::RequestTimeoutError) do
8
+ get "/sleep"
9
+ end
10
+ end
11
+ end
12
+
13
+ def test_zero_wait_timeout
14
+ with_env(RACK_TIMEOUT_WAIT_TIMEOUT: 0) do
15
+ get "/", "", 'HTTP_X_REQUEST_START' => time_in_msec(Time.now - 100)
16
+ assert last_response.ok?
17
+ end
18
+ end
19
+
20
+ def test_term
21
+ with_env(RACK_TIMEOUT_TERM_ON_TIMEOUT: 1) do
22
+ assert_raises(SignalException) do
23
+ get "/sleep"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,45 @@
1
+ require "test/unit"
2
+ require "rack/test"
3
+ require "rack-timeout"
4
+
5
+ class RackTimeoutTest < Test::Unit::TestCase
6
+ include Rack::Test::Methods
7
+
8
+ attr_accessor :settings
9
+
10
+ def initialize(*args)
11
+ self.settings ||= {}
12
+ super(*args)
13
+ end
14
+
15
+ def app
16
+ settings = self.settings
17
+ Rack::Builder.new do
18
+ use Rack::Timeout, settings
19
+
20
+ map "/" do
21
+ run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['OK']] }
22
+ end
23
+
24
+ map "/sleep" do
25
+ run lambda { |env| sleep }
26
+ end
27
+ end
28
+ end
29
+
30
+ # runs the test with the given environment, but doesnt restore the original
31
+ # environment afterwards. This should be sufficient for rack-timeout testing.
32
+ def with_env(hash)
33
+ hash.each_pair do |k, v|
34
+ ENV[k.to_s] = v.to_s
35
+ end
36
+ yield
37
+ hash.each_key do |k|
38
+ ENV[k.to_s] = nil
39
+ end
40
+ end
41
+
42
+ def time_in_msec(t = Time.now)
43
+ "#{t.tv_sec}#{t.tv_usec/1000}"
44
+ end
45
+ end
metadata CHANGED
@@ -1,15 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-timeout
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Caio Chassot
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-04-05 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2019-12-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack-test
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: test-unit
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
13
55
  description: Rack middleware which aborts requests that have been running for longer
14
56
  than a specified timeout.
15
57
  email: caio@heroku.com
@@ -17,13 +59,22 @@ executables: []
17
59
  extensions: []
18
60
  extra_rdoc_files: []
19
61
  files:
20
- - CHANGELOG
62
+ - CHANGELOG.md
63
+ - Gemfile
21
64
  - MIT-LICENSE
22
- - README.markdown
65
+ - README.md
66
+ - Rakefile
67
+ - UPGRADING.md
68
+ - doc/exceptions.md
69
+ - doc/logging.md
70
+ - doc/observers.md
71
+ - doc/request-lifecycle.md
72
+ - doc/risks.md
73
+ - doc/rollbar.md
74
+ - doc/settings.md
23
75
  - lib/rack-timeout.rb
24
76
  - lib/rack/timeout/base.rb
25
77
  - lib/rack/timeout/core.rb
26
- - lib/rack/timeout/legacy.rb
27
78
  - lib/rack/timeout/logger.rb
28
79
  - lib/rack/timeout/logging-observer.rb
29
80
  - lib/rack/timeout/rails.rb
@@ -32,10 +83,17 @@ files:
32
83
  - lib/rack/timeout/support/namespace.rb
33
84
  - lib/rack/timeout/support/scheduler.rb
34
85
  - lib/rack/timeout/support/timeout.rb
35
- homepage: http://github.com/heroku/rack-timeout
86
+ - test/basic_test.rb
87
+ - test/env_settings_test.rb
88
+ - test/test_helper.rb
89
+ homepage: https://github.com/sharpstone/rack-timeout
36
90
  licenses:
37
91
  - MIT
38
- metadata: {}
92
+ metadata:
93
+ bug_tracker_uri: https://github.com/sharpstone/rack-timeout/issues
94
+ changelog_uri: https://github.com/sharpstone/rack-timeout/blob/v0.6.0/CHANGELOG.md
95
+ documentation_uri: https://rubydoc.info/gems/rack-timeout/0.6.0/
96
+ source_code_uri: https://github.com/sharpstone/rack-timeout
39
97
  post_install_message:
40
98
  rdoc_options: []
41
99
  require_paths:
@@ -51,10 +109,13 @@ required_rubygems_version: !ruby/object:Gem::Requirement
51
109
  - !ruby/object:Gem::Version
52
110
  version: '0'
53
111
  requirements: []
54
- rubyforge_project:
55
- rubygems_version: 2.5.1
112
+ rubygems_version: 3.0.6
56
113
  signing_key:
57
114
  specification_version: 4
58
115
  summary: Abort requests that are taking too long
59
- test_files: []
60
- has_rdoc:
116
+ test_files:
117
+ - test/basic_test.rb
118
+ - test/env_settings_test.rb
119
+ - test/test_helper.rb
120
+ - Gemfile
121
+ - Rakefile