px-service-client 1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/.rspec +2 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/Gemfile +4 -0
  7. data/Guardfile +19 -0
  8. data/LICENSE.txt +22 -0
  9. data/README.md +140 -0
  10. data/Rakefile +9 -0
  11. data/lib/px/service/client/base.rb +41 -0
  12. data/lib/px/service/client/caching/cache_entry.rb +95 -0
  13. data/lib/px/service/client/caching/log_subscriber.rb +23 -0
  14. data/lib/px/service/client/caching/railtie.rb +11 -0
  15. data/lib/px/service/client/caching.rb +112 -0
  16. data/lib/px/service/client/circuit_breaker.rb +47 -0
  17. data/lib/px/service/client/future.rb +91 -0
  18. data/lib/px/service/client/list_response.rb +80 -0
  19. data/lib/px/service/client/multiplexer.rb +34 -0
  20. data/lib/px/service/client/retriable_response_future.rb +98 -0
  21. data/lib/px/service/client/version.rb +7 -0
  22. data/lib/px/service/client.rb +19 -0
  23. data/lib/px/service/errors.rb +28 -0
  24. data/lib/px-service-client.rb +1 -0
  25. data/px-service-client.gemspec +35 -0
  26. data/spec/px/service/client/base_spec.rb +49 -0
  27. data/spec/px/service/client/caching/caching_spec.rb +209 -0
  28. data/spec/px/service/client/circuit_breaker_spec.rb +113 -0
  29. data/spec/px/service/client/future_spec.rb +182 -0
  30. data/spec/px/service/client/list_response_spec.rb +118 -0
  31. data/spec/px/service/client/multiplexer_spec.rb +63 -0
  32. data/spec/px/service/client/retriable_response_future_spec.rb +99 -0
  33. data/spec/spec_helper.rb +25 -0
  34. data/spec/vcr/Px_Service_Client_Multiplexer/with_multiple_requests/when_the_requests_depend_on_each_other/runs_the_requests.yml +91 -0
  35. data/spec/vcr/Px_Service_Client_Multiplexer/with_multiple_requests/when_the_requests_don_t_depend_on_each_other/runs_the_requests.yml +91 -0
  36. data/spec/vcr/Px_Service_Client_Multiplexer/with_one_request/returns_a_ResponseFuture.yml +47 -0
  37. data/spec/vcr/Px_Service_Client_Multiplexer/with_one_request/runs_the_requests.yml +47 -0
  38. metadata +288 -0
@@ -0,0 +1,80 @@
1
+ require 'will_paginate/collection'
2
+
3
+ module Px::Service::Client
4
+ ##
5
+ # This class implements the methods necessary to be compatible with WillPaginate and Enumerable
6
+ class ListResponse
7
+ include WillPaginate::CollectionMethods
8
+ include Enumerable
9
+
10
+ attr_reader :response, :per_page
11
+
12
+ DEFAULT_PER_PAGE = 20
13
+
14
+ def initialize(page_size, response, results_key, options = {})
15
+ @response = response
16
+ @results_key = results_key
17
+ @options = options
18
+ @per_page = page_size || DEFAULT_PER_PAGE
19
+ end
20
+
21
+ ##
22
+ # Get the current page
23
+ def current_page
24
+ response["current_page"]
25
+ end
26
+
27
+ def offset
28
+ (current_page - 1) * per_page
29
+ end
30
+
31
+ def total_entries
32
+ response["total_items"]
33
+ end
34
+ alias_method :total, :total_entries
35
+
36
+ def total_pages
37
+ response["total_pages"]
38
+ end
39
+
40
+ def results
41
+ response[@results_key]
42
+ end
43
+
44
+ def raw_results
45
+ response[@results_key]
46
+ end
47
+
48
+ ##
49
+ # Support Enumerable
50
+ def each(&block)
51
+ results.each(&block)
52
+ end
53
+
54
+ ##
55
+ # Allow comparisons with arrays e.g. in Rspec to succeed
56
+ def ==(other)
57
+ if other.class == self.class
58
+ other.results == self.results
59
+ elsif other.class <= Array
60
+ other == self.results
61
+ else
62
+ false
63
+ end
64
+ end
65
+ alias_method :eql?, :==
66
+
67
+ def empty?
68
+ results.empty?
69
+ end
70
+
71
+ def method_missing(method_name, *arguments, &block)
72
+ results.send(method_name, *arguments, &block)
73
+ end
74
+
75
+ def respond_to_missing?(method_name, include_private = false)
76
+ results.respond_to?(method_name, include_private)
77
+ end
78
+
79
+ end
80
+ end
@@ -0,0 +1,34 @@
1
+ module Px::Service::Client
2
+ class Multiplexer
3
+ attr_accessor :hydra
4
+ attr_accessor :states
5
+
6
+ def initialize(params = {})
7
+ self.hydra = Typhoeus::Hydra.new(params)
8
+ end
9
+
10
+ def context
11
+ Fiber.new{ yield }.resume
12
+ self
13
+ end
14
+
15
+ ##
16
+ # Queue a request on the multiplexer, with retry
17
+ def do(request_or_future, retries: RetriableResponseFuture::DEFAULT_RETRIES)
18
+ response = request_or_future
19
+ if request_or_future.is_a?(Typhoeus::Request)
20
+ response = RetriableResponseFuture(request_or_future, retries: retries)
21
+ end
22
+
23
+ # Will automatically queue the request on the hydra
24
+ response.hydra = hydra
25
+ response
26
+ end
27
+
28
+ ##
29
+ # Start the multiplexer.
30
+ def run
31
+ hydra.run
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,98 @@
1
+ # This is based on this code: https://github.com/bitherder/stitch
2
+
3
+ require 'fiber'
4
+
5
+ module Px::Service::Client
6
+ class RetriableResponseFuture < Future
7
+ DEFAULT_RETRIES = 3
8
+
9
+ attr_reader :hydra, :request
10
+
11
+ def initialize(request = nil, retries: DEFAULT_RETRIES)
12
+ super()
13
+
14
+ @retries = retries
15
+ self.request = request if request
16
+ end
17
+
18
+ def request=(request)
19
+ raise ArgumentError.new("A request has already been assigned") if @request
20
+
21
+ @request = request
22
+ self.request.on_complete do |response|
23
+ result = handle_error_statuses(response)
24
+ complete(result)
25
+ end
26
+
27
+ configure_auto_retry(request, @retries)
28
+
29
+ hydra.queue(request) if hydra
30
+ end
31
+
32
+ def hydra=(hydra)
33
+ raise ArgumentError.new("A hydra has already been assigned") if @hydra
34
+
35
+ @hydra = hydra
36
+ hydra.queue(request) if request
37
+ end
38
+
39
+ private
40
+
41
+ ##
42
+ # Raise appropriate exception on error statuses
43
+ def handle_error_statuses(response)
44
+ return response if response.success?
45
+
46
+ begin
47
+ body = parse_error_body(response)
48
+
49
+ if response.response_code >= 400 && response.response_code < 499
50
+ raise Px::Service::ServiceRequestError.new(body, response.response_code)
51
+ elsif response.response_code >= 500 || response.response_code == 0
52
+ raise Px::Service::ServiceError.new(body, response.response_code)
53
+ end
54
+ rescue Exception => ex
55
+ return ex
56
+ end
57
+ end
58
+
59
+ def parse_error_body(response)
60
+ if response.headers && response.headers["Content-Type"] =~ %r{application/json}
61
+ JSON.parse(response.body)["error"] rescue response.body.try(:strip)
62
+ else
63
+ response.body.strip
64
+ end
65
+ end
66
+
67
+
68
+ ##
69
+ # Configures auto-retry on the request
70
+ def configure_auto_retry(request, retries)
71
+ return if retries.nil? || retries == 0
72
+ # To do this, we have to hijack the Typhoeus callback list, as there's
73
+ # no way to prevent later callbacks from being executed from earlier callbacks
74
+ old_on_complete = request.on_complete.dup
75
+ request.on_complete.clear
76
+ retries_left = retries
77
+
78
+ request.on_complete do |response|
79
+ if !self.completed?
80
+ # Don't retry on success, client error, or after exhausting our retry count
81
+ if response.success? ||
82
+ response.response_code >= 400 && response.response_code <= 499 ||
83
+ retries_left <= 0
84
+
85
+ # Call the old callbacks
86
+ old_on_complete.map do |callback|
87
+ response.handled_response = callback.call(response)
88
+ end
89
+ else
90
+ # Retry
91
+ retries_left -= 1
92
+ hydra.queue(response.request)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ module Px
2
+ module Service
3
+ module Client
4
+ VERSION = "1.0.1"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'px/service/errors'
4
+
5
+ module Px
6
+ module Service
7
+ module Client
8
+ end
9
+ end
10
+ end
11
+
12
+ require "px/service/client/version"
13
+ require "px/service/client/future"
14
+ require "px/service/client/caching"
15
+ require "px/service/client/circuit_breaker"
16
+ require "px/service/client/list_response"
17
+ require "px/service/client/base"
18
+ require "px/service/client/multiplexer"
19
+ require "px/service/client/retriable_response_future"
@@ -0,0 +1,28 @@
1
+ module Px
2
+ module Service
3
+ ##
4
+ # Any external service should have its exceptions inherit from this class
5
+ # so that controllers can handle them all nicely with "service is down" pages or whatnot
6
+ class ServiceBaseError < StandardError
7
+ attr_accessor :status
8
+
9
+ def initialize(message, status)
10
+ self.status = status
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ ##
16
+ # Indicates something was wrong with the request (ie, not a service failure, but an error on the caller's
17
+ # part). Corresponds to HTTP status 4xx responses
18
+ class ServiceRequestError < ServiceBaseError
19
+ end
20
+
21
+ ##
22
+ # Indicates something went wrong during request processing (a service or network error occurred)
23
+ # Corresponds to HTTP status 5xx responses.
24
+ # Services should catch other network/transport errors and raise this exception instead.
25
+ class ServiceError < ServiceBaseError
26
+ end
27
+ end
28
+ end
@@ -0,0 +1 @@
1
+ require 'px/service/client'
@@ -0,0 +1,35 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'px/service/client/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "px-service-client"
8
+ spec.version = Px::Service::Client::VERSION
9
+ spec.summary = %q{Common service client behaviours for Ruby applications}
10
+ spec.authors = ["Chris Micacchi"]
11
+ spec.email = ["chris@500px.com"]
12
+ spec.homepage = ""
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency "will_paginate", "~> 3.0"
21
+ spec.add_dependency "dalli"
22
+ spec.add_dependency "typhoeus"
23
+ spec.add_dependency "activesupport"
24
+ spec.add_dependency "circuit_breaker", "~> 1.1"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.6"
27
+ spec.add_development_dependency "rake"
28
+ spec.add_development_dependency "webmock"
29
+ spec.add_development_dependency "pry-byebug"
30
+ spec.add_development_dependency "vcr"
31
+ spec.add_development_dependency "guard"
32
+ spec.add_development_dependency "guard-rspec"
33
+ spec.add_development_dependency "rspec", "~> 2.14"
34
+ spec.add_development_dependency "timecop", "~> 0.5"
35
+ end
@@ -0,0 +1,49 @@
1
+ require 'spec_helper'
2
+
3
+ describe Px::Service::Client::Base do
4
+ subject { Px::Service::Client::Base.send(:new) }
5
+ let(:response) do
6
+ Typhoeus::Response.new(
7
+ code: 200,
8
+ body: { status: 200, message: "Success"}.to_json,
9
+ headers: { "Content-Type" => "application/json"} )
10
+ end
11
+
12
+ describe '#make_request' do
13
+ let(:url) { 'http://localhost:3000/path' }
14
+
15
+ it "returns a future response" do
16
+ expect(subject.send(:make_request, 'get', url)).to be_a(Px::Service::Client::Future)
17
+ end
18
+
19
+ context "with a header" do
20
+ let(:expected_headers) {
21
+ {
22
+ "Cookie" => "_hpx1=cookie",
23
+ }
24
+ }
25
+
26
+ let(:resp) { subject.send(:make_request, 'get', url, headers: expected_headers) }
27
+ let(:headers) { resp.request.options[:headers] }
28
+
29
+ it "sets the expected header" do
30
+ expect(headers).to include(expected_headers)
31
+ end
32
+ end
33
+
34
+ context "with a query" do
35
+ let(:expected_query) {
36
+ {
37
+ "one" => "a",
38
+ "two" => "b",
39
+ }
40
+ }
41
+
42
+ let(:resp) { subject.send(:make_request, 'get', url, query: expected_query) }
43
+
44
+ it "sets the query" do
45
+ expect(resp.request.url).to include("one=a&two=b")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,209 @@
1
+ require 'spec_helper'
2
+ require 'dalli'
3
+
4
+ describe Px::Service::Client::Caching do
5
+ subject {
6
+ Class.new.include(Px::Service::Client::Caching).tap do |c|
7
+ # Anonymous classes don't have a name. Stub out :name so that things work
8
+ allow(c).to receive(:name).and_return("Caching")
9
+ end.new
10
+ }
11
+
12
+ let(:dalli_host) { "localhost:11211" }
13
+ let(:dalli_options) { { :namespace => "service-client-test", expires_in: 3600, compress: false, failover: false } }
14
+ let(:dalli) { Dalli::Client.new(dalli_host, dalli_options) }
15
+
16
+ before :each do
17
+ dalli.flush_all
18
+ subject.cache_client = dalli
19
+ end
20
+
21
+ let (:url) { "http://search/foo?bar=baz"}
22
+ let (:response) {
23
+ { "response" => ["foo", "bar"], "status" => 200 }
24
+ }
25
+
26
+ shared_examples_for "a successful request" do
27
+ it "should call the block" do
28
+ called = false
29
+ subject.cache_request(url, strategy: strategy) do |u|
30
+ expect(u).to eq(url)
31
+ called = true
32
+ end
33
+
34
+ expect(called).to be_truthy
35
+ end
36
+
37
+ it "should return the block's return value" do
38
+ expect(subject.cache_request(url, strategy: strategy) { response }).to eq(response)
39
+ end
40
+ end
41
+
42
+ shared_examples_for "a failed uncacheable request" do
43
+ it "should raise the exception raised by the block" do
44
+ expect{
45
+ subject.cache_request(url, strategy: strategy) do
46
+ # Px::Service::ServiceRequestError is not cachable
47
+ # and does not trigger a fallback to a cached response
48
+ raise Px::Service::ServiceRequestError.new("Error", 404)
49
+ end
50
+ }.to raise_error(Px::Service::ServiceRequestError)
51
+ end
52
+ end
53
+
54
+ shared_examples_for "a request with no cached response" do
55
+ it "raises the exception" do
56
+ expect {
57
+ subject.cache_request(url, strategy: strategy) do
58
+ raise Px::Service::ServiceError.new("Error", 500)
59
+ end
60
+ }.to raise_error(Px::Service::ServiceError)
61
+ end
62
+ end
63
+
64
+ context "when not caching" do
65
+ let(:strategy) { :none }
66
+
67
+ it_behaves_like "a successful request"
68
+ it_behaves_like "a failed uncacheable request"
69
+ end
70
+
71
+ context "when caching as a last resort" do
72
+ let(:strategy) { :last_resort }
73
+
74
+ it_behaves_like "a successful request"
75
+ it_behaves_like "a failed uncacheable request"
76
+
77
+ context "when there is a cached response" do
78
+ before :each do
79
+ subject.cache_request(url, strategy: strategy) do
80
+ response
81
+ end
82
+ end
83
+
84
+ it "returns the cached response on failure" do
85
+ expect(subject.cache_request(url, strategy: strategy) do
86
+ raise Px::Service::ServiceError.new("Error", 500)
87
+ end).to eq(response)
88
+ end
89
+
90
+ it "does not returns the cached response on request error" do
91
+ expect {
92
+ subject.cache_request(url, strategy: strategy) do
93
+ raise Px::Service::ServiceRequestError.new("Error", 404)
94
+ end
95
+ }.to raise_error(Px::Service::ServiceRequestError)
96
+ end
97
+
98
+ it "touches the cache entry on failure" do
99
+ expect(dalli).to receive(:touch).with(a_kind_of(String), a_kind_of(Fixnum))
100
+
101
+ subject.cache_request(url, strategy: strategy) do
102
+ raise Px::Service::ServiceError.new("Error", 500)
103
+ end
104
+ end
105
+ end
106
+
107
+ it_behaves_like "a request with no cached response"
108
+ end
109
+
110
+ context "when caching as a first resort" do
111
+ let(:strategy) { :first_resort }
112
+
113
+ it_behaves_like "a successful request"
114
+ it_behaves_like "a failed uncacheable request"
115
+
116
+ context "when there is a cached response" do
117
+ before :each do
118
+ subject.cache_request(url, strategy: strategy) do
119
+ response
120
+ end
121
+ end
122
+
123
+ it "does not invoke the block" do
124
+ called = false
125
+ subject.cache_request(url, strategy: strategy) do |u|
126
+ called = true
127
+ end
128
+
129
+ expect(called).to be_falsey
130
+ end
131
+
132
+ it "returns the response" do
133
+ expect(subject.cache_request(url, strategy: strategy) { nil }).to eq(response)
134
+ end
135
+ end
136
+
137
+ context "when there is an expired cached response" do
138
+ before :each do
139
+ Timecop.freeze(10.minutes.ago) do
140
+ subject.cache_request(url, strategy: strategy) do
141
+ response
142
+ end
143
+ end
144
+ end
145
+
146
+ let (:response) { { "value" => "response" } }
147
+
148
+ it "invokes the block" do
149
+ called = false
150
+ subject.cache_request(url, strategy: strategy) do |u|
151
+ called = true
152
+ end
153
+
154
+ expect(called).to be_truthy
155
+ end
156
+
157
+ it "returns the new response" do
158
+ expect(subject.cache_request(url, strategy: strategy) { response }).to eq(response)
159
+ end
160
+
161
+ it "updates the cache entry before making the request" do
162
+ subject.cache_request(url, strategy: strategy) do
163
+ # A bit goofy, but basically, make a request, but in the block
164
+ # check that another request that happens while we're in the block
165
+ # gets the cached result and doesn't invoke its own block
166
+ called = false
167
+ expect(subject.cache_request(url, strategy: strategy) do
168
+ called = true
169
+ end).to eq(response)
170
+ expect(called).to be_falsey
171
+
172
+ response
173
+ end
174
+ end
175
+
176
+ it "caches the new response" do
177
+ subject.cache_request(url, strategy: strategy) do
178
+ response
179
+ end
180
+
181
+ expect(subject.cache_request(url, strategy: strategy) { nil }).to eq(response)
182
+ end
183
+
184
+ it "returns the cached response on failure" do
185
+ expect(subject.cache_request(url, strategy: strategy) do
186
+ raise Px::Service::ServiceError.new("Error", 500)
187
+ end).to eq(response)
188
+ end
189
+
190
+ it "does not returns the cached response on request error" do
191
+ expect {
192
+ subject.cache_request(url, strategy: strategy) do
193
+ raise Px::Service::ServiceRequestError.new("Error", 404)
194
+ end
195
+ }.to raise_error(Px::Service::ServiceRequestError)
196
+ end
197
+
198
+ it "touches the cache entry on failure" do
199
+ expect(dalli).to receive(:touch).with(a_kind_of(String), a_kind_of(Fixnum)).twice
200
+
201
+ subject.cache_request(url, strategy: strategy) do
202
+ raise Px::Service::ServiceError.new("Error", 500)
203
+ end
204
+ end
205
+ end
206
+
207
+ it_behaves_like "a request with no cached response"
208
+ end
209
+ end
@@ -0,0 +1,113 @@
1
+ require 'spec_helper'
2
+
3
+ describe Px::Service::Client::CircuitBreaker do
4
+
5
+ let(:subject_class) {
6
+ Class.new.include(Px::Service::Client::CircuitBreaker).tap do |c|
7
+ # Anonymous classes don't have a name. Stub out :name so that things work
8
+ allow(c).to receive(:name).and_return("CircuitBreaker")
9
+ end
10
+ }
11
+
12
+ subject { subject_class.new }
13
+
14
+ describe '#included' do
15
+ it "excludes Px::Service::ServiceRequestError by default" do
16
+ expect(subject_class.circuit_handler.excluded_exceptions).to include(Px::Service::ServiceRequestError)
17
+ end
18
+
19
+ it "sets the failure threshold" do
20
+ expect(subject_class.circuit_handler.failure_threshold).to eq(5)
21
+ end
22
+
23
+ it "sets the failure timeout" do
24
+ expect(subject_class.circuit_handler.failure_timeout).to eq(7)
25
+ end
26
+
27
+ it "sets the invocation timeout" do
28
+ expect(subject_class.circuit_handler.invocation_timeout).to eq(5)
29
+ end
30
+ end
31
+
32
+ describe '#circuit_method' do
33
+ context "when the wrapped method succeeds" do
34
+ before :each do
35
+ subject_class.send(:define_method, :test_method) do |arg|
36
+ "returned #{arg}"
37
+ end
38
+
39
+ subject_class.circuit_method(:test_method)
40
+ end
41
+
42
+ it "returns the return value" do
43
+ expect(subject.test_method("test")).to eq("returned test")
44
+ end
45
+ end
46
+
47
+ context "when the wrapped method fails with a ServiceRequestError" do
48
+ before :each do
49
+ subject_class.send(:define_method, :test_method) do |arg|
50
+ raise Px::Service::ServiceRequestError.new("Error", 404)
51
+ end
52
+
53
+ subject_class.circuit_method(:test_method)
54
+ end
55
+
56
+ it "raises a ServiceRequestError" do
57
+ expect{
58
+ subject.test_method("test")
59
+ }.to raise_error(Px::Service::ServiceRequestError, "Error")
60
+ end
61
+ end
62
+
63
+ context "when the wrapped method fails with a ServiceError" do
64
+ before :each do
65
+ subject_class.send(:define_method, :test_method) do |arg|
66
+ raise Px::Service::ServiceError.new("Error", 500)
67
+ end
68
+
69
+ subject_class.circuit_method(:test_method)
70
+ end
71
+
72
+ it "raises a ServiceError" do
73
+ expect{
74
+ subject.test_method("test")
75
+ }.to raise_error(Px::Service::ServiceError, "Error")
76
+ end
77
+ end
78
+
79
+ context "when the wrapped method fails with another exception" do
80
+ before :each do
81
+ subject_class.send(:define_method, :test_method) do |arg|
82
+ this_is_not_a_method # Raises NoMethodError
83
+ end
84
+
85
+ subject_class.circuit_method(:test_method)
86
+ end
87
+
88
+ it "raises a ServiceError" do
89
+ expect{
90
+ subject.test_method("test")
91
+ }.to raise_error(Px::Service::ServiceError)
92
+ end
93
+ end
94
+
95
+ context "when the circuit is open" do
96
+ before :each do
97
+ subject_class.send(:define_method, :test_method) do |arg|
98
+ "should not be called"
99
+ end
100
+
101
+ subject_class.circuit_method(:test_method)
102
+
103
+ subject.circuit_state.trip
104
+ end
105
+
106
+ it "raises a ServiceError" do
107
+ expect{
108
+ subject.test_method("test")
109
+ }.to raise_error(Px::Service::ServiceError)
110
+ end
111
+ end
112
+ end
113
+ end