Skip to content

Commit 90f7b54

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 5fafe5e + d37a765 commit 90f7b54

15 files changed

Lines changed: 154 additions & 44 deletions
Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
name: Check Changelog
22

3-
on: [pull_request]
3+
on:
4+
pull_request:
5+
types: [opened, reopened, edited, labeled, unlabeled, synchronize]
46

57
jobs:
6-
build:
8+
check-changelog:
79
runs-on: ubuntu-latest
10+
if: |
11+
!contains(github.event.pull_request.body, '[skip changelog]') &&
12+
!contains(github.event.pull_request.body, '[changelog skip]') &&
13+
!contains(github.event.pull_request.body, '[skip ci]') &&
14+
!contains(github.event.pull_request.labels.*.name, 'skip changelog') &&
15+
!contains(github.event.pull_request.labels.*.name, 'dependencies') &&
16+
!contains(github.event.pull_request.labels.*.name, 'automation')
817
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
18+
- uses: actions/checkout@v3
19+
- name: Check that CHANGELOG is touched
20+
run: |
21+
git fetch origin ${{ github.base_ref }} --depth 1 && \
22+
git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md

.github/workflows/ci.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,27 @@ on:
66

77
jobs:
88
test:
9-
runs-on: ubuntu-latest
109
strategy:
1110
fail-fast: false
1211
matrix:
12+
os: [ ubuntu-latest, windows-latest ]
13+
rack-version: ['~> 2.0', '~> 3.0']
1314
ruby:
14-
- '2.2'
1515
- '2.3'
1616
- '2.4'
1717
- '2.5'
1818
- '2.6'
1919
- '2.7'
2020
- '3.0'
2121
- '3.1'
22+
- '3.2'
2223
- 'head'
24+
exclude:
25+
- ruby: '2.3'
26+
rack-version: '~> 3.0'
27+
runs-on: ${{ matrix.os }}
28+
env:
29+
RACK_VERSION: ${{ matrix.rack-version }}
2330
steps:
2431
- name: Checkout code
2532
uses: actions/checkout@v3

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
## HEAD (unreleased)
1+
## 0.7.0
2+
3+
- Honor an `X-Request-Start` header with the `t=<microseconds>` format, to allow using `wait_timeout` functionality with Apache (https://github.com/zombocom/rack-timeout/pull/210)
4+
- Improve message when Terminate on Timeout is used on a platform that does not support it (eg. Windows or JVM) (https://github.com/zombocom/rack-timeout/pull/192)
5+
- Fix a thread safety issue for forks that are not on the main thread (https://github.com/zombocom/rack-timeout/pull/212)
6+
- Add compatibility with frozen_string_literal: true (https://github.com/zombocom/rack-timeout/pull/196)
7+
- Fix if Rails is defined but Rails::VERSION is not defined (https://github.com/zombocom/rack-timeout/pull/191)
28

39
## 0.6.3
410

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
source 'https://rubygems.org'
22

33
gemspec
4+
5+
gem 'rack', ENV['RACK_VERSION'] if ENV['RACK_VERSION']

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ only: [] # RACK_TIMEOUT_ONLY
4747

4848
Both `exclude` and `only` can be used at the same time, in this case excluded paths will be substracted from the `only` array.
4949

50-
These settings can be overriden during middleware initialization or
50+
These settings can be overridden during middleware initialization or
5151
environment variables `RACK_TIMEOUT_*` mentioned above. Middleware
5252
parameters take precedence:
5353

@@ -56,8 +56,41 @@ use Rack::Timeout::Select, service_timeout: 5, exclude: ["api"]
5656
```
5757
[Demo application](https://github.com/mkrl/rack-timeout-test)
5858

59-
Please note that you may have controller actions with names similar to your excludes/targets, use with wise.
59+
For more on these settings, please see [doc/settings](doc/settings.md).
60+
61+
Further Documentation
62+
---------------------
63+
64+
Please see the [doc](doc) folder for further documentation on:
65+
66+
* [Risks and shortcomings of using Rack::Timeout](doc/risks.md)
67+
* [Understanding the request lifecycle](doc/request-lifecycle.md)
68+
* [Exceptions raised by Rack::Timeout](doc/exceptions.md)
69+
* [Rollbar fingerprinting](doc/rollbar.md)
70+
* [Observers](doc/observers.md)
71+
* [Settings](doc/settings.md)
72+
* [Logging](doc/logging.md)
73+
74+
Additionally there is a [demo app](https://github.com/zombocom/rack_timeout_demos)
75+
that shows the impact of changing settings and how the library behaves
76+
when a timeout is hit.
77+
78+
Contributing
79+
------------
80+
81+
Run the test suite:
82+
83+
```console
84+
bundle
85+
bundle exec rake test
86+
```
87+
88+
Compatibility
89+
-------------
90+
91+
This version of Rack::Timeout is compatible with Ruby 2.3 and up, and,
92+
for Rails apps, Rails 3.x and up.
6093

6194
---
6295
Copyright © 2010-2020 Caio Chassot, released under the MIT license
63-
<http://github.com/sharpstone/rack-timeout>
96+
<http://github.com/zombocom/rack-timeout>

doc/risks.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Risks and shortcomings of using Rack::Timeout
55

66
Sometimes a request is taking too long to complete because it's blocked waiting on synchronous IO. Such IO does not need to be file operations, it could be, say, network or database operations. If said IO is happening in a C library that's unaware of ruby's interrupt system (i.e. anything written without ruby in mind), calling `Thread#raise` (that's what rack-timeout uses) will not have effect until after the IO block is gone.
77

8-
At the moment rack-timeout does not try to address this issue. As a fail-safe against these cases, a blunter solution that kills the entire process is recommended, such as unicorn's timeouts.
8+
As a fail-safe against these cases, a blunter solution that kills the entire process is recommended, such as unicorn's timeouts. You can enable this process killing behavior by enabling `term_on_timeout` for more info see [setting][term-on-timeout].
99

1010
More detailed explanations of the issues surrounding timing out in ruby during IO blocks can be found at:
1111

@@ -15,14 +15,16 @@ More detailed explanations of the issues surrounding timing out in ruby during I
1515

1616
Raising mid-flight in stateful applications is inherently unsafe. A request can be aborted at any moment in the code flow, and the application can be left in an inconsistent state. There's little way rack-timeout could be aware of ongoing state changes. Applications that rely on a set of globals (like class variables) or any other state that lives beyond a single request may find those left in an unexpected/inconsistent state after an aborted request. Some cleanup code might not have run, or only half of a set of related changes may have been applied.
1717

18-
A lot more can go wrong. An intricate explanation of the issue by JRuby's Charles Nutter can be found [here][broken-timeout].
18+
A lot more can go wrong. An intricate explanation of the issue by JRuby's Charles Nutter can be found [
19+
Ruby's Thread#raise, Thread#kill, timeout.rb, and net/protocol.rb libraries are broken][broken-timeout]. In addition Richard Schneeman talked about this issue in [The Oldest Bug In Ruby - Why Rack::Timeout Might Hose your Server][oldest-bug]. One solution from having `rack-timeout` corrupt process state is to restart the entire process on timeout. You can enable this behavior by setting [term_on_timeout][term-on-timeout].
1920

20-
Ruby 2.1 provides a way to defer the result of raising exceptions through the [Thread.handle_interrupt][handle-interrupt] method. This could be used in critical areas of your application code to prevent Rack::Timeout from accidentally wreaking havoc by raising just in the wrong moment. That said, `handle_interrupt` and threads in general are hard to reason about, and detecting all cases where it would be needed in an application is a tall order, and the added code complexity is probably not worth the trouble.
21+
Ruby 2.1+ provides a way to defer the result of raising exceptions through the [Thread.handle_interrupt][handle-interrupt] method. This low level interface is meant more for library authors than higher level application developers. This interface could be used in critical areas of your application code to prevent Rack::Timeout from accidentally wreaking havoc by raising just in the wrong moment. That said, `handle_interrupt` and threads in general are hard to reason about, and detecting all cases where it would be needed in an application is a tall order, and the added code complexity is probably not worth the trouble.
2122

2223
Your time is better spent ensuring requests run fast and don't need to timeout.
2324

2425
That said, it's something to be aware of, and may explain some eerie wonkiness seen in logs.
2526

27+
[oldest-bug]: https://www.schneems.com/2017/02/21/the-oldest-bug-in-ruby-why-racktimeout-might-hose-your-server/
2628
[broken-timeout]: http://headius.blogspot.de/2008/02/rubys-threadraise-threadkill-timeoutrb.html
2729
[handle-interrupt]: http://www.ruby-doc.org/core-2.1.3/Thread.html#method-c-handle_interrupt
2830

@@ -33,3 +35,5 @@ Because of the aforementioned issues, it's recommended you set library-specific
3335
You'll want to set all relevant timeouts to something lower than Rack::Timeout's `service_timeout`. Generally you want them to be at least 1s lower, so as to account for time spent elsewhere during the request's lifetime while still giving libraries a chance to time out before Rack::Timeout.
3436

3537
[ruby-timeouts]: https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts
38+
[term-on-timeout]: https://github.com/zombocom/rack-timeout/blob/main/doc/settings.md#term-on-timeout
39+

doc/settings.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
Rack::Timeout has 4 settings, each of which impacts when Rack::Timeout
44
will raise an exception, and which type of exception will be raised.
55

6+
7+
Additionally there is a [demo app](https://github.com/zombocom/rack_timeout_demos) that shows the impact of changing settings and how the library behaves when a timeout is hit.
8+
69
### Service Timeout
710

811
`service_timeout` is the most important setting.
@@ -26,9 +29,18 @@ Wait timeout can be disabled entirely by setting the property to `0` or `false`.
2629

2730
A request's computed wait time may affect the service timeout used for it. Basically, a request's wait time plus service time may not exceed the wait timeout. The reasoning for that is based on Heroku router's behavior, that the request would be dropped anyway after the wait timeout. So, for example, with the default settings of `service_timeout=15`, `wait_timeout=30`, a request that had 20 seconds of wait time will not have a service timeout of 15, but instead of 10, as there are only 10 seconds left before `wait_timeout` is reached. This behavior can be disabled by setting `service_past_wait` to `true`. When set, the `service_timeout` setting will always be honored. Please note that if you're using the `RACK_TIMEOUT_SERVICE_PAST_WAIT` environment variable, any value different than `"false"` will be considered `true`.
2831

29-
The way we're able to infer a request's start time, and from that its wait time, is through the availability of the `X-Request-Start` HTTP header, which is expected to contain the time since epoch in milliseconds. (A concession is made for nginx's sec.msec notation.)
32+
The way we're able to infer a request's start time, and from that its wait time, is through the availability of the `X-Request-Start` HTTP header, which is expected to contain the time since UNIX epoch in milliseconds or microseconds.
33+
34+
Compatible header string formats are:
35+
36+
- `seconds.milliseconds`, e.g. `1700173924.763` - 10.3 digits (nginx format)
37+
- `t=seconds.milliseconds`, e.g. `t=1700173924.763` - 10.3 digits, nginx format with [New Relic recommended][new-relic-recommended-format] `t=` prefix
38+
- `milliseconds`, e.g. `1700173924763` - 13 digits (Heroku format)
39+
- `t=microseconds`, e.g. `t=1700173924763384` - 16 digits with `t=` prefix (Apache format)
40+
41+
[new-relic-recommended-format]: https://docs.newrelic.com/docs/apm/applications-menu/features/request-queue-server-configuration-examples/
3042

31-
If the `X-Request-Start` header is not present `wait_timeout` handling is skipped entirely.
43+
If the `X-Request-Start` header is not present, or does not match one of these formats, `wait_timeout` handling is skipped entirely.
3244

3345
### Wait Overtime
3446

@@ -55,7 +67,7 @@ If your application timeouts fire frequently then [they can cause your applicati
5567
- [Ruby Application Restart Behavior](https://devcenter.heroku.com/articles/what-happens-to-ruby-apps-when-they-are-restarted)
5668
- [License to SIGKILL](https://www.sitepoint.com/license-to-sigkill/)
5769

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.
70+
**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 interrupted, all other in-flight requests will be allowed to run until they return or also are timed out.
5971

6072
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.
6173

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) && Rails::VERSION::MAJOR >= 3
2+
require_relative "rack/timeout/rails" if defined?(Rails) && Rails.const_defined?(:VERSION) && Rails::VERSION::MAJOR >= 3

lib/rack/timeout/core.rb

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,14 @@ def initialize(app, service_timeout:nil, wait_timeout:nil, wait_overtime:nil, se
100100
@exclude = exclude == [] ? ENV.fetch("RACK_TIMEOUT_EXCLUDE", []) : exclude
101101
@only = only == [] ? ENV.fetch("RACK_TIMEOUT_ONLY", []) : only
102102

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)
103+
if @term_on_timeout && !::Process.respond_to?(:fork)
104+
raise(NotImplementedError, <<-MSG)
105+
The platform running your application does not support forking (i.e. Windows, JVM, etc).
106+
107+
To avoid this error, either specify RACK_TIMEOUT_TERM_ON_TIMEOUT=0 or
108+
leave it as default (which will have the same result).
109+
110+
MSG
106111
end
107112
@app = app
108113
end
@@ -124,7 +129,7 @@ def call(env)
124129
seconds_waited = time_started_service - time_started_wait # how long it took between the web server first receiving the request and rack being able to handle it
125130
seconds_waited = 0 if seconds_waited < 0 # make up for potential time drift between the routing server and the application server
126131
final_wait_timeout = wait_timeout + effective_overtime # how long the request will be allowed to have waited
127-
seconds_service_left = final_wait_timeout - seconds_waited # first calculation of service timeout (relevant if request doesn't get expired, may be overriden later)
132+
seconds_service_left = final_wait_timeout - seconds_waited # first calculation of service timeout (relevant if request doesn't get expired, may be overridden later)
128133
info.wait = seconds_waited # updating the info properties; info.timeout will be the wait timeout at this point
129134
info.timeout = final_wait_timeout
130135

@@ -154,13 +159,14 @@ def call(env)
154159
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
155160
register_state_change.call :timed_out
156161

157-
message = "Request "
162+
message = +"Request "
158163
message << "waited #{info.ms(:wait)}, then " if info.wait
159164
message << "ran for longer than #{info.ms(:timeout)} "
160165
if term_on_timeout
166+
Thread.main['RACK_TIMEOUT_COUNT'] ||= 0
161167
Thread.main['RACK_TIMEOUT_COUNT'] += 1
162168

163-
if Thread.main['RACK_TIMEOUT_COUNT'] >= @term_on_timeout
169+
if Thread.main['RACK_TIMEOUT_COUNT'] >= term_on_timeout
164170
message << ", sending SIGTERM to process #{Process.pid}"
165171
Process.kill("SIGTERM", Process.pid)
166172
else
@@ -188,9 +194,9 @@ def call(env)
188194
# X-Request-Start contains the time the request was first seen by the server. Format varies wildly amongst servers, yay!
189195
# - nginx gives the time since epoch as seconds.milliseconds[1]. New Relic documentation recommends preceding it with t=[2], so might as well detect it.
190196
# - Heroku gives the time since epoch in milliseconds. [3]
191-
# - Apache uses t=microseconds[4], so we're not even going there.
197+
# - Apache uses t=microseconds[4], so 16 digits (until November 2286).
192198
#
193-
# The sane way to handle this would be by knowing the server being used, instead let's just hack around with regular expressions and ignore apache entirely.
199+
# The sane way to handle this would be by knowing the server being used, instead let's just hack around with regular expressions.
194200
# [1]: http://nginx.org/en/docs/http/ngx_http_log_module.html#var_msec
195201
# [2]: https://docs.newrelic.com/docs/apm/other-features/request-queueing/request-queue-server-configuration-examples#nginx
196202
# [3]: https://devcenter.heroku.com/articles/http-routing#heroku-headers
@@ -199,11 +205,15 @@ def call(env)
199205
# This is a code extraction for readability, this method is only called from a single point.
200206
RX_NGINX_X_REQUEST_START = /^(?:t=)?(\d+)\.(\d{3})$/
201207
RX_HEROKU_X_REQUEST_START = /^(\d+)$/
208+
RX_APACHE_X_REQUEST_START = /^t=(\d{16})$/
202209
HTTP_X_REQUEST_START = "HTTP_X_REQUEST_START".freeze
203210
def self._read_x_request_start(env)
204211
return unless s = env[HTTP_X_REQUEST_START]
205-
return unless m = s.match(RX_HEROKU_X_REQUEST_START) || s.match(RX_NGINX_X_REQUEST_START)
206-
Time.at(m[1,2].join.to_f / 1000)
212+
if m = s.match(RX_HEROKU_X_REQUEST_START) || s.match(RX_NGINX_X_REQUEST_START)
213+
Time.at(m[1,2].join.to_f / 1000)
214+
elsif m = s.match(RX_APACHE_X_REQUEST_START)
215+
Time.at(m[1].to_f / 1_000_000)
216+
end
207217
end
208218

209219
# This method determines if a body is present. requests with a body (generally POST, PUT) can have a lengthy body which may have taken a while to be received by the web server, inflating their computed wait time. This in turn could lead to unwanted expirations. See wait_overtime property as a way to overcome those.

lib/rack/timeout/logging-observer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def log_state_change(env)
4343
info = env[::Rack::Timeout::ENV_INFO_KEY]
4444
level = STATE_LOG_LEVEL[info.state]
4545
logger(env).send(level) do
46-
s = "source=rack-timeout"
46+
s = +"source=rack-timeout"
4747
s << " id=" << info.id if info.id
4848
s << " wait=" << info.ms(:wait) if info.wait
4949
s << " timeout=" << info.ms(:timeout) if info.timeout

0 commit comments

Comments
 (0)