rack-timeout 0.4.2 → 0.6.3

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,12 +43,14 @@ 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
54
56
  else
@@ -61,13 +63,20 @@ module Rack
61
63
  :service_timeout, # How long the application can take to complete handling the request once it's passed down to it.
62
64
  :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
65
  :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
66
+ :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.
67
+ :term_on_timeout
68
+
69
+ def initialize(app, service_timeout:nil, wait_timeout:nil, wait_overtime:nil, service_past_wait:"not_specified", term_on_timeout: nil)
70
+ @term_on_timeout = read_timeout_property term_on_timeout, ENV.fetch("RACK_TIMEOUT_TERM_ON_TIMEOUT", 0).to_i
71
+ @service_timeout = read_timeout_property service_timeout, ENV.fetch("RACK_TIMEOUT_SERVICE_TIMEOUT", 15).to_i
72
+ @wait_timeout = read_timeout_property wait_timeout, ENV.fetch("RACK_TIMEOUT_WAIT_TIMEOUT", 30).to_i
73
+ @wait_overtime = read_timeout_property wait_overtime, ENV.fetch("RACK_TIMEOUT_WAIT_OVERTIME", 60).to_i
74
+ @service_past_wait = service_past_wait == "not_specified" ? ENV.fetch("RACK_TIMEOUT_SERVICE_PAST_WAIT", false).to_s != "false" : service_past_wait
75
+
76
+ Thread.main['RACK_TIMEOUT_COUNT'] ||= 0
77
+ if @term_on_timeout
78
+ raise "Current Runtime does not support processes" unless ::Process.respond_to?(:fork)
79
+ end
71
80
  @app = app
72
81
  end
73
82
 
@@ -75,7 +84,7 @@ module Rack
75
84
  RT = self # shorthand reference
76
85
  def call(env)
77
86
  info = (env[ENV_INFO_KEY] ||= RequestDetails.new)
78
- info.id ||= env["HTTP_X_REQUEST_ID"] || SecureRandom.hex
87
+ info.id ||= env[HTTP_X_REQUEST_ID] || env[ACTION_DISPATCH_REQUEST_ID] || SecureRandom.uuid
79
88
 
80
89
  time_started_service = Time.now # The wall time the request started being processed by rack
81
90
  ts_started_service = fsecs # The monotonic time the request started being processed by rack
@@ -89,7 +98,9 @@ module Rack
89
98
  seconds_waited = 0 if seconds_waited < 0 # make up for potential time drift between the routing server and the application server
90
99
  final_wait_timeout = wait_timeout + effective_overtime # how long the request will be allowed to have waited
91
100
  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
101
+ info.wait = seconds_waited # updating the info properties; info.timeout will be the wait timeout at this point
102
+ info.timeout = final_wait_timeout
103
+
93
104
  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
105
  RT._set_state! env, :expired
95
106
  raise RequestExpiryError.new(env), "Request older than #{info.ms(:timeout)}."
@@ -102,7 +113,7 @@ module Rack
102
113
  # 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
114
  info.timeout = service_timeout # nice and simple, when service_past_wait is true, not so much otherwise:
104
115
  info.timeout = seconds_service_left if !service_past_wait && seconds_service_left && seconds_service_left > 0 && seconds_service_left < service_timeout
105
-
116
+ info.term = term_on_timeout
106
117
  RT._set_state! env, :ready # we're good to go, but have done nothing yet
107
118
 
108
119
  heartbeat_event = nil # init var so it's in scope for following proc
@@ -115,7 +126,22 @@ module Rack
115
126
 
116
127
  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
128
  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)}")
129
+
130
+ message = "Request "
131
+ message << "waited #{info.ms(:wait)}, then " if info.wait
132
+ message << "ran for longer than #{info.ms(:timeout)} "
133
+ if term_on_timeout
134
+ Thread.main['RACK_TIMEOUT_COUNT'] += 1
135
+
136
+ if Thread.main['RACK_TIMEOUT_COUNT'] >= @term_on_timeout
137
+ message << ", sending SIGTERM to process #{Process.pid}"
138
+ Process.kill("SIGTERM", Process.pid)
139
+ else
140
+ message << ", #{Thread.main['RACK_TIMEOUT_COUNT']}/#{term_on_timeout} timeouts allowed before SIGTERM for process #{Process.pid}"
141
+ end
142
+ end
143
+
144
+ app_thread.raise(RequestTimeoutException.new(env), message)
119
145
  end
120
146
 
121
147
  response = timeout.timeout(info.timeout) do # perform request with timeout
@@ -146,8 +172,9 @@ module Rack
146
172
  # This is a code extraction for readability, this method is only called from a single point.
147
173
  RX_NGINX_X_REQUEST_START = /^(?:t=)?(\d+)\.(\d{3})$/
148
174
  RX_HEROKU_X_REQUEST_START = /^(\d+)$/
175
+ HTTP_X_REQUEST_START = "HTTP_X_REQUEST_START".freeze
149
176
  def self._read_x_request_start(env)
150
- return unless s = env["HTTP_X_REQUEST_START"]
177
+ return unless s = env[HTTP_X_REQUEST_START]
151
178
  return unless m = s.match(RX_HEROKU_X_REQUEST_START) || s.match(RX_NGINX_X_REQUEST_START)
152
179
  Time.at(m[1,2].join.to_f / 1000)
153
180
  end
@@ -189,6 +216,5 @@ module Rack
189
216
  def self.notify_state_change_observers(env)
190
217
  @state_change_observers.values.each { |observer| observer.call(env) }
191
218
  end
192
-
193
219
  end
194
220
  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
@@ -29,7 +32,7 @@ class Rack::Timeout::StateChangeLoggingObserver
29
32
 
30
33
  def logger(env = nil)
31
34
  @logger ||
32
- (defined?(::Rails) && ::Rails.logger) ||
35
+ (defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger) ||
33
36
  (env && !env["rack.logger"].is_a?(::Rack::NullLogger) && env["rack.logger"]) ||
34
37
  (env && env["rack.errors"] && self.class.mk_logger(env["rack.errors"])) ||
35
38
  (@fallback_logger ||= self.class.mk_logger($stderr))
@@ -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.1 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,5].include?(Rails::VERSION::MAJOR)
2
+ require_relative "rack/timeout/rails" if defined?(Rails) && Rails::VERSION::MAJOR >= 3
@@ -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.2
4
+ version: 0.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Caio Chassot
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-04-05 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2022-06-09 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,11 +83,18 @@ 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/zombocom/rack-timeout
36
90
  licenses:
37
91
  - MIT
38
- metadata: {}
39
- post_install_message:
92
+ metadata:
93
+ bug_tracker_uri: https://github.com/zombocom/rack-timeout/issues
94
+ changelog_uri: https://github.com/zombocom/rack-timeout/blob/v0.6.3/CHANGELOG.md
95
+ documentation_uri: https://rubydoc.info/gems/rack-timeout/0.6.3/
96
+ source_code_uri: https://github.com/zombocom/rack-timeout
97
+ post_install_message:
40
98
  rdoc_options: []
41
99
  require_paths:
42
100
  - lib
@@ -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
56
- signing_key:
112
+ rubygems_version: 3.3.7
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