idempotent 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :test do
6
+ gem 'guard-rspec'
7
+ gem 'rspec'
8
+ end
9
+
10
+ group :faraday do
11
+ gem 'faraday'
12
+ end
13
+
14
+ group :rack do
15
+ gem 'rack-client'
16
+ end
@@ -0,0 +1,6 @@
1
+ guard :rspec do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec" }
4
+ watch('spec/spec_helper.rb') { "spec" }
5
+ end
6
+
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Josh Lane
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.
@@ -0,0 +1,29 @@
1
+ # Idempotent
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'idempotent'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install idempotent
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,23 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'idempotent/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "idempotent"
8
+ spec.version = Idempotent::VERSION
9
+ spec.authors = ["Josh Lane"]
10
+ spec.email = ["me@joshualane.com"]
11
+ spec.description = %q{Idempotency middleware for Rack::Client and Faraday}
12
+ spec.summary = %q{Make your client's less sensitive}
13
+ spec.homepage = "http://joshualane.com/idempotent"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.3"
22
+ spec.add_development_dependency "rake"
23
+ end
@@ -0,0 +1,21 @@
1
+ require "idempotent/version"
2
+
3
+ module Idempotent
4
+ DEFAULT_RETRY_LIMIT = 5
5
+
6
+ # Handlers
7
+ autoload :Handler, 'idempotent/handler'
8
+ autoload :Rack, 'idempotent/rack'
9
+ autoload :Faraday, 'idempotent/faraday'
10
+
11
+ # Retry policies
12
+ autoload :ImmediateRetry, 'idempotent/immediate_retry'
13
+ autoload :ExponentialBackoff, 'idempotent/exponential_backoff'
14
+
15
+ # Rescue policies
16
+ autoload :DefaultRescue, 'idempotent/default_rescue'
17
+
18
+ # Exceptions
19
+ autoload :RetryLimitExceeded, 'idempotent/retry_limit_exceeded'
20
+ autoload :Retryable, 'idempotent/retryable'
21
+ end
@@ -0,0 +1,27 @@
1
+ class Idempotent::DefaultRescue
2
+ GET_RETRY_HTTP_CODES = [408, 502, 503, 504]
3
+ POST_RETRY_HTTP_CODES = [502, 503, 504]
4
+ IDEMPOTENT_ERROR_CLASSES = [Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH]
5
+
6
+ def call(request, response, exception)
7
+ status = nil
8
+ method = nil
9
+
10
+ if exception
11
+ return IDEMPOTENT_ERROR_CLASSES.include?(exception.class)
12
+ end
13
+
14
+ unless status && method
15
+ status = response.status
16
+ method = request.env["REQUEST_METHOD"]
17
+ end
18
+
19
+ if method == "GET"
20
+ GET_RETRY_HTTP_CODES.include?(status)
21
+ elsif method == "POST"
22
+ POST_RETRY_HTTP_CODES.include?(status)
23
+ else
24
+ false
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ class Idempotent::ExponentialBackoff
2
+ attr_reader :max_retries, :max_retry_interval, :min_retry_interval
3
+
4
+ def initialize(options={})
5
+ @max_retries = options[:max_retries] || Idempotent::DEFAULT_RETRY_LIMIT
6
+ @min_retry_interval = options[:min_retry_interval] || 0.5
7
+ @max_retry_interval = options[:max_retry_interval] || 1800
8
+ end
9
+
10
+ def call(request, response, exception)
11
+ request.env["idempotent.requests.count"] ||= 0
12
+ request.env["idempotent.requests.count"] += 1
13
+ request.env["idempotent.requests.sleep"] ||= (@min_retry_interval / 2)
14
+ request.env["idempotent.requests.sleep"] *= 2
15
+
16
+ if request.env["idempotent.requests.sleep"] > @max_retry_interval
17
+ request.env["idempotent.requests.sleep"] = @max_retry_interval
18
+ end
19
+ if request.env["idempotent.requests.count"] >= @max_retries
20
+ raise Idempotent::RetryLimitExceeded.new(
21
+ request.env["idempotent.requests.exceptions"]
22
+ )
23
+ end
24
+ delay(request.env["idempotent.requests.sleep"])
25
+ end
26
+
27
+ def delay(secs)
28
+ sleep(secs)
29
+ end
30
+ end
@@ -0,0 +1,14 @@
1
+ class Idempotent::Handler
2
+
3
+ attr_reader :retry_policy, :rescue_policy
4
+
5
+ def initialize(app, options={})
6
+ @app = app
7
+ @retry_policy = options[:retry] || Idempotent::ImmediateRetry.new
8
+ @rescue_policy = options[:rescue] || Idempotent::DefaultRescue.new
9
+ end
10
+
11
+ # @api
12
+ def store_exception(exception, request)
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ class Idempotent::ImmediateRetry
2
+ attr_reader :max_retries
3
+
4
+ def initialize(options={})
5
+ @max_retries = options[:max_retries] || Idempotent::DEFAULT_RETRY_LIMIT
6
+ end
7
+
8
+ def call(request, response, exception)
9
+ request.env["idempotent.requests.count"] ||= 0
10
+ request.env["idempotent.requests.count"] += 1
11
+
12
+ if request.env["idempotent.requests.count"] >= max_retries
13
+ raise Idempotent::RetryLimitExceeded.new(
14
+ request.env["idempotent.requests.exceptions"]
15
+ )
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ require 'rack/response'
2
+ require 'rack/request'
3
+
4
+ module Idempotent
5
+ class Rack < Handler
6
+
7
+ def call(env)
8
+ request = ::Rack::Request.new(env)
9
+ response = nil
10
+ exception = nil
11
+
12
+ while true
13
+ retry_policy.call(request, response, exception) if response || exception
14
+ response, exception = nil
15
+
16
+ begin
17
+ status, headers, body = @app.call(env.dup)
18
+ response = ::Rack::Response.new(body, status, headers)
19
+
20
+ next if rescue_policy.call(request, response, exception)
21
+
22
+ return [status, headers, body]
23
+ rescue Idempotent::Retryable => exception
24
+ store_exception(exception, request)
25
+ next
26
+ rescue => exception
27
+ if rescue_policy.call(request, response, exception)
28
+ store_exception(exception, request)
29
+ next
30
+ end
31
+ raise
32
+ end
33
+ end
34
+ end
35
+
36
+ def store_exception(exception, request)
37
+ request.env["idempotent.requests.exceptions"] ||= []
38
+ request.env["idempotent.requests.exceptions"] << exception
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,7 @@
1
+ class Idempotent::RetryLimitExceeded < StandardError
2
+ attr_reader :idempotent_exceptions
3
+
4
+ def initialize(idempotent_exceptions)
5
+ @idempotent_exceptions = idempotent_exceptions
6
+ end
7
+ end
@@ -0,0 +1,2 @@
1
+ class Idempotent::Retryable < StandardError
2
+ end
@@ -0,0 +1,3 @@
1
+ module Idempotent
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,213 @@
1
+ require 'spec_helper'
2
+
3
+ Bundler.require(:rack)
4
+
5
+ describe 'rack/default' do
6
+ let(:client) do
7
+ Rack::Client.new do
8
+ use Rack::Lint
9
+ use Idempotent::Rack
10
+ use Rack::Lint
11
+ use RecordRequests
12
+ run ChaosMonkey
13
+ end
14
+ end
15
+
16
+ describe "with defaults" do
17
+ it "should not retry if succesful response" do
18
+ client.get("http://example.org/")
19
+ RecordRequests.requests.count.should == 1
20
+ end
21
+
22
+ it "should retry if it gets one unsuccesful response" do
23
+ ChaosMonkey.errors = [503]
24
+ client.get("http://example.org/")
25
+
26
+ RecordRequests.requests.count.should == 2
27
+
28
+ RecordRequests.responses.count.should == 2
29
+ RecordRequests.responses[0][0].should == 503
30
+ RecordRequests.responses[1][0].should == 200
31
+ end
32
+
33
+ it "should retry if it gets more than one unsuccesful response" do
34
+ ChaosMonkey.errors = [503, 504]
35
+ client.get("http://example.org/")
36
+
37
+ RecordRequests.requests.count.should == 3
38
+
39
+ RecordRequests.responses.count.should == 3
40
+ RecordRequests.responses[0][0].should == 503
41
+ RecordRequests.responses[1][0].should == 504
42
+ RecordRequests.responses[2][0].should == 200
43
+ end
44
+
45
+ it "should raise RetryLimitExceeded when the request fails too many times" do
46
+ retry_limit = Idempotent::DEFAULT_RETRY_LIMIT
47
+ ChaosMonkey.errors = (retry_limit + 1).times.map {|i| 503}
48
+ lambda {
49
+ client.get("http://example.org/")
50
+ }.should raise_exception Idempotent::RetryLimitExceeded
51
+ RecordRequests.requests.count.should == retry_limit
52
+ RecordRequests.responses.count.should == retry_limit
53
+ end
54
+
55
+ it "should retry if the connection times out once" do
56
+ ChaosMonkey.errors = [Errno::ETIMEDOUT]
57
+ client.get("http://example.org/")
58
+
59
+ RecordRequests.requests.count.should == 2
60
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
61
+ exceptions.count.should == 1
62
+ exceptions.first.class.should == Errno::ETIMEDOUT
63
+
64
+ RecordRequests.responses.count.should == 2
65
+ RecordRequests.responses.last[0].should == 200
66
+ end
67
+
68
+ it "should retry if the connection times out more than once" do
69
+ ChaosMonkey.errors = [Errno::ETIMEDOUT, Errno::ETIMEDOUT]
70
+ client.get("http://example.org/")
71
+
72
+ RecordRequests.requests.count.should == 3
73
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
74
+ exceptions.count.should == 2
75
+ exceptions.first.class.should == Errno::ETIMEDOUT
76
+
77
+ RecordRequests.responses.count.should == 3
78
+ RecordRequests.responses.last[0].should == 200
79
+ end
80
+
81
+ it "should raise RetryLimitExceeded when the connection times out too many times" do
82
+ retry_limit = Idempotent::DEFAULT_RETRY_LIMIT
83
+ ChaosMonkey.errors = (retry_limit + 1).times.map {|i| Errno::ETIMEDOUT}
84
+ lambda {
85
+ client.get("http://example.org/")
86
+ }.should raise_exception Idempotent::RetryLimitExceeded
87
+ RecordRequests.requests.count.should == retry_limit
88
+ RecordRequests.responses.count.should == retry_limit
89
+ end
90
+
91
+ describe "does what the README says it does and" do
92
+ it 'has a retry limit of 5' do
93
+ Idempotent::DEFAULT_RETRY_LIMIT.should == 5
94
+ end
95
+
96
+ [Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH].each do |e|
97
+ it "retries on #{e}" do
98
+ ChaosMonkey.errors = [e]
99
+ client.get("http://example.org/")
100
+
101
+ RecordRequests.requests.count.should == 2
102
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
103
+ exceptions.count.should == 1
104
+ exceptions.first.class.should == e
105
+
106
+ RecordRequests.responses.count.should == 2
107
+ RecordRequests.responses.last[0].should == 200
108
+ end
109
+ end
110
+
111
+ [408, 502, 503, 504].each do |status|
112
+ it "retries on #{status}" do
113
+ ChaosMonkey.errors = [status]
114
+ client.get("http://example.org/")
115
+
116
+ RecordRequests.requests.count.should == 2
117
+
118
+ RecordRequests.responses.count.should == 2
119
+ RecordRequests.responses[0][0].should == status
120
+ RecordRequests.responses[1][0].should == 200
121
+ end
122
+ end
123
+
124
+ it 'raises RetryLimitExceeded if the retry limit is exceeded' do
125
+ retry_limit = Idempotent::DEFAULT_RETRY_LIMIT
126
+ ChaosMonkey.errors = (retry_limit + 1).times.map {|i| 408}
127
+ lambda {
128
+ client.get("http://example.org/")
129
+ }.should raise_exception Idempotent::RetryLimitExceeded
130
+ RecordRequests.requests.count.should == retry_limit
131
+ RecordRequests.responses.count.should == retry_limit
132
+ end
133
+
134
+ it 'stores any exceptions raised in RetryLimitExceeded.idempotent_exceptions' do
135
+ retry_limit = Idempotent::DEFAULT_RETRY_LIMIT
136
+ ChaosMonkey.errors = (retry_limit + 1).times.map {|i| Errno::ETIMEDOUT}
137
+ lambda {
138
+ begin
139
+ client.get("http://example.org/")
140
+ rescue Idempotent::RetryLimitExceeded => e
141
+ e.idempotent_exceptions.should_not be_nil
142
+ exceptions = e.idempotent_exceptions
143
+ exceptions.count.should == retry_limit
144
+ exceptions.each do |ex|
145
+ ex.class.should == Errno::ETIMEDOUT
146
+ end
147
+ raise
148
+ end
149
+ }.should raise_exception Idempotent::RetryLimitExceeded
150
+
151
+ RecordRequests.requests.count.should == retry_limit
152
+ RecordRequests.responses.count.should == retry_limit
153
+ end
154
+ end
155
+
156
+ describe "does what v0.0.3 does and" do
157
+ [502, 503, 504].each do |status|
158
+ it "retries POST requests if the status is #{status}" do
159
+ ChaosMonkey.errors = [status]
160
+ client.post("http://example.org/")
161
+
162
+ RecordRequests.requests.count.should == 2
163
+
164
+ RecordRequests.responses.count.should == 2
165
+ RecordRequests.responses[0][0].should == status
166
+ RecordRequests.responses[1][0].should == 200
167
+ end
168
+ end
169
+
170
+ it 'does not retry a POST if the status is 408' do
171
+ ChaosMonkey.errors = [408]
172
+ client.post("http://example.org/")
173
+
174
+ RecordRequests.requests.count.should == 1
175
+
176
+ RecordRequests.responses.count.should == 1
177
+ RecordRequests.responses[0][0].should == 408
178
+ end
179
+
180
+ it 'retries if a Idempotent::Retryable exception is thrown' do
181
+ ChaosMonkey.errors = [Idempotent::Retryable]
182
+ client.get("http://example.org/")
183
+
184
+ RecordRequests.requests.count.should == 2
185
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
186
+ exceptions.count.should == 1
187
+ exceptions.first.class.should == Idempotent::Retryable
188
+
189
+ RecordRequests.responses.count.should == 2
190
+ RecordRequests.responses.last[0].should == 200
191
+ end
192
+
193
+ it 'is able to rescue http exception via standard error' do
194
+ ChaosMonkey.errors = [400]
195
+ begin
196
+ client.post("http://example.org/")
197
+ rescue => e
198
+ e.class.should == Idempotent::HTTPException
199
+ end
200
+ end
201
+
202
+ it 'is able to rescue retry limit exceeded via standard error' do
203
+ retry_limit = Idempotent::DEFAULT_RETRY_LIMIT
204
+ ChaosMonkey.errors = (retry_limit + 1).times.map {|i| Errno::ETIMEDOUT}
205
+ begin
206
+ client.post("http://example.org/")
207
+ rescue => e
208
+ e.class.should == Idempotent::RetryLimitExceeded
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,12 @@
1
+ require File.expand_path("../../lib/idempotent", __FILE__)
2
+
3
+ Bundler.require(:test)
4
+
5
+ Dir[File.expand_path("../{support,shared,matchers}/*.rb", __FILE__)].each{|f| require(f)}
6
+
7
+ RSpec.configure do |config|
8
+ config.before(:each) do
9
+ ChaosMonkey.errors = []
10
+ RecordRequests.reset
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ class ChaosMonkey
2
+ class << self
3
+ attr_accessor :errors
4
+ end
5
+
6
+ def self.call(env)
7
+ error = nil
8
+ if self.errors
9
+ error = self.errors.shift
10
+ raise error if error.is_a?(Class)
11
+ end
12
+ status_code = error || 200
13
+ [status_code, {"Content-Type" => "text/plain"}, []]
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ class RecordRequests
2
+ class << self
3
+ attr_accessor :requests
4
+ attr_accessor :responses
5
+
6
+ def reset
7
+ self.requests = []
8
+ self.responses = []
9
+ end
10
+ end
11
+
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ response = @app.call(env)
18
+ ensure
19
+ self.class.requests << env
20
+ self.class.responses << response
21
+ end
22
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: idempotent
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.0.1
6
+ platform: ruby
7
+ authors:
8
+ - Josh Lane
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-07-01 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ version_requirements: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '1.3'
22
+ requirement: !ruby/object:Gem::Requirement
23
+ none: false
24
+ requirements:
25
+ - - ~>
26
+ - !ruby/object:Gem::Version
27
+ version: '1.3'
28
+ type: :development
29
+ prerelease: false
30
+ - !ruby/object:Gem::Dependency
31
+ name: rake
32
+ version_requirements: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ requirement: !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :development
45
+ prerelease: false
46
+ description: Idempotency middleware for Rack::Client and Faraday
47
+ email:
48
+ - me@joshualane.com
49
+ executables: []
50
+ extensions: []
51
+ extra_rdoc_files: []
52
+ files:
53
+ - .gitignore
54
+ - Gemfile
55
+ - Guardfile
56
+ - LICENSE.txt
57
+ - README.md
58
+ - Rakefile
59
+ - idempotent.gemspec
60
+ - lib/idempotent.rb
61
+ - lib/idempotent/default_rescue.rb
62
+ - lib/idempotent/exponential_backoff.rb
63
+ - lib/idempotent/handler.rb
64
+ - lib/idempotent/immediate_retry.rb
65
+ - lib/idempotent/rack.rb
66
+ - lib/idempotent/retry_limit_exceeded.rb
67
+ - lib/idempotent/retryable.rb
68
+ - lib/idempotent/version.rb
69
+ - spec/rack/default_spec.rb
70
+ - spec/spec_helper.rb
71
+ - spec/support/chaos_monkey.rb
72
+ - spec/support/record_requests.rb
73
+ homepage: http://joshualane.com/idempotent
74
+ licenses:
75
+ - MIT
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ none: false
88
+ requirements:
89
+ - - ! '>='
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubyforge_project:
94
+ rubygems_version: 1.8.25
95
+ signing_key:
96
+ specification_version: 3
97
+ summary: Make your client's less sensitive
98
+ test_files:
99
+ - spec/rack/default_spec.rb
100
+ - spec/spec_helper.rb
101
+ - spec/support/chaos_monkey.rb
102
+ - spec/support/record_requests.rb