{"record":{"id":"3a8c17bf01ec91da","repo":"rack/rack-attack","slug":"must-pass-maxretry-option","errorCode":null,"errorMessage":"Must pass maxretry option","messagePattern":"Must pass maxretry option","errorType":"exception","errorClass":"ArgumentError","httpStatus":null,"severity":"error","filePath":"lib/rack/attack/fail2ban.rb","lineNumber":10,"sourceCode":"# frozen_string_literal: true\n\nmodule Rack\n  class Attack\n    class Fail2Ban\n      class << self\n        def filter(discriminator, options)\n          bantime   = options[:bantime]   or raise ArgumentError, \"Must pass bantime option\"\n          findtime  = options[:findtime]  or raise ArgumentError, \"Must pass findtime option\"\n          maxretry  = options[:maxretry]  or raise ArgumentError, \"Must pass maxretry option\"\n\n          if banned?(discriminator)\n            # Return true for blocklist\n            true\n          elsif yield\n            fail!(discriminator, bantime, findtime, maxretry)\n          end\n        end\n\n        def reset(discriminator, options)\n          findtime = options[:findtime] or raise ArgumentError, \"Must pass findtime option\"\n          cache.reset_count(\"#{key_prefix}:count:#{discriminator}\", findtime)\n          # Clear ban flag just in case it's there\n          cache.delete(\"#{key_prefix}:ban:#{discriminator}\")\n        end\n\n        def banned?(discriminator)\n          cache.read(\"#{key_prefix}:ban:#{discriminator}\") ? true : false","sourceCodeStart":1,"sourceCodeEnd":28,"githubUrl":"https://github.com/rack/rack-attack/blob/b771ea18afb9e6b625906a641d2d331e6d0c282b/lib/rack/attack/fail2ban.rb#L1-L28","documentation":"Fail2Ban.filter's third mandatory option is :maxretry - the number of failures within findtime that trigger a ban. 'maxretry = options[:maxretry] or raise ArgumentError' fires when the key is missing or nil/false, because fail! compares cache.count(...) >= maxretry and banning on nil would never (or always) trigger. The check happens before banned?/yield, so the very first filtered request raises.","triggerScenarios":"Rack::Attack::Fail2Ban.filter(ip, bantime: 3600, findtime: 600) { ... } - maxretry omitted - evaluated from a blocklist on the first matching request; maxretry: nil from config like ENV['MAXRETRY'] unset; key typos (max_retry:, retries:). Direct invocation in tests: Fail2Ban.filter('1.2.3.4', {bantime: 1, findtime: 1}) { true }.","commonSituations":"Partial copy of the fail2ban snippet from docs/guides; moving hard-coded options into per-environment config where one key is absent in one env; nil-sensitivity surprises: 0 or false for maxretry also raise (falsy), though 1+ integers pass; upgrading rack-attack versions where option enforcement got stricter.","solutions":["Pass maxretry with the other two: Rack::Attack::Fail2Ban.filter(req.ip, bantime: 3600, findtime: 600, maxretry: 5) { ... }.","Use one shared frozen options hash for all fail2ban call sites so the trio stays complete.","Coerce ENV values eagerly: maxretry: Integer(ENV.fetch('MAXRETRY', '5')) so nil never reaches the API.","Double-check exact key names (:bantime, :findtime, :maxretry) when porting examples from other libraries (the real fail2ban daemon uses different names like bantime.findtime/maxretry in INI syntax, which invites typos)."],"exampleFix":"# before\nRack::Attack::Fail2Ban.filter(req.ip, bantime: 3600, findtime: 600) { req.post? && req.path == '/login' && bad_credentials?(req) }\n# => ArgumentError: Must pass maxretry option\n\n# after\nRack::Attack::Fail2Ban.filter(req.ip, bantime: 3600, findtime: 600, maxretry: 5) { req.post? && req.path == '/login' && bad_credentials?(req) }","handlingStrategy":"validation","validationCode":"opts = { bantime: 3600, findtime: 600, maxretry: 5 }\nraise ArgumentError, 'maxretry required' unless opts[:maxretry].to_i.positive?\nRack::Attack::Fail2Ban.filter(ip, opts) { ... }","typeGuard":"def fail2ban_filter_args_valid?(ip, opts)\n  ip.is_a?(String) && !ip.empty? &&\n    %i[bantime findtime maxretry].all? { |k| opts[k].is_a?(Integer) && opts[k].positive? }\nend","tryCatchPattern":"begin\n  Rack::Attack::Fail2Ban.filter(ip, opts) { suspicious?(req) }\nrescue ArgumentError => e\n  raise unless e.message.include?('Must pass')\n  notify_ops(\"fail2ban option missing: #{e.message}\")\n  false\nend","preventionTips":["Never assemble the options hash inline at multiple call sites; one frozen constant prevents drift.","Add a boot spec: expect { Fail2Ban.filter('spec-ip', CONFIG) { true } }.not_to raise_error.","Coerce and default ENV numerics with Integer(ENV.fetch('MAXRETRY', '5')).","Remember falsy values (nil/false) raise like missing keys - treat 0/nil retries as config errors."],"tags":["rack-attack","ruby","fail2ban","argument-error","configuration","brute-force"],"backgroundTag":"missing-required-option","analyzedSha":"b771ea18afb9e6b625906a641d2d331e6d0c282b","analyzedAt":"2026-08-21T19:44:17.247Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}