rack-timeout 0.4.0 → 0.6.0

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.
@@ -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