├── 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 |Give it another shot.
64 |Give it another shot.
64 |