idempotent 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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