rack-idempotent 0.0.3 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,12 +1,3 @@
1
1
  rvm:
2
- - 1.8.7
3
2
  - 1.9.3
4
- - rbx
5
- notifications:
6
- recipients:
7
- - drnicwilliams@gmail.com
8
- - isombra@engineyard.com
9
- - jhansen@engineyard.com
10
- branches:
11
- only:
12
- - master
3
+ - rbx-19mode
data/Gemfile CHANGED
@@ -3,8 +3,9 @@ source 'https://rubygems.org'
3
3
  # Specify your gem's dependencies in rack-idempotent.gemspec
4
4
  gemspec
5
5
 
6
- group(:test) do
7
- gem 'rake'
8
- gem 'rack-client', :require => 'rack/client'
9
- gem 'rspec'
6
+ group :test do
7
+ gem 'guard-rspec'
8
+ gem "rake"
9
+ gem "rack-client"
10
+ gem "rspec", "~> 2.0"
10
11
  end
@@ -0,0 +1,9 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard 'rspec', :version => 2 do
5
+ watch(%r{^spec/.+_spec\.rb$})
6
+ watch(%r{^lib/(.+)\.rb$}) { "spec" }
7
+ watch('spec/spec_helper.rb') { "spec" }
8
+ end
9
+
@@ -1,67 +1,54 @@
1
1
  require "rack-idempotent/version"
2
2
 
3
- module Rack
4
- class Idempotent
5
- RETRY_LIMIT = 5
6
- RETRY_HTTP_CODES = [502, 503, 504]
7
- IDEMPOTENT_HTTP_CODES = RETRY_HTTP_CODES + [408]
8
- IDEMPOTENT_ERROR_CLASSES = [Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH]
3
+ class Rack::Idempotent
4
+ DEFAULT_RETRY_LIMIT = 5
9
5
 
10
- class RetryLimitExceeded < StandardError
11
- attr_reader :idempotent_exceptions
12
- def initialize(idempotent_exceptions)
13
- @idempotent_exceptions = idempotent_exceptions
14
- end
15
- end
6
+ # Retry policies
7
+ autoload :ImmediateRetry, 'rack-idempotent/immediate_retry'
8
+ autoload :ExponentialBackoff, 'rack-idempotent/exponential_backoff'
16
9
 
17
- class HTTPException < StandardError
18
- attr_reader :status, :headers, :body
19
- def initialize(status, headers, body)
20
- @status, @headers, @body = status, headers, body
21
- end
10
+ # Rescue policies
11
+ autoload :DefaultRescue, 'rack-idempotent/default_rescue'
22
12
 
23
- def to_s
24
- @status.to_s
25
- end
26
- end
13
+ # Exceptions
14
+ autoload :HTTPException, 'rack-idempotent/http_exception'
15
+ autoload :RetryLimitExceeded, 'rack-idempotent/retry_limit_exceeded'
16
+ autoload :Retryable, 'rack-idempotent/retryable'
27
17
 
28
- class Retryable < StandardError
29
- end
18
+ attr_reader :retry_policy, :rescue_policy
30
19
 
31
- def initialize(app)
32
- @app= app
33
- end
20
+ def initialize(app, options={})
21
+ @app = app
22
+ @retry_policy = options[:retry] || Rack::Idempotent::ImmediateRetry.new
23
+ @rescue_policy = options[:rescue] || Rack::Idempotent::DefaultRescue.new
24
+ end
25
+
26
+ def call(env)
27
+ request = Rack::Request.new(env)
28
+ response = nil
29
+ exception = nil
30
+ while true
31
+ retry_policy.call(request, response, exception) if response || exception
32
+ response, exception = nil
34
33
 
35
- def call(env)
36
- env['client.retries'] = 0
37
- status, headers, body = nil
38
- idempotent_exceptions = []
39
34
  begin
40
- dup_env = env.dup
41
- status, headers, body = @app.call(dup_env)
42
- raise HTTPException.new(status, headers, body) if IDEMPOTENT_HTTP_CODES.include?(status)
43
- env.merge!(dup_env)
44
- [status, headers, body]
45
- rescue *(IDEMPOTENT_ERROR_CLASSES + [HTTPException, Retryable]) => ie
46
- idempotent_exceptions << ie
47
- if env['client.retries'] > RETRY_LIMIT - 1
48
- raise(RetryLimitExceeded.new(idempotent_exceptions))
49
- else
50
- if retry?(status, env["REQUEST_METHOD"])
51
- env['client.retries'] += 1
52
- retry
53
- else
54
- raise
55
- end
35
+ status, headers, body = @app.call(env.dup)
36
+ raise HTTPException.new(status, headers, body, request) if status >= 400
37
+ response = Rack::Response.new(body, status, headers)
38
+ next if rescue_policy.call({:response => response, :request => request})
39
+ return [status, headers, body]
40
+ rescue Rack::Idempotent::Retryable => exception
41
+ request.env["idempotent.requests.exceptions"] ||= []
42
+ request.env["idempotent.requests.exceptions"] << exception
43
+ next
44
+ rescue => exception
45
+ if rescue_policy.call({:exception => exception, :request => request})
46
+ request.env["idempotent.requests.exceptions"] ||= []
47
+ request.env["idempotent.requests.exceptions"] << exception
48
+ next
56
49
  end
50
+ raise
57
51
  end
58
52
  end
59
-
60
- private
61
-
62
- def retry?(response_status, request_method)
63
- RETRY_HTTP_CODES.include?(response_status) || request_method == "GET"
64
- end
65
-
66
53
  end
67
54
  end
@@ -0,0 +1,35 @@
1
+ class Rack::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(options={})
7
+ exception = options[:exception]
8
+ status = nil
9
+ method = nil
10
+
11
+ if exception
12
+ if IDEMPOTENT_ERROR_CLASSES.include?(exception.class)
13
+ return true
14
+ elsif exception.class == Rack::Idempotent::HTTPException
15
+ status = exception.status
16
+ method = exception.request.env["REQUEST_METHOD"]
17
+ else
18
+ return false
19
+ end
20
+ end
21
+
22
+ unless status && method
23
+ status = options[:response].status
24
+ method = options[:request].env["REQUEST_METHOD"]
25
+ end
26
+
27
+ if method == "GET"
28
+ GET_RETRY_HTTP_CODES.include?(status)
29
+ elsif method == "POST"
30
+ POST_RETRY_HTTP_CODES.include?(status)
31
+ else
32
+ false
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ class Rack::Idempotent::ExponentialBackoff
2
+ attr_reader :max_retries, :max_retry_interval, :min_retry_interval
3
+
4
+ def initialize(options={})
5
+ @max_retries = options[:max_retries] || Rack::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 Rack::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,10 @@
1
+ class Rack::Idempotent::HTTPException < StandardError
2
+ attr_reader :status, :headers, :body, :request
3
+ def initialize(status, headers, body, request)
4
+ @status, @headers, @body, @request = status, headers, body, request
5
+ end
6
+
7
+ def to_s
8
+ @status.to_s
9
+ end
10
+ end
@@ -0,0 +1,18 @@
1
+ class Rack::Idempotent::ImmediateRetry
2
+ attr_reader :max_retries
3
+
4
+ def initialize(options={})
5
+ @max_retries = options[:max_retries] || Rack::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 Rack::Idempotent::RetryLimitExceeded.new(
14
+ request.env["idempotent.requests.exceptions"]
15
+ )
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ class Rack::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 Rack::Idempotent::Retryable < StandardError
2
+ end
@@ -1,5 +1,5 @@
1
1
  module Rack
2
2
  class Idempotent
3
- VERSION = "0.0.3"
3
+ VERSION = "0.1.0"
4
4
  end
5
5
  end
@@ -2,10 +2,10 @@
2
2
  require File.expand_path('../lib/rack-idempotent/version', __FILE__)
3
3
 
4
4
  Gem::Specification.new do |gem|
5
- gem.authors = ["Ines Sombra"]
6
- gem.email = ["isombra@engineyard.com"]
7
- gem.description = %q{Retry logic for rack-client}
8
- gem.summary = %q{Retry logic for rack-client}
5
+ gem.authors = ["Engine Yard"]
6
+ gem.email = ["engineering@engineyard.com"]
7
+ gem.description = %q{Idempotent Rack middleware}
8
+ gem.summary = %q{Retry middleware for Rack clients}
9
9
  gem.homepage = ""
10
10
 
11
11
  gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
@@ -0,0 +1,150 @@
1
+ require File.expand_path('../../spec_helper', __FILE__)
2
+
3
+ describe Rack::Idempotent do
4
+ before(:each) do
5
+ TestCall.errors = []
6
+ RecordRequests.reset
7
+ end
8
+ let(:client) do
9
+ Rack::Client.new do
10
+ use Rack::Lint
11
+ use Rack::Idempotent, {:rescue => Rack::Idempotent::DefaultRescue.new}
12
+ use Rack::Lint
13
+ use RecordRequests
14
+ run TestCall
15
+ end
16
+ end
17
+
18
+ describe "using Rack::Idempotent::DefaultRescue" do
19
+ [408, 502, 503, 504].each do |status|
20
+ it "should retry GET requests that result in #{status}" do
21
+ TestCall.errors = [status]
22
+ client.get("http://example.org/")
23
+
24
+ RecordRequests.requests.count.should == 2
25
+
26
+ RecordRequests.responses.count.should == 2
27
+ RecordRequests.responses[0][0].should == status
28
+ RecordRequests.responses[1][0].should == 200
29
+ end
30
+ end
31
+
32
+ [200, 201, 301, 302, 400, 401, 403, 404, 500].each do |status|
33
+ it "should not retry GET requests that result in #{status}" do
34
+ TestCall.errors = [status]
35
+ begin
36
+ client.get("http://example.org/")
37
+ status.should < 400
38
+ rescue Rack::Idempotent::HTTPException => e
39
+ e.status.should == status
40
+ e.status.should >= 400
41
+ end
42
+ RecordRequests.requests.count.should == 1
43
+ end
44
+ end
45
+
46
+ [502, 503, 504].each do |status|
47
+ it "should retry POST requests that result in #{status}" do
48
+ TestCall.errors = [status]
49
+ client.post("http://example.org/")
50
+
51
+ RecordRequests.requests.count.should == 2
52
+
53
+ RecordRequests.responses.count.should == 2
54
+ RecordRequests.responses[0][0].should == status
55
+ RecordRequests.responses[1][0].should == 200
56
+ end
57
+ end
58
+
59
+ [200, 201, 301, 302, 400, 401, 403, 404, 408, 500].each do |status|
60
+ it "should not retry POST requests that result in #{status}" do
61
+ TestCall.errors = [status]
62
+ begin
63
+ client.post("http://example.org/")
64
+ status.should < 400
65
+ rescue Rack::Idempotent::HTTPException => e
66
+ e.status.should == status
67
+ e.status.should >= 400
68
+ end
69
+ RecordRequests.requests.count.should == 1
70
+ end
71
+ end
72
+
73
+ it "should not retry HEAD requests" do
74
+ TestCall.errors = [503]
75
+ begin
76
+ client.head("http://example.org/")
77
+ rescue Rack::Idempotent::HTTPException => e
78
+ e.status.should == 503
79
+ end
80
+ RecordRequests.requests.count.should == 1
81
+ end
82
+
83
+ it "should retry if it gets more than one unsuccesful response" do
84
+ TestCall.errors = [503, 504]
85
+ client.get("http://example.org/")
86
+
87
+ RecordRequests.requests.count.should == 3
88
+
89
+ RecordRequests.responses.count.should == 3
90
+ RecordRequests.responses[0][0].should == 503
91
+ RecordRequests.responses[1][0].should == 504
92
+ RecordRequests.responses[2][0].should == 200
93
+ end
94
+
95
+ it "should raise RetryLimitExceeded when the request fails too many times" do
96
+ retry_limit = Rack::Idempotent::DEFAULT_RETRY_LIMIT
97
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
98
+ lambda {
99
+ client.get("http://example.org/")
100
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
101
+ RecordRequests.requests.count.should == retry_limit
102
+ RecordRequests.responses.count.should == retry_limit
103
+ end
104
+
105
+ [Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH].each do |ex|
106
+ it "should retry requests that result in #{ex}" do
107
+ TestCall.errors = [ex]
108
+ client.get("http://example.org/")
109
+
110
+ RecordRequests.requests.count.should == 2
111
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
112
+ exceptions.count.should == 1
113
+ exceptions.first.class.should == ex
114
+ end
115
+ end
116
+
117
+ [Errno::EHOSTDOWN, Errno::ECONNRESET, Errno::ENETRESET].each do |ex|
118
+ it "should not retry requests that result in #{ex}" do
119
+ TestCall.errors = [ex]
120
+ lambda {
121
+ client.get("http://example.org/")
122
+ }.should raise_exception ex
123
+ RecordRequests.requests.count.should == 1
124
+ end
125
+ end
126
+
127
+ it "should retry if the connection times out more than once" do
128
+ TestCall.errors = [Errno::ETIMEDOUT, Errno::ETIMEDOUT]
129
+ client.get("http://example.org/")
130
+
131
+ RecordRequests.requests.count.should == 3
132
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
133
+ exceptions.count.should == 2
134
+ exceptions.first.class.should == Errno::ETIMEDOUT
135
+
136
+ RecordRequests.responses.count.should == 3
137
+ RecordRequests.responses.last[0].should == 200
138
+ end
139
+
140
+ it "should raise RetryLimitExceeded when the connection times out too many times" do
141
+ retry_limit = Rack::Idempotent::DEFAULT_RETRY_LIMIT
142
+ TestCall.errors = (retry_limit + 1).times.map {|i| Errno::ETIMEDOUT}
143
+ lambda {
144
+ client.get("http://example.org/")
145
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
146
+ RecordRequests.requests.count.should == retry_limit
147
+ RecordRequests.responses.count.should == retry_limit
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,164 @@
1
+ require File.expand_path('../../spec_helper', __FILE__)
2
+
3
+ describe Rack::Idempotent do
4
+ class SleepMonitor
5
+ class << self
6
+ attr_accessor :sleeps
7
+ end
8
+ def self.delay(secs)
9
+ self.sleeps << secs
10
+ end
11
+ def self.reset
12
+ self.sleeps = []
13
+ end
14
+ end
15
+
16
+ before(:each) do
17
+ TestCall.errors = []
18
+ RecordRequests.reset
19
+ SleepMonitor.reset
20
+ end
21
+
22
+ class Rack::Idempotent::ExponentialBackoff
23
+ def delay(secs)
24
+ SleepMonitor.delay(secs)
25
+ end
26
+ end
27
+
28
+ configurations = {
29
+ 'defaults' => {},
30
+ 'custom max_retries' => {:max_retries => 50},
31
+ 'custom min_retry_interval' => {:min_retry_interval => 0.1},
32
+ 'custom max_retry_interval' => {:max_retry_interval => 86400},
33
+ }
34
+
35
+ configurations.each_pair do |name,opts|
36
+ describe "using Rack::Idempotent::ExponentialBackoff with #{name}" do
37
+ let(:client) do
38
+ Rack::Client.new do
39
+ use Rack::Lint
40
+ use Rack::Idempotent, {
41
+ :retry => Rack::Idempotent::ExponentialBackoff.new(opts)
42
+ }
43
+ use Rack::Lint
44
+ use RecordRequests
45
+ run TestCall
46
+ end
47
+ end
48
+ it "should not retry if succesful response" do
49
+ client.get("http://example.org/")
50
+ RecordRequests.requests.count.should == 1
51
+ end
52
+
53
+ it "should retry if it gets one unsuccesful response" do
54
+ TestCall.errors = [503]
55
+ client.get("http://example.org/")
56
+
57
+ RecordRequests.requests.count.should == 2
58
+
59
+ RecordRequests.responses.count.should == 2
60
+ RecordRequests.responses[0][0].should == 503
61
+ RecordRequests.responses[1][0].should == 200
62
+ end
63
+
64
+ it "should retry if it gets more than one unsuccesful response" do
65
+ TestCall.errors = [503, 504]
66
+ client.get("http://example.org/")
67
+
68
+ RecordRequests.requests.count.should == 3
69
+
70
+ RecordRequests.responses.count.should == 3
71
+ RecordRequests.responses[0][0].should == 503
72
+ RecordRequests.responses[1][0].should == 504
73
+ RecordRequests.responses[2][0].should == 200
74
+ end
75
+
76
+ it "should raise RetryLimitExceeded when the request fails too many times" do
77
+ retry_limit = rack_idempotent(client).retry_policy.max_retries
78
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
79
+ lambda {
80
+ client.get("http://example.org/")
81
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
82
+ RecordRequests.requests.count.should == retry_limit
83
+ RecordRequests.responses.count.should == retry_limit
84
+ end
85
+
86
+ it 'should sleep between retries' do
87
+ TestCall.errors = [503]
88
+ client.get("http://example.org/")
89
+ SleepMonitor.sleeps.count.should == 1
90
+ end
91
+
92
+ it 'should sleep for increasingly longer times' do
93
+ retry_limit = rack_idempotent(client).retry_policy.max_retries
94
+ max_retry_interval = rack_idempotent(client).retry_policy.max_retry_interval
95
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
96
+ begin
97
+ client.get("http://example.org/")
98
+ rescue Rack::Idempotent::RetryLimitExceeded
99
+ # Ignore, this should be thrown
100
+ ensure
101
+ SleepMonitor.sleeps.count.should == retry_limit - 1
102
+ SleepMonitor.sleeps.each_index do |i|
103
+ if i > 0
104
+ expected = SleepMonitor.sleeps[i - 1]
105
+ else
106
+ expected = 0
107
+ end
108
+ if expected < max_retry_interval
109
+ SleepMonitor.sleeps[i].should > expected
110
+ else
111
+ SleepMonitor.sleeps[i].should == max_retry_interval
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ it 'should always use min_retry_interval as the first sleep' do
118
+ retry_limit = rack_idempotent(client).retry_policy.max_retries
119
+ min_retry_interval = rack_idempotent(client).retry_policy.min_retry_interval
120
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
121
+ begin
122
+ client.get("http://example.org/")
123
+ rescue Rack::Idempotent::RetryLimitExceeded
124
+ # Ignore, this should be thrown
125
+ ensure
126
+ SleepMonitor.sleeps.count.should == retry_limit - 1
127
+ SleepMonitor.sleeps.first.should == min_retry_interval
128
+ end
129
+ end
130
+
131
+ it 'should never sleep shorter than min_retry_interval' do
132
+ retry_limit = rack_idempotent(client).retry_policy.max_retries
133
+ min_retry_interval = rack_idempotent(client).retry_policy.min_retry_interval
134
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
135
+ begin
136
+ client.get("http://example.org/")
137
+ rescue Rack::Idempotent::RetryLimitExceeded
138
+ # Ignore, this should be thrown
139
+ ensure
140
+ SleepMonitor.sleeps.count.should == retry_limit - 1
141
+ SleepMonitor.sleeps.each do |sleep|
142
+ sleep.should >= min_retry_interval
143
+ end
144
+ end
145
+ end
146
+
147
+ it 'should never sleep longer than max_retry_interval' do
148
+ retry_limit = rack_idempotent(client).retry_policy.max_retries
149
+ max_retry_interval = rack_idempotent(client).retry_policy.max_retry_interval
150
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
151
+ begin
152
+ client.get("http://example.org/")
153
+ rescue Rack::Idempotent::RetryLimitExceeded
154
+ # Ignore, this should be thrown
155
+ ensure
156
+ SleepMonitor.sleeps.count.should == retry_limit - 1
157
+ SleepMonitor.sleeps.each do |sleep|
158
+ sleep.should <= max_retry_interval
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,67 @@
1
+ require File.expand_path('../../spec_helper', __FILE__)
2
+
3
+ describe Rack::Idempotent do
4
+ before(:each) do
5
+ TestCall.errors = []
6
+ RecordRequests.reset
7
+ end
8
+
9
+ configurations = {
10
+ "defaults" => {},
11
+ "custom max_retries" => {:max_retries => 50},
12
+ }
13
+
14
+ configurations.each_pair do |name,opts|
15
+ describe "using Rack::Idempotent::ImmediateRetry with #{name}" do
16
+ let(:client) do
17
+ Rack::Client.new do
18
+ use Rack::Lint
19
+ use Rack::Idempotent, {
20
+ :retry => Rack::Idempotent::ImmediateRetry.new(opts)
21
+ }
22
+ use Rack::Lint
23
+ use RecordRequests
24
+ run TestCall
25
+ end
26
+ end
27
+
28
+ it "should not retry if succesful response" do
29
+ client.get("http://example.org/")
30
+ RecordRequests.requests.count.should == 1
31
+ end
32
+
33
+ it "should retry if it gets one unsuccesful response" do
34
+ TestCall.errors = [503]
35
+ client.get("http://example.org/")
36
+
37
+ RecordRequests.requests.count.should == 2
38
+
39
+ RecordRequests.responses.count.should == 2
40
+ RecordRequests.responses[0][0].should == 503
41
+ RecordRequests.responses[1][0].should == 200
42
+ end
43
+
44
+ it "should retry if it gets more than one unsuccesful response" do
45
+ TestCall.errors = [503, 504]
46
+ client.get("http://example.org/")
47
+
48
+ RecordRequests.requests.count.should == 3
49
+
50
+ RecordRequests.responses.count.should == 3
51
+ RecordRequests.responses[0][0].should == 503
52
+ RecordRequests.responses[1][0].should == 504
53
+ RecordRequests.responses[2][0].should == 200
54
+ end
55
+
56
+ it "should raise RetryLimitExceeded when the request fails too many times" do
57
+ retry_limit = rack_idempotent(client).retry_policy.max_retries
58
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
59
+ lambda {
60
+ client.get("http://example.org/")
61
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
62
+ RecordRequests.requests.count.should == retry_limit
63
+ RecordRequests.responses.count.should == retry_limit
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,120 +1,217 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Rack::Idempotent do
4
- class CaptureEnv
5
- class << self; attr_accessor :env; end
6
- def initialize(app); @app=app; end
7
- def call(env)
8
- @app.call(env)
9
- ensure
10
- self.class.env = env
11
- end
12
- end
13
- class RaiseUp
14
- class << self; attr_accessor :errors; end
15
- def self.call(env)
16
- error = self.errors.shift
17
- raise error if error.is_a?(Class)
18
- status_code = error || 200
19
- [status_code, {}, []]
20
- end
4
+ before(:each) do
5
+ TestCall.errors = []
6
+ RecordRequests.reset
21
7
  end
22
-
23
- before(:each){ CaptureEnv.env = nil }
24
8
  let(:client) do
25
9
  Rack::Client.new do
26
- use CaptureEnv
10
+ use Rack::Lint
27
11
  use Rack::Idempotent
28
- run RaiseUp
12
+ use Rack::Lint
13
+ use RecordRequests
14
+ run TestCall
29
15
  end
30
16
  end
31
17
 
32
- it "should retry Errno::ETIMEDOUT" do
33
- RaiseUp.errors = [Errno::ETIMEDOUT, Errno::ETIMEDOUT]
34
- client.get("/doesntmatter")
35
-
36
- env = CaptureEnv.env
37
- env['client.retries'].should == 2
38
- end
18
+ describe "with defaults" do
19
+ it "should not retry if succesful response" do
20
+ client.get("http://example.org/")
21
+ RecordRequests.requests.count.should == 1
22
+ end
39
23
 
40
- it "should retry Rack::Idempotent::Retryable" do
41
- RaiseUp.errors = [Rack::Idempotent::Retryable, Rack::Idempotent::Retryable]
42
- client.get("/alsodoesntmatter")
24
+ it "should retry if it gets one unsuccesful response" do
25
+ TestCall.errors = [503]
26
+ client.get("http://example.org/")
43
27
 
44
- env = CaptureEnv.env
45
- env['client.retries'].should == 2
46
- end
28
+ RecordRequests.requests.count.should == 2
47
29
 
48
- it "should raise Rack::Idempotent::RetryLimitExceeded when retry limit is reached" do
49
- RaiseUp.errors = (Rack::Idempotent::RETRY_LIMIT + 1).times.map{|i| Errno::ETIMEDOUT}
30
+ RecordRequests.responses.count.should == 2
31
+ RecordRequests.responses[0][0].should == 503
32
+ RecordRequests.responses[1][0].should == 200
33
+ end
50
34
 
51
- lambda { client.get("/doesntmatter") }.should raise_exception(Rack::Idempotent::RetryLimitExceeded)
35
+ it "should retry if it gets more than one unsuccesful response" do
36
+ TestCall.errors = [503, 504]
37
+ client.get("http://example.org/")
52
38
 
53
- env = CaptureEnv.env
54
- env['client.retries'].should == Rack::Idempotent::RETRY_LIMIT
55
- end
39
+ RecordRequests.requests.count.should == 3
56
40
 
57
- [502, 503, 504, 408].each do |code|
58
- it "retries GET #{code}" do
59
- RaiseUp.errors = [code]
60
- client.get("/something")
61
- env = CaptureEnv.env
62
- env['client.retries'].should == 1
41
+ RecordRequests.responses.count.should == 3
42
+ RecordRequests.responses[0][0].should == 503
43
+ RecordRequests.responses[1][0].should == 504
44
+ RecordRequests.responses[2][0].should == 200
63
45
  end
64
- end
65
46
 
66
- [502, 503, 504].each do |code|
67
- it "retries POST #{code}" do
68
- RaiseUp.errors = [code]
69
- client.post("/something")
70
- env = CaptureEnv.env
71
- env['client.retries'].should == 1
47
+ it "should raise RetryLimitExceeded when the request fails too many times" do
48
+ retry_limit = Rack::Idempotent::DEFAULT_RETRY_LIMIT
49
+ TestCall.errors = (retry_limit + 1).times.map {|i| 503}
50
+ lambda {
51
+ client.get("http://example.org/")
52
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
53
+ RecordRequests.requests.count.should == retry_limit
54
+ RecordRequests.responses.count.should == retry_limit
72
55
  end
73
- end
74
56
 
75
- it "doesn't retry POST when return code is 408" do
76
- RaiseUp.errors = [408]
77
- lambda do
78
- client.post("/something")
79
- end.should raise_error(Rack::Idempotent::HTTPException)
80
- env = CaptureEnv.env
81
- env['client.retries'].should == 0
82
- end
57
+ it "should retry if the connection times out once" do
58
+ TestCall.errors = [Errno::ETIMEDOUT]
59
+ client.get("http://example.org/")
83
60
 
84
- it "should be able to rescue http exception via standard error" do
85
- RaiseUp.errors = [408]
61
+ RecordRequests.requests.count.should == 2
62
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
63
+ exceptions.count.should == 1
64
+ exceptions.first.class.should == Errno::ETIMEDOUT
86
65
 
87
- begin
88
- client.post("/something")
89
- rescue => e
90
- # works
66
+ RecordRequests.responses.count.should == 2
67
+ RecordRequests.responses.last[0].should == 200
91
68
  end
92
- end
93
69
 
94
- it "should store exceptions raised" do
95
- RaiseUp.errors = [502, Errno::ECONNREFUSED, 408, 504, Errno::EHOSTUNREACH, Errno::ETIMEDOUT]
96
- errors = RaiseUp.errors.dup
97
- exception = nil
70
+ it "should retry if the connection times out more than once" do
71
+ TestCall.errors = [Errno::ETIMEDOUT, Errno::ETIMEDOUT]
72
+ client.get("http://example.org/")
73
+
74
+ RecordRequests.requests.count.should == 3
75
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
76
+ exceptions.count.should == 2
77
+ exceptions.first.class.should == Errno::ETIMEDOUT
98
78
 
99
- begin
100
- client.get("/doesntmatter")
101
- rescue Rack::Idempotent::RetryLimitExceeded => e
102
- exception = e
79
+ RecordRequests.responses.count.should == 3
80
+ RecordRequests.responses.last[0].should == 200
103
81
  end
104
82
 
105
- exception.should_not be_nil
106
- exception.idempotent_exceptions.size.should == 6
107
- exception.idempotent_exceptions.map{|ie| ie.is_a?(Rack::Idempotent::HTTPException) ? ie.status : ie.class}.should == errors
108
- end
83
+ it "should raise RetryLimitExceeded when the connection times out too many times" do
84
+ retry_limit = Rack::Idempotent::DEFAULT_RETRY_LIMIT
85
+ TestCall.errors = (retry_limit + 1).times.map {|i| Errno::ETIMEDOUT}
86
+ lambda {
87
+ client.get("http://example.org/")
88
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
89
+ RecordRequests.requests.count.should == retry_limit
90
+ RecordRequests.responses.count.should == retry_limit
91
+ end
109
92
 
110
- it "should be able to rescue retry limit exceeded via standard error" do
111
- RaiseUp.errors = (0...Rack::Idempotent::RETRY_LIMIT.succ).map{|_| 503 }
93
+ describe "does what the README says it does and" do
94
+ it 'has a retry limit of 5' do
95
+ Rack::Idempotent::DEFAULT_RETRY_LIMIT.should == 5
96
+ end
97
+
98
+ [Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH].each do |e|
99
+ it "retries on #{e}" do
100
+ TestCall.errors = [e]
101
+ client.get("http://example.org/")
102
+
103
+ RecordRequests.requests.count.should == 2
104
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
105
+ exceptions.count.should == 1
106
+ exceptions.first.class.should == e
107
+
108
+ RecordRequests.responses.count.should == 2
109
+ RecordRequests.responses.last[0].should == 200
110
+ end
111
+ end
112
+
113
+ [408, 502, 503, 504].each do |status|
114
+ it "retries on #{status}" do
115
+ TestCall.errors = [status]
116
+ client.get("http://example.org/")
117
+
118
+ RecordRequests.requests.count.should == 2
119
+
120
+ RecordRequests.responses.count.should == 2
121
+ RecordRequests.responses[0][0].should == status
122
+ RecordRequests.responses[1][0].should == 200
123
+ end
124
+ end
125
+
126
+ it 'raises RetryLimitExceeded if the retry limit is exceeded' do
127
+ retry_limit = Rack::Idempotent::DEFAULT_RETRY_LIMIT
128
+ TestCall.errors = (retry_limit + 1).times.map {|i| 408}
129
+ lambda {
130
+ client.get("http://example.org/")
131
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
132
+ RecordRequests.requests.count.should == retry_limit
133
+ RecordRequests.responses.count.should == retry_limit
134
+ end
135
+
136
+ it 'stores any exceptions raised in RetryLimitExceeded.idempotent_exceptions' do
137
+ retry_limit = Rack::Idempotent::DEFAULT_RETRY_LIMIT
138
+ TestCall.errors = (retry_limit + 1).times.map {|i| Errno::ETIMEDOUT}
139
+ lambda {
140
+ begin
141
+ client.get("http://example.org/")
142
+ rescue Rack::Idempotent::RetryLimitExceeded => e
143
+ e.idempotent_exceptions.should_not be_nil
144
+ exceptions = e.idempotent_exceptions
145
+ exceptions.count.should == retry_limit
146
+ exceptions.each do |ex|
147
+ ex.class.should == Errno::ETIMEDOUT
148
+ end
149
+ raise
150
+ end
151
+ }.should raise_exception Rack::Idempotent::RetryLimitExceeded
152
+
153
+ RecordRequests.requests.count.should == retry_limit
154
+ RecordRequests.responses.count.should == retry_limit
155
+ end
156
+ end
112
157
 
113
- begin
114
- res = client.get("/doesntmatter")
115
- rescue => e
116
- # works
158
+ describe "does what v0.0.3 does and" do
159
+ [502, 503, 504].each do |status|
160
+ it "retries POST requests if the status is #{status}" do
161
+ TestCall.errors = [status]
162
+ client.post("http://example.org/")
163
+
164
+ RecordRequests.requests.count.should == 2
165
+
166
+ RecordRequests.responses.count.should == 2
167
+ RecordRequests.responses[0][0].should == status
168
+ RecordRequests.responses[1][0].should == 200
169
+ end
170
+ end
171
+
172
+ it 'does not retry a POST if the status is 408' do
173
+ TestCall.errors = [408]
174
+ lambda {
175
+ client.post("http://example.org/")
176
+ }.should raise_exception Rack::Idempotent::HTTPException
177
+
178
+ RecordRequests.requests.count.should == 1
179
+
180
+ RecordRequests.responses.count.should == 1
181
+ RecordRequests.responses[0][0].should == 408
182
+ end
183
+
184
+ it 'retries if a Rack::Idempotent::Retryable exception is thrown' do
185
+ TestCall.errors = [Rack::Idempotent::Retryable]
186
+ client.get("http://example.org/")
187
+
188
+ RecordRequests.requests.count.should == 2
189
+ exceptions = RecordRequests.requests.last["idempotent.requests.exceptions"]
190
+ exceptions.count.should == 1
191
+ exceptions.first.class.should == Rack::Idempotent::Retryable
192
+
193
+ RecordRequests.responses.count.should == 2
194
+ RecordRequests.responses.last[0].should == 200
195
+ end
196
+
197
+ it 'is able to rescue http exception via standard error' do
198
+ TestCall.errors = [400]
199
+ begin
200
+ client.post("http://example.org/")
201
+ rescue => e
202
+ e.class.should == Rack::Idempotent::HTTPException
203
+ end
204
+ end
205
+
206
+ it 'is able to rescue retry limit exceeded via standard error' do
207
+ retry_limit = Rack::Idempotent::DEFAULT_RETRY_LIMIT
208
+ TestCall.errors = (retry_limit + 1).times.map {|i| Errno::ETIMEDOUT}
209
+ begin
210
+ client.post("http://example.org/")
211
+ rescue => e
212
+ e.class.should == Rack::Idempotent::RetryLimitExceeded
213
+ end
214
+ end
117
215
  end
118
216
  end
119
-
120
217
  end
@@ -1,3 +1,51 @@
1
+ require File.expand_path("../../lib/rack-idempotent", __FILE__)
2
+
1
3
  Bundler.require(:test)
2
4
 
3
- require File.expand_path("../../lib/rack-idempotent", __FILE__)
5
+ class RecordRequests
6
+ class << self
7
+ attr_accessor :requests
8
+ attr_accessor :responses
9
+
10
+ def reset
11
+ self.requests = []
12
+ self.responses = []
13
+ end
14
+ end
15
+
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ def call(env)
21
+ response = @app.call(env)
22
+ ensure
23
+ self.class.requests << env
24
+ self.class.responses << response
25
+ end
26
+ end
27
+
28
+ class TestCall
29
+ class << self
30
+ attr_accessor :errors
31
+ end
32
+
33
+ def self.call(env)
34
+ error = nil
35
+ if self.errors
36
+ error = self.errors.shift
37
+ raise error if error.is_a?(Class)
38
+ end
39
+ status_code = error || 200
40
+ [status_code, {"Content-Type" => "text/plain"}, []]
41
+ end
42
+ end
43
+
44
+ def rack_idempotent(client)
45
+ while true
46
+ if client.class == Rack::Idempotent
47
+ return client
48
+ end
49
+ client = client.instance_eval { @app }
50
+ end
51
+ end
metadata CHANGED
@@ -1,77 +1,71 @@
1
- --- !ruby/object:Gem::Specification
1
+ --- !ruby/object:Gem::Specification
2
2
  name: rack-idempotent
3
- version: !ruby/object:Gem::Version
4
- hash: 25
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
5
  prerelease:
6
- segments:
7
- - 0
8
- - 0
9
- - 3
10
- version: 0.0.3
11
6
  platform: ruby
12
- authors:
13
- - Ines Sombra
7
+ authors:
8
+ - Engine Yard
14
9
  autorequire:
15
10
  bindir: bin
16
11
  cert_chain: []
17
-
18
- date: 2012-02-09 00:00:00 Z
12
+ date: 2013-02-21 00:00:00.000000000 Z
19
13
  dependencies: []
20
-
21
- description: Retry logic for rack-client
22
- email:
23
- - isombra@engineyard.com
14
+ description: Idempotent Rack middleware
15
+ email:
16
+ - engineering@engineyard.com
24
17
  executables: []
25
-
26
18
  extensions: []
27
-
28
19
  extra_rdoc_files: []
29
-
30
- files:
20
+ files:
31
21
  - .gitignore
32
22
  - .travis.yml
33
23
  - Gemfile
24
+ - Guardfile
34
25
  - LICENSE
35
26
  - README.md
36
27
  - Rakefile
37
28
  - lib/rack-idempotent.rb
29
+ - lib/rack-idempotent/default_rescue.rb
30
+ - lib/rack-idempotent/exponential_backoff.rb
31
+ - lib/rack-idempotent/http_exception.rb
32
+ - lib/rack-idempotent/immediate_retry.rb
33
+ - lib/rack-idempotent/retry_limit_exceeded.rb
34
+ - lib/rack-idempotent/retryable.rb
38
35
  - lib/rack-idempotent/version.rb
39
36
  - rack-idempotent.gemspec
37
+ - spec/rack-idempotent/default_rescue_spec.rb
38
+ - spec/rack-idempotent/exponential_backoff_spec.rb
39
+ - spec/rack-idempotent/immediate_retry_spec.rb
40
40
  - spec/rack-idempotent_spec.rb
41
41
  - spec/spec_helper.rb
42
- homepage: ""
42
+ homepage: ''
43
43
  licenses: []
44
-
45
44
  post_install_message:
46
45
  rdoc_options: []
47
-
48
- require_paths:
46
+ require_paths:
49
47
  - lib
50
- required_ruby_version: !ruby/object:Gem::Requirement
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ! '>='
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
51
53
  none: false
52
- requirements:
53
- - - ">="
54
- - !ruby/object:Gem::Version
55
- hash: 3
56
- segments:
57
- - 0
58
- version: "0"
59
- required_rubygems_version: !ruby/object:Gem::Requirement
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ! '>='
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
60
59
  none: false
61
- requirements:
62
- - - ">="
63
- - !ruby/object:Gem::Version
64
- hash: 3
65
- segments:
66
- - 0
67
- version: "0"
68
60
  requirements: []
69
-
70
61
  rubyforge_project:
71
- rubygems_version: 1.8.10
62
+ rubygems_version: 1.8.25
72
63
  signing_key:
73
64
  specification_version: 3
74
- summary: Retry logic for rack-client
75
- test_files:
65
+ summary: Retry middleware for Rack clients
66
+ test_files:
67
+ - spec/rack-idempotent/default_rescue_spec.rb
68
+ - spec/rack-idempotent/exponential_backoff_spec.rb
69
+ - spec/rack-idempotent/immediate_retry_spec.rb
76
70
  - spec/rack-idempotent_spec.rb
77
71
  - spec/spec_helper.rb