├── lib ├── slowpoke │ ├── version.rb │ ├── middleware.rb │ ├── timeout.rb │ └── railtie.rb ├── generators │ └── slowpoke │ │ ├── install_generator.rb │ │ └── templates │ │ └── 503.html └── slowpoke.rb ├── Gemfile ├── test ├── internal │ ├── config │ │ └── routes.rb │ ├── app │ │ └── controllers │ │ │ └── users_controller.rb │ └── public │ │ └── 503.html ├── install_generator_test.rb ├── test_helper.rb └── slowpoke_test.rb ├── gemfiles ├── rails71.gemfile ├── rails72.gemfile └── rails80.gemfile ├── Rakefile ├── .gitignore ├── slowpoke.gemspec ├── .github └── workflows │ └── build.yml ├── LICENSE.txt ├── CHANGELOG.md └── README.md /lib/slowpoke/version.rb: -------------------------------------------------------------------------------- 1 | module Slowpoke 2 | VERSION = "0.7.0" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 8.1.0" 9 | -------------------------------------------------------------------------------- /test/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | get "timeout" => "users#timeout" 3 | get "admin" => "users#admin" 4 | end 5 | -------------------------------------------------------------------------------- /gemfiles/rails71.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 7.1.0" 9 | -------------------------------------------------------------------------------- /gemfiles/rails72.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 7.2.0" 9 | -------------------------------------------------------------------------------- /gemfiles/rails80.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 8.0.0" 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new do |t| 5 | t.test_files = FileList["test/**/*_test.rb"] 6 | end 7 | 8 | task default: :test 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | *.log 15 | *.lock 16 | -------------------------------------------------------------------------------- /test/internal/app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ActionController::Base 2 | def timeout 3 | sleep(0.2) 4 | head :ok 5 | end 6 | 7 | def admin 8 | sleep(0.2) 9 | head :ok 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/generators/slowpoke/install_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators" 2 | 3 | module Slowpoke 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | source_root File.expand_path("templates", __dir__) 7 | 8 | def copy_503_html 9 | template "503.html", "public/503.html" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/install_generator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | require "generators/slowpoke/install_generator" 4 | 5 | class InstallGeneratorTest < Rails::Generators::TestCase 6 | tests Slowpoke::Generators::InstallGenerator 7 | destination File.expand_path("../tmp", __dir__) 8 | setup :prepare_destination 9 | 10 | def test_works 11 | run_generator 12 | assert_file "public/503.html" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/slowpoke/middleware.rb: -------------------------------------------------------------------------------- 1 | module Slowpoke 2 | class Middleware 3 | def initialize(app) 4 | @app = app 5 | end 6 | 7 | def call(env) 8 | @app.call(env) 9 | ensure 10 | # extremely important 11 | # protect the process with a restart 12 | # https://github.com/heroku/rack-timeout/issues/39 13 | # can't do in timed_out state consistently 14 | Slowpoke.on_timeout.call(env) if env[Slowpoke::ENV_KEY] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/slowpoke/timeout.rb: -------------------------------------------------------------------------------- 1 | module Slowpoke 2 | class Timeout 3 | def initialize(app, service_timeout:) 4 | @app = app 5 | @service_timeout = service_timeout 6 | @middleware = {} 7 | end 8 | 9 | def call(env) 10 | service_timeout = @service_timeout.call(env) 11 | if service_timeout 12 | (@middleware[service_timeout] ||= Rack::Timeout.new(@app, service_timeout: service_timeout)).call(env) 13 | else 14 | @app.call(env) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /slowpoke.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/slowpoke/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "slowpoke" 5 | spec.version = Slowpoke::VERSION 6 | spec.summary = "Rack::Timeout enhancements for Rails" 7 | spec.homepage = "https://github.com/ankane/slowpoke" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | 18 | spec.add_dependency "railties", ">= 7.1" 19 | spec.add_dependency "actionpack", ">= 7.1" 20 | spec.add_dependency "rack-timeout", ">= 0.6" 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | include: 10 | - ruby: 3.4 11 | gemfile: Gemfile 12 | - ruby: 3.4 13 | gemfile: gemfiles/rails80.gemfile 14 | - ruby: 3.3 15 | gemfile: gemfiles/rails72.gemfile 16 | - ruby: 3.2 17 | gemfile: gemfiles/rails71.gemfile 18 | env: 19 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 20 | steps: 21 | - uses: actions/checkout@v5 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - run: bundle exec rake test 27 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "combustion" 3 | Bundler.require(:default) 4 | require "minitest/autorun" 5 | 6 | logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDOUT : nil) 7 | 8 | Combustion.path = "test/internal" 9 | Combustion.initialize! :action_controller do 10 | config.load_defaults Rails::VERSION::STRING.to_f 11 | config.action_controller.logger = logger 12 | 13 | config.action_dispatch.show_exceptions = :all 14 | config.consider_all_requests_local = false 15 | 16 | config.slowpoke.timeout = lambda do |env| 17 | request = Rack::Request.new(env) 18 | request.path.start_with?("/admin") ? 1 : 0.1 19 | end 20 | end 21 | 22 | # https://github.com/rails/rails/issues/54595 23 | if RUBY_ENGINE == "jruby" && Rails::VERSION::MAJOR >= 8 24 | Rails.application.reload_routes_unless_loaded 25 | end 26 | -------------------------------------------------------------------------------- /test/slowpoke_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class SlowpokeTest < ActionDispatch::IntegrationTest 4 | def test_timeout 5 | get timeout_url 6 | assert_response :service_unavailable 7 | assert_match "This page took too long to load", response.body 8 | end 9 | 10 | def test_dynamic 11 | get admin_url 12 | assert_response :success 13 | end 14 | 15 | def test_on_timeout 16 | timed_out = false 17 | previous_value = Slowpoke.on_timeout 18 | begin 19 | Slowpoke.on_timeout { timed_out = true } 20 | get timeout_url 21 | ensure 22 | Slowpoke.on_timeout(&previous_value) 23 | end 24 | assert timed_out 25 | end 26 | 27 | def test_notifications 28 | notifications = [] 29 | callback = ->(*args) { notifications << args } 30 | ActiveSupport::Notifications.subscribed(callback, "timeout.slowpoke") do 31 | get timeout_url 32 | end 33 | assert_equal 1, notifications.size 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/slowpoke/railtie.rb: -------------------------------------------------------------------------------- 1 | module Slowpoke 2 | class Railtie < Rails::Railtie 3 | config.slowpoke = ActiveSupport::OrderedOptions.new 4 | 5 | # must happen outside initializer (so it runs earlier) 6 | config.action_dispatch.rescue_responses.merge!( 7 | "Rack::Timeout::RequestTimeoutError" => :service_unavailable, 8 | "Rack::Timeout::RequestExpiryError" => :service_unavailable 9 | ) 10 | 11 | initializer "slowpoke" do |app| 12 | service_timeout = app.config.slowpoke.timeout 13 | service_timeout ||= ENV["RACK_TIMEOUT_SERVICE_TIMEOUT"] || ENV["REQUEST_TIMEOUT"] || ENV["TIMEOUT"] || 15 14 | 15 | if service_timeout.respond_to?(:call) 16 | app.config.middleware.insert_after ActionDispatch::DebugExceptions, Slowpoke::Timeout, 17 | service_timeout: service_timeout 18 | else 19 | app.config.middleware.insert_after ActionDispatch::DebugExceptions, Rack::Timeout, 20 | service_timeout: service_timeout.to_i 21 | end 22 | 23 | app.config.middleware.insert(0, Slowpoke::Middleware) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2024 Andrew Kane 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/slowpoke.rb: -------------------------------------------------------------------------------- 1 | # dependencies 2 | require "rack/timeout/base" 3 | 4 | # modules 5 | require_relative "slowpoke/middleware" 6 | require_relative "slowpoke/railtie" 7 | require_relative "slowpoke/timeout" 8 | require_relative "slowpoke/version" 9 | 10 | module Slowpoke 11 | ENV_KEY = "slowpoke.timed_out".freeze 12 | 13 | def self.kill 14 | if defined?(::PhusionPassenger) 15 | `passenger-config detach-process #{Process.pid}` 16 | elsif defined?(::Puma) 17 | Process.kill("TERM", Process.pid) 18 | else 19 | Process.kill("QUIT", Process.pid) 20 | end 21 | end 22 | 23 | def self.on_timeout(&block) 24 | if block_given? 25 | @on_timeout = block 26 | else 27 | @on_timeout 28 | end 29 | end 30 | 31 | on_timeout do |env| 32 | next if Rails.env.development? || Rails.env.test? 33 | 34 | Slowpoke.kill 35 | end 36 | end 37 | 38 | # remove noisy logger 39 | Rack::Timeout.unregister_state_change_observer(:logger) 40 | 41 | # process protection and notifications 42 | Rack::Timeout.register_state_change_observer(:slowpoke) do |env| 43 | if env[Rack::Timeout::ENV_INFO_KEY].state == :timed_out 44 | env[Slowpoke::ENV_KEY] = true 45 | 46 | # TODO better payload 47 | ActiveSupport::Notifications.instrument("timeout.slowpoke", {}) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/internal/public/503.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This page took too long to load (503) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

This page took too long to load.

62 |
63 |

Give it another shot.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /lib/generators/slowpoke/templates/503.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This page took too long to load (503) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

This page took too long to load.

62 |
63 |

Give it another shot.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 (2025-05-26) 2 | 3 | - Dropped support for Ruby < 3.2 and Rails < 7.1 4 | 5 | ## 0.6.0 (2025-02-01) 6 | 7 | - Dropped support for Ruby < 3.1 and Rails < 7 8 | 9 | ## 0.5.0 (2023-07-02) 10 | 11 | - Dropped support for rack-timeout < 0.6 12 | - Dropped support for Ruby < 3 and Rails < 6.1 13 | 14 | ## 0.4.0 (2022-01-10) 15 | 16 | - Dropped support for Ruby < 2.6 and Rails < 5.2 17 | 18 | ## 0.3.2 (2019-12-23) 19 | 20 | - Added `on_timeout` method 21 | 22 | ## 0.3.1 (2019-12-10) 23 | 24 | - Added support for dynamic timeouts 25 | 26 | ## 0.3.0 (2019-05-31) 27 | 28 | - Use proper signal for Puma 29 | - Dropped support for rack-timeout < 0.4 30 | - Dropped support for migration timeouts 31 | - Dropped support for Rails < 5 32 | 33 | ## 0.2.1 (2018-05-21) 34 | 35 | - Don’t kill server in test environment 36 | - Require rack-timeout < 0.5 37 | 38 | ## 0.2.0 (2017-11-05) 39 | 40 | - Fixed custom error pages for Rails 5.1 41 | - Fixed migration statement timeout 42 | - Don’t kill server in development 43 | 44 | ## 0.1.3 (2016-08-03) 45 | 46 | - Fixed deprecation warning in Rails 5 47 | - No longer requires Active Record 48 | 49 | ## 0.1.2 (2016-02-10) 50 | 51 | - Updated to latest version of rack-timeout, removing the need to bubble timeouts 52 | 53 | ## 0.1.1 (2015-08-02) 54 | 55 | - Fixed safer service timeouts 56 | - Added migration statement timeout 57 | 58 | ## 0.1.0 (2015-06-24) 59 | 60 | - Prevent `RequestExpiryError` from killing web server 61 | - Removed database timeouts 62 | 63 | ## 0.0.6 (2015-03-15) 64 | 65 | - Switched to `safely_block` gem 66 | 67 | ## 0.0.5 (2015-03-04) 68 | 69 | - Fixed error when Postgres is not used 70 | 71 | ## 0.0.4 (2014-10-31) 72 | 73 | - Added `REQUEST_TIMEOUT` and `DATABASE_TIMEOUT` variables 74 | 75 | ## 0.0.3 (2014-10-22) 76 | 77 | - Improved handling of timeouts 78 | 79 | ## 0.0.2 (2014-10-22) 80 | 81 | - Added process protection 82 | 83 | ## 0.0.1 (2014-10-21) 84 | 85 | - First release 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slowpoke 2 | 3 | [Rack::Timeout](https://github.com/heroku/rack-timeout) enhancements for Rails 4 | 5 | - safer service timeouts 6 | - dynamic timeouts 7 | - custom error pages 8 | 9 | [![Build Status](https://github.com/ankane/slowpoke/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/slowpoke/actions) 10 | 11 | ## Installation 12 | 13 | Add this line to your application’s Gemfile: 14 | 15 | ```ruby 16 | gem "slowpoke" 17 | ``` 18 | 19 | And run: 20 | 21 | ```sh 22 | rails generate slowpoke:install 23 | ``` 24 | 25 | This creates a `public/503.html` you can customize. 26 | 27 | ## Development 28 | 29 | To try out custom error pages in development, temporarily add to `config/environments/development.rb`: 30 | 31 | ```ruby 32 | config.slowpoke.timeout = 1 33 | config.consider_all_requests_local = false 34 | ``` 35 | 36 | And add a `sleep` call to one of your actions: 37 | 38 | ```ruby 39 | sleep(2) 40 | ``` 41 | 42 | The custom error page should appear. 43 | 44 | ## Production 45 | 46 | The default timeout is 15 seconds. You can change this in `config/environments/production.rb` with: 47 | 48 | ```ruby 49 | config.slowpoke.timeout = 5 50 | ``` 51 | 52 | For dynamic timeouts, use: 53 | 54 | ```ruby 55 | config.slowpoke.timeout = lambda do |env| 56 | request = Rack::Request.new(env) 57 | request.path.start_with?("/admin") ? 15 : 5 58 | end 59 | ``` 60 | 61 | Subscribe to timeouts with: 62 | 63 | ```ruby 64 | ActiveSupport::Notifications.subscribe "timeout.slowpoke" do |name, start, finish, id, payload| 65 | # report timeout 66 | end 67 | ``` 68 | 69 | To learn more, see the [Rack::Timeout documentation](https://github.com/heroku/rack-timeout). 70 | 71 | ## Safer Service Timeouts 72 | 73 | Rack::Timeout can raise an exception at any point in the code, which can leave your app in an [unclean state](https://www.schneems.com/2017/02/21/the-oldest-bug-in-ruby-why-racktimeout-might-hose-your-server/). The safest way to recover from a request timeout is to spawn a new process. This is the default behavior for Slowpoke. 74 | 75 | For threaded servers like Puma, this means killing all threads when any one of them times out. This can have a significant impact on performance. 76 | 77 | You can customize this behavior with: 78 | 79 | ```ruby 80 | Slowpoke.on_timeout do |env| 81 | next if Rails.env.development? || Rails.env.test? 82 | 83 | exception = env["action_dispatch.exception"] 84 | if exception && exception.backtrace.first.include?("/active_record/") 85 | Slowpoke.kill 86 | end 87 | end 88 | ``` 89 | 90 | Note: To access `env["action_dispatch.exception"]` in development, temporarily add to `config/environments/development.rb`: 91 | 92 | ```ruby 93 | config.consider_all_requests_local = false 94 | ``` 95 | 96 | ## Database Timeouts 97 | 98 | It’s a good idea to set a [statement timeout](https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts/#statement-timeouts-1) and a [connect timeout](https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts/#activerecord). For Postgres, your `config/database.yml` should include something like: 99 | 100 | ```yml 101 | production: 102 | connect_timeout: 3 # sec 103 | variables: 104 | statement_timeout: 5s 105 | ``` 106 | 107 | ## History 108 | 109 | View the [changelog](https://github.com/ankane/slowpoke/blob/master/CHANGELOG.md) 110 | 111 | ## Contributing 112 | 113 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 114 | 115 | - [Report bugs](https://github.com/ankane/slowpoke/issues) 116 | - Fix bugs and [submit pull requests](https://github.com/ankane/slowpoke/pulls) 117 | - Write, clarify, or fix documentation 118 | - Suggest or add new features 119 | 120 | To get started with development: 121 | 122 | ```sh 123 | git clone https://github.com/ankane/slowpoke.git 124 | cd slowpoke 125 | bundle install 126 | bundle exec rake test 127 | ``` 128 | --------------------------------------------------------------------------------