Skip to content

Commit 5fafe5e

Browse files
committed
Merge remote-tracking branch 'origin/master'
2 parents 9531271 + d605ecc commit 5fafe5e

17 files changed

Lines changed: 175 additions & 32 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
name: Check Changelog
2+
3+
on: [pull_request]
4+
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v1
10+
- name: Check that CHANGELOG is touched
11+
run: |
12+
cat $GITHUB_EVENT_PATH | jq .pull_request.title | grep -i '\[\(\(changelog skip\)\|\(ci skip\)\)\]' || git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
- push
5+
- pull_request
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
strategy:
11+
fail-fast: false
12+
matrix:
13+
ruby:
14+
- '2.2'
15+
- '2.3'
16+
- '2.4'
17+
- '2.5'
18+
- '2.6'
19+
- '2.7'
20+
- '3.0'
21+
- '3.1'
22+
- 'head'
23+
steps:
24+
- name: Checkout code
25+
uses: actions/checkout@v3
26+
- name: Set up Ruby
27+
uses: ruby/setup-ruby@v1
28+
with:
29+
ruby-version: ${{ matrix.ruby }}
30+
bundler-cache: true
31+
- name: Run test
32+
run: bundle exec rake test

.travis.yml

Lines changed: 0 additions & 11 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
## HEAD (unreleased)
2+
3+
## 0.6.3
4+
5+
- Fix `NoMethodError: undefined method 'logger' for Rails:Module` when Rails is defined as a Module, but is not a full Rails app (https://github.com/zombocom/rack-timeout/pull/180)
6+
7+
## 0.6.2
8+
9+
- Migrate CI from Travis CI to GitHub Actions (https://github.com/zombocom/rack-timeout/pull/182)
10+
- Rails 7+ support (https://github.com/zombocom/rack-timeout/pull/184)
11+
12+
## 0.6.1
13+
14+
- RACK_TIMEOUT_TERM_ON_TIMEOUT can be set to zero to disable (https://github.com/sharpstone/rack-timeout/pull/161)
15+
- Update the gemspec's homepage to the current repo URL(https://github.com/zombocom/rack-timeout/pull/183)
16+
17+
## 0.6.0
18+
19+
- Allow sending SIGTERM to workers on timeout (https://github.com/sharpstone/rack-timeout/pull/157)
20+
21+
0.5.2
22+
=====
23+
- Rails 6 support (#147)
24+
125
0.5.1
226
=====
327
- Fixes setting ENV vars to false or 0 would not disable a timeout

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Pass an an array of paths strings you want to exclude from or only run timeout f
1515

1616
```ruby
1717
# Gemfile
18-
gem "rack-timeout", require:"rack/timeout/base", :git => 'git://github.com/mkrl/rack-timeout.git'
18+
gem "rack-timeout", require:"rack/timeout/base", :git => 'git://github.com/cqsdev/rack-timeout.git'
1919
```
2020

2121
```ruby
@@ -40,6 +40,7 @@ service_timeout: 15 # RACK_TIMEOUT_SERVICE_TIMEOUT
4040
wait_timeout: 30 # RACK_TIMEOUT_WAIT_TIMEOUT
4141
wait_overtime: 60 # RACK_TIMEOUT_WAIT_OVERTIME
4242
service_past_wait: false # RACK_TIMEOUT_SERVICE_PAST_WAIT
43+
term_on_timeout: false # RACK_TIMEOUT_TERM_ON_TIMEOUT
4344
exclude: [] # RACK_TIMEOUT_EXCLUDE
4445
only: [] # RACK_TIMEOUT_ONLY
4546
```
@@ -58,5 +59,5 @@ use Rack::Timeout::Select, service_timeout: 5, exclude: ["api"]
5859
Please note that you may have controller actions with names similar to your excludes/targets, use with wise.
5960

6061
---
61-
Copyright © 2010-2016 Caio Chassot, released under the MIT license
62-
<http://github.com/heroku/rack-timeout>
62+
Copyright © 2010-2020 Caio Chassot, released under the MIT license
63+
<http://github.com/sharpstone/rack-timeout>

doc/risks.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ That said, it's something to be aware of, and may explain some eerie wonkiness s
2626
[broken-timeout]: http://headius.blogspot.de/2008/02/rubys-threadraise-threadkill-timeoutrb.html
2727
[handle-interrupt]: http://www.ruby-doc.org/core-2.1.3/Thread.html#method-c-handle_interrupt
2828

29-
3029
### Time Out Early and Often
3130

3231
Because of the aforementioned issues, it's recommended you set library-specific timeouts and leave Rack::Timeout as a last resort measure. Library timeouts will generally take care of IO issues and abort the operation safely. See [The Ultimate Guide to Ruby Timeouts][ruby-timeouts].

doc/settings.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,55 @@ This extra time is called *wait overtime* and can be set via `wait_overtime`. It
4747
Keep in mind that Heroku [recommends][uploads] uploading large files directly to S3, so as to prevent the dyno from being blocked for too long and hence unable to handle further incoming requests.
4848

4949
[uploads]: https://devcenter.heroku.com/articles/s3#file-uploads
50+
51+
### Term on Timeout
52+
53+
If your application timeouts fire frequently then [they can cause your application to enter a corrupt state](https://www.schneems.com/2017/02/21/the-oldest-bug-in-ruby-why-racktimeout-might-hose-your-server/). One option for resetting that bad state is to restart the entire process. If you are running in an environment with multiple processes (such as `puma -w 2`) then when a process is sent a `SIGTERM` it will exit. The webserver then knows how to restart the process. For more information on process restart behavior see:
54+
55+
- [Ruby Application Restart Behavior](https://devcenter.heroku.com/articles/what-happens-to-ruby-apps-when-they-are-restarted)
56+
- [License to SIGKILL](https://www.sitepoint.com/license-to-sigkill/)
57+
58+
**Puma SIGTERM behavior** When a Puma worker receives a `SIGTERM` it will begin to shut down, but not exit right away. It stops accepting new requests and waits for any existing requests to finish before fully shutting down. This means that only the request that experiences a timeout will be interupted, all other in-flight requests will be allowed to run until they return or also are timed out.
59+
60+
After the worker process exists will Puma's parent process know to boot a replacement worker. While one process is restarting, another can still serve requests (if you have more than 1 worker process per server/dyno). Between when a process exits and when a new process boots, there will be a reduction in throughput. If all processes are restarting, then incoming requests will be blocked while new processes boot.
61+
62+
**How to enable** To enable this behavior you can set `term_on_timeout: 1` to an integer value. If you set it to one, then the first time the process encounters a timeout, it will receive a SIGTERM.
63+
64+
To enable on Heroku run:
65+
66+
```
67+
$ heroku config:set RACK_TIMEOUT_TERM_ON_TIMEOUT=1
68+
```
69+
70+
**Caution** If you use this setting inside of a webserver without enabling multi-process mode, then it will exit the entire server when it fires:
71+
72+
-`puma -w 2 -t 5` This is OKAY
73+
-`puma -t 5` This is NOT OKAY
74+
75+
If you're using a `config/puma.rb` file then make sure you are calling `workers` configuration DSL. You should see multiple workers when the server boots:
76+
77+
```
78+
[3922] Puma starting in cluster mode...
79+
[3922] * Version 4.3.0 (ruby 2.6.5-p114), codename: Mysterious Traveller
80+
[3922] * Min threads: 0, max threads: 16
81+
[3922] * Environment: development
82+
[3922] * Process workers: 2
83+
[3922] * Phased restart available
84+
[3922] * Listening on tcp://0.0.0.0:9292
85+
[3922] Use Ctrl-C to stop
86+
[3922] - Worker 0 (pid: 3924) booted, phase: 0
87+
[3922] - Worker 1 (pid: 3925) booted, phase: 0
88+
```
89+
90+
> ✅ Notice how it says it is booting in "cluster mode" and how it gives PIDs for two worker processes at the bottom.
91+
92+
**How to decide the term_on_timeout value** If you set to a higher value such as `5` then rack-timeout will wait until the process has experienced five timeouts before restarting the process. Setting this value to a higher number means the application restarts processes less frequently, so throughput will be less impacted. If you set it to too high of a number, then the underlying issue of the application being put into a bad state will not be effectively mitigated.
93+
94+
95+
**How do I know when a process is being restarted by rack-timeout?** This exception error should be visible in the logs:
96+
97+
```
98+
Request ran for longer than 1000ms, sending SIGTERM to process 3925
99+
```
100+
101+
> Note: Since the worker waits for all in-flight requests to finish (with puma) you may see multiple SIGTERMs to the same PID before it exits, this means that multiple requests timed out.

lib/rack-timeout.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
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

lib/rack/timeout/core.rb

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class RequestTimeoutException < Exception # This is first raised to help prevent
3030
:service, # time rack spent processing the request (updated ~ every second)
3131
:timeout, # the actual computed timeout to be used for this request
3232
:state, # the request's current state, see VALID_STATES below
33+
:term,
3334
) {
3435
def ms(k) # helper method used for formatting values in milliseconds
3536
"%.fms" % (self[k] * 1000) if self[k]
@@ -86,16 +87,23 @@ def read_timeout_property value, default
8687
: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.
8788
: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.
8889
: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.
90+
:term_on_timeout
8991
:exclude # exclude routes with those paths in them from being processed
9092
:only # only process requests coming from those paths
9193

92-
def initialize(app, service_timeout:nil, wait_timeout:nil, wait_overtime:nil, service_past_wait:"not_specified", exclude:[], only:[])
94+
def initialize(app, service_timeout:nil, wait_timeout:nil, wait_overtime:nil, service_past_wait:"not_specified", term_on_timeout: nil, exclude:[], only:[])
95+
@term_on_timeout = read_timeout_property term_on_timeout, ENV.fetch("RACK_TIMEOUT_TERM_ON_TIMEOUT", 0).to_i
9396
@service_timeout = read_timeout_property service_timeout, ENV.fetch("RACK_TIMEOUT_SERVICE_TIMEOUT", 15).to_i
9497
@wait_timeout = read_timeout_property wait_timeout, ENV.fetch("RACK_TIMEOUT_WAIT_TIMEOUT", 30).to_i
9598
@wait_overtime = read_timeout_property wait_overtime, ENV.fetch("RACK_TIMEOUT_WAIT_OVERTIME", 60).to_i
9699
@service_past_wait = service_past_wait == "not_specified" ? ENV.fetch("RACK_TIMEOUT_SERVICE_PAST_WAIT", false).to_s != "false" : service_past_wait
97100
@exclude = exclude == [] ? ENV.fetch("RACK_TIMEOUT_EXCLUDE", []) : exclude
98101
@only = only == [] ? ENV.fetch("RACK_TIMEOUT_ONLY", []) : only
102+
103+
Thread.main['RACK_TIMEOUT_COUNT'] ||= 0
104+
if @term_on_timeout
105+
raise "Current Runtime does not support processes" unless ::Process.respond_to?(:fork)
106+
end
99107
@app = app
100108
end
101109

@@ -117,7 +125,9 @@ def call(env)
117125
seconds_waited = 0 if seconds_waited < 0 # make up for potential time drift between the routing server and the application server
118126
final_wait_timeout = wait_timeout + effective_overtime # how long the request will be allowed to have waited
119127
seconds_service_left = final_wait_timeout - seconds_waited # first calculation of service timeout (relevant if request doesn't get expired, may be overriden later)
120-
info.wait, info.timeout = seconds_waited, final_wait_timeout # updating the info properties; info.timeout will be the wait timeout at this point
128+
info.wait = seconds_waited # updating the info properties; info.timeout will be the wait timeout at this point
129+
info.timeout = final_wait_timeout
130+
121131
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)
122132
RT._set_state! env, :expired
123133
raise RequestExpiryError.new(env), "Request older than #{info.ms(:timeout)}."
@@ -130,7 +140,7 @@ def call(env)
130140
# 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.
131141
info.timeout = service_timeout # nice and simple, when service_past_wait is true, not so much otherwise:
132142
info.timeout = seconds_service_left if !service_past_wait && seconds_service_left && seconds_service_left > 0 && seconds_service_left < service_timeout
133-
143+
info.term = term_on_timeout
134144
RT._set_state! env, :ready # we're good to go, but have done nothing yet
135145

136146
heartbeat_event = nil # init var so it's in scope for following proc
@@ -143,7 +153,22 @@ def call(env)
143153

144154
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
145155
register_state_change.call :timed_out
146-
app_thread.raise(RequestTimeoutException.new(env), "Request #{"waited #{info.ms(:wait)}, then " if info.wait}ran for longer than #{info.ms(:timeout)}")
156+
157+
message = "Request "
158+
message << "waited #{info.ms(:wait)}, then " if info.wait
159+
message << "ran for longer than #{info.ms(:timeout)} "
160+
if term_on_timeout
161+
Thread.main['RACK_TIMEOUT_COUNT'] += 1
162+
163+
if Thread.main['RACK_TIMEOUT_COUNT'] >= @term_on_timeout
164+
message << ", sending SIGTERM to process #{Process.pid}"
165+
Process.kill("SIGTERM", Process.pid)
166+
else
167+
message << ", #{Thread.main['RACK_TIMEOUT_COUNT']}/#{term_on_timeout} timeouts allowed before SIGTERM for process #{Process.pid}"
168+
end
169+
end
170+
171+
app_thread.raise(RequestTimeoutException.new(env), message)
147172
end
148173

149174
response = timeout.timeout(info.timeout) do # perform request with timeout
@@ -218,6 +243,5 @@ def self.unregister_state_change_observer(id)
218243
def self.notify_state_change_observers(env)
219244
@state_change_observers.values.each { |observer| observer.call(env) }
220245
end
221-
222246
end
223247
end

lib/rack/timeout/logger.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,4 @@ def update(new_device, new_level)
3535
@level = new_level || ::Logger::INFO
3636
self.logger = ::Rack::Timeout::StateChangeLoggingObserver.mk_logger(device, level)
3737
end
38-
3938
end

0 commit comments

Comments
 (0)