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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: af7a7e8bbce817e251279c1a31fe9737e05d7d2d
4
+ data.tar.gz: deef44499ba41a91b75c2d1efbf4a55568a5fa62
5
+ SHA512:
6
+ metadata.gz: 7caf92bd8294aafe32fa12de6c5328d51960cee2c19c93ea42dd48a335778d13b83fa34b44e05a58a879f5720deb0954b0c6b5cfe6a84c1a8496752b46cfc60a
7
+ data.tar.gz: cc7781a37f252033c1e585d31e6eb125fc1c5bda0f15c7396a3de44ca767cfc91aff3fa6f2e280d48088220c3df459022464a5db30c6d98c4df0317ce7d4408e
data/.gitignore ADDED
@@ -0,0 +1,22 @@
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
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --colour
2
+ --format documentation
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ px-service-client
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.1.0
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in px-service-client.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,19 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ # Note: The cmd option is now required due to the increasing number of ways
5
+ # rspec may be run, below are examples of the most common uses.
6
+ # * bundler: 'bundle exec rspec'
7
+ # * bundler binstubs: 'bin/rspec'
8
+ # * spring: 'bin/rsspec' (This will use spring if running and you have
9
+ # installed the spring binstubs per the docs)
10
+ # * zeus: 'zeus rspec' (requires the server to be started separetly)
11
+ # * 'just' rspec: 'rspec'
12
+ guard :rspec, cmd: 'bundle exec rspec' do
13
+ watch(%r{^spec/.+_spec\.rb$})
14
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
15
+ watch('spec/spec_helper.rb') { "spec" }
16
+
17
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
18
+ end
19
+
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 500px
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.
data/README.md ADDED
@@ -0,0 +1,140 @@
1
+ px-service-client
2
+ =================
3
+
4
+ [![Build Status](https://semaphoreapp.com/api/v1/projects/3e3b69a9-7606-49d9-a9e1-acea22b026c4/277528/badge.png)](https://semaphoreapp.com/500px/ruby-service-client)
5
+
6
+ A set of modules to add common functionality to a Ruby service client
7
+
8
+ Usage
9
+ -----
10
+
11
+ ```
12
+ gem install px-service-client
13
+ ```
14
+
15
+ Or, with bundler
16
+
17
+ ```ruby
18
+ gem 'px-service-client'
19
+ ```
20
+
21
+ Then use it:
22
+
23
+ ```ruby
24
+ require 'px-service-client'
25
+
26
+ class MyClient
27
+ include Px::Service::Client::::Caching
28
+ include Px::Service::Client::::CircuitBreaker
29
+ end
30
+
31
+ ```
32
+
33
+ Features
34
+ --------
35
+
36
+ This gem includes several common features used in 500px service client libraries.
37
+
38
+ The features are:
39
+
40
+ #### Px::Service::Client::::Caching
41
+
42
+ ```ruby
43
+ include Px::Service::Client::::Caching
44
+
45
+ self.cache_client = Dalli::Client.new(...)
46
+ self.cache_logger = Logger.new(STDOUT) # or Rails.logger, for example
47
+ ```
48
+
49
+ Provides client-side response caching of service requests. Responses are cached in memcached (using the provided cache client) in either a *last-resort* or *first-resort* manner.
50
+
51
+ *last-resort* means that the cached value is only used when the service client request fails (with a
52
+ `ServiceError`). When using last-resort caching, a `refresh_probability` can be provided that causes the cached value
53
+ to be refreshed probabilistically (rather than on every request).
54
+
55
+ *first-resort* means that the cached value is always used, if present. Requests to the service are only made
56
+ when the cached value is close to expiry.
57
+
58
+ #### Px::Service::Client::::CircuitBreaker
59
+
60
+ ```ruby
61
+ def call_remote_service() ...
62
+
63
+ circuit_method :call_remote_service
64
+
65
+ # Optional
66
+ circuit_handler do |handler|
67
+ handler.logger = Logger.new(STDOUT)
68
+ handler.failure_threshold = 5
69
+ handler.failure_timeout = 5
70
+ handler.invocation_timeout = 10
71
+ handler.excluded_exceptions += [NotConsideredFailureException]
72
+ end
73
+ ```
74
+
75
+ Provides a circuit breaker on the class, and turns the class into a singleton. Each method named using
76
+ `circuit_method` will be wrapped in a circuit breaker that will raise a `Px::Service::ServiceError` if the breaker
77
+ is open. The circuit will open on any exception from the wrapped method, or if the wrapped method
78
+ runs for longer than the `invocation_timeout`.
79
+
80
+ Note that `Px::Service::ServiceRequestError` exceptions do NOT trip the breaker, as these exceptions indicate an error
81
+ on the caller's part (e.g. an HTTP 4xx error).
82
+
83
+ The class is made a singleton using the standard `Singleton` module. Access to the class's methods should be done
84
+ using its `instance` class method (calls to `new` will fail).
85
+
86
+ This module is based on (and uses) the [Circuit Breaker](https://github.com/wsargent/circuit_breaker) gem by Will Sargent.
87
+
88
+ #### Px::Service::Client::::ListResponse
89
+
90
+ ```ruby
91
+ def get_something(page, page_size)
92
+ response = JSON.parse(http_get("http://some/url?p=#{page}&l=#{page_size}"))
93
+ return Px::Service::Client::::ListResponse(page_size, response, "items")
94
+ end
95
+
96
+ ```
97
+
98
+ Wraps a deserialized response. A `ListResponse` implements the Ruby `Enumerable` module, as well
99
+ as the methods required to work with [WillPaginate](https://github.com/mislav/will_paginate).
100
+
101
+ It assumes that the response resembles this form:
102
+ ```json
103
+ {
104
+ "current_page": 1,
105
+ "total_items": 100,
106
+ "total_pages": 10,
107
+ "items": [
108
+ { /* item 1 */ },
109
+ { /* item 2 */ },
110
+ ...
111
+ ]
112
+ }
113
+ ```
114
+
115
+ The name of the `"items"` key is given in the third argument.
116
+
117
+ License
118
+ -------
119
+
120
+ The MIT License (MIT)
121
+
122
+ Copyright (c) 2014 500px, Inc.
123
+
124
+ Permission is hereby granted, free of charge, to any person obtaining a copy
125
+ of this software and associated documentation files (the "Software"), to deal
126
+ in the Software without restriction, including without limitation the rights
127
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
128
+ copies of the Software, and to permit persons to whom the Software is
129
+ furnished to do so, subject to the following conditions:
130
+
131
+ The above copyright notice and this permission notice shall be included in
132
+ all copies or substantial portions of the Software.
133
+
134
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
135
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
136
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
137
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
138
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
139
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
140
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ end
7
+
8
+ desc "Run tests"
9
+ task :default => :test
@@ -0,0 +1,41 @@
1
+ module Px::Service::Client
2
+ class Base
3
+ include Px::Service::Client::Caching
4
+ include Px::Service::Client::CircuitBreaker
5
+ cattr_accessor :logger
6
+
7
+ private
8
+
9
+ def parsed_body(response)
10
+ if response.success?
11
+ Hashie::Mash.new(JSON.parse(response.body))
12
+ else
13
+ if response.response_headers["Content-Type"] =~ %r{application/json}
14
+ JSON.parse(response.body)["error"] rescue response.body.try(:strip)
15
+ else
16
+ response.body.strip
17
+ end
18
+ end
19
+ end
20
+
21
+ def make_request(method, uri, query: nil, headers: nil, body: nil)
22
+ req = Typhoeus::Request.new(
23
+ uri,
24
+ method: method,
25
+ params: query,
26
+ body: body,
27
+ headers: headers)
28
+
29
+ start_time = Time.now
30
+ logger.debug "Making request #{method.to_s.upcase} #{uri}" if logger
31
+
32
+ req.on_complete do |response|
33
+ elapsed = (Time.now - start_time) * 1000
34
+ logger.debug "Completed request #{method.to_s.upcase} #{uri}, took #{elapsed.to_i}ms, got status #{response.response_code}" if logger
35
+ end
36
+
37
+ RetriableResponseFuture.new(req)
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,95 @@
1
+ module Px::Service::Client::Caching
2
+ class CacheEntry
3
+ attr_accessor :url, :data, :expires_at, :policy_group
4
+ attr_reader :cache_client
5
+
6
+ def initialize(cache_client, url, policy_group, data, expires_at = nil)
7
+ @cache_client = cache_client
8
+ self.url = url
9
+ self.data = data
10
+ self.expires_at = expires_at
11
+ self.policy_group = policy_group
12
+ end
13
+
14
+ def expired?
15
+ expires_at < DateTime.now
16
+ end
17
+
18
+ ##
19
+ # Store this entry in the cache with the given expiry.
20
+ def store(expires_in, refresh_window: 5.minutes)
21
+ self.expires_at = DateTime.now + expires_in
22
+
23
+ ActiveSupport::Notifications.instrument("store.caching", { url: url, policy_group: policy_group, expires_in: expires_in} ) do
24
+ real_expiry = real_cache_expiry(expires_in, refresh_window: refresh_window)
25
+ cache_client.multi do
26
+ cache_client.set(cache_key(:data), data.to_json, real_expiry)
27
+ cache_client.set(cache_key(:meta), metadata.to_json, real_expiry)
28
+ end
29
+ end
30
+ end
31
+
32
+ ##
33
+ # Fetch an entry from the cache. Returns the entry if it's present, otherwise returns nil
34
+ def self.fetch(cache_client, url, policy_group)
35
+ key_values = nil
36
+ data_key = cache_key(url, policy_group, :data)
37
+ meta_key = cache_key(url, policy_group, :meta)
38
+ ActiveSupport::Notifications.instrument("get.caching", { url: url, policy_group: policy_group } ) do
39
+ key_values = cache_client.get_multi(data_key, meta_key)
40
+ end
41
+
42
+ data_json = key_values[data_key]
43
+ meta_json = key_values[meta_key]
44
+ if data_json && meta_json
45
+ data = JSON.parse(data_json)
46
+ meta = JSON.parse(meta_json)
47
+ CacheEntry.new(cache_client, meta['url'], meta['pg'], data, meta['expires_at'])
48
+ else
49
+ nil
50
+ end
51
+ end
52
+
53
+ ##
54
+ # Touch this entry in the cache, updating its expiry time but not its data
55
+ def touch(expires_in, refresh_window: 5.minutes)
56
+ self.expires_at = DateTime.now + expires_in
57
+
58
+ ActiveSupport::Notifications.instrument("touch.caching", { url: url, policy_group: policy_group, expires_in: expires_in} ) do
59
+ real_expiry = real_cache_expiry(expires_in, refresh_window: refresh_window)
60
+
61
+ cache_client.touch(cache_key(:data), real_expiry)
62
+ cache_client.set(cache_key(:meta), metadata.to_json, real_expiry)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def metadata
69
+ {
70
+ "url" => url,
71
+ "pg" => policy_group,
72
+ "expires_at" => expires_at,
73
+ }
74
+ end
75
+
76
+ def cache_key(type)
77
+ self.class.cache_key(url, policy_group, type)
78
+ end
79
+
80
+ def self.cache_key(url, policy_group, type)
81
+ "#{policy_group}_#{cache_key_base(url)}_#{type}"
82
+ end
83
+
84
+ ##
85
+ # Get the cache key for the given query string
86
+ def self.cache_key_base(url)
87
+ md5 = Digest::MD5.hexdigest(url.to_s)
88
+ "#{self.class.name.parameterize}_#{md5}"
89
+ end
90
+
91
+ def real_cache_expiry(expires_in, refresh_window: nil)
92
+ (expires_in + refresh_window).to_i
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,23 @@
1
+ module Px::Service::Client::Caching
2
+ ##
3
+ # Prints caching events to the log
4
+ class LogSubscriber < ActiveSupport::LogSubscriber
5
+ def get(event)
6
+ payload = event.payload
7
+ name = color(" ServiceCache Get (#{event.duration.round(1)}ms)", GREEN, true)
8
+ debug("#{name} #{payload[:policy_group]}[#{payload[:url]}]")
9
+ end
10
+
11
+ def store(event)
12
+ payload = event.payload
13
+ name = color(" ServiceCache Store (#{event.duration.round(1)}ms)", GREEN, true)
14
+ debug("#{name} #{payload[:expires_in].to_i}s => #{payload[:policy_group]}[#{payload[:url]}]")
15
+ end
16
+
17
+ def touch(event)
18
+ payload = event.payload
19
+ name = color(" ServiceCache Touch (#{event.duration.round(1)}ms)", GREEN, true)
20
+ debug("#{name} #{payload[:expires_in].to_i}s => #{payload[:policy_group]}[#{payload[:url]}]")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ module Px::Service
2
+ module Client
3
+ module Caching
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "service.client.caching" do
6
+ Px::Service::Client::Caching::LogSubscriber.attach_to :caching
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,112 @@
1
+ require 'px/service/client/caching/cache_entry'
2
+ require 'dalli'
3
+
4
+ if defined?(Rails)
5
+ require 'px/service/client/caching/log_subscriber'
6
+ require 'px/service/client/caching/railtie'
7
+ end
8
+
9
+ module Px::Service::Client
10
+ module Caching
11
+ extend ActiveSupport::Concern
12
+
13
+ STRATEGIES = [
14
+ NO_CACHE = :none,
15
+ LAST_RESORT = :last_resort,
16
+ FIRST_RESORT = :first_resort,
17
+ ]
18
+
19
+ included do
20
+ cattr_accessor :cache_client, :cache_logger
21
+ end
22
+
23
+ def cache_request(url, strategy: :last_resort, expires_in: 30.seconds, **options, &block)
24
+ case strategy
25
+ when :first_resort
26
+ cache_first_resort(url, expires_in: expires_in, **options, &block)
27
+ when :last_resort
28
+ cache_last_resort(url, expires_in: expires_in, **options, &block)
29
+ else
30
+ no_cache(url, &block)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ ##
37
+ # Use the cache as a last resort. This path will make the request each time, caching the result
38
+ # on success. If an exception occurs, the cache is checked for a result. If the cache has a result, it's
39
+ # returned and the cache entry is touched to prevent expiry. Otherwise, the original exception is re-raised.
40
+ def cache_last_resort(url, policy_group: 'general', expires_in: nil, refresh_probability: 1, &block)
41
+ # Note we use a smaller refresh window here (technically, could even use 0)
42
+ # since we don't really need the "expired but not really expired" behaviour when caching as a last resort.
43
+ begin
44
+ response = block.call(url)
45
+
46
+ entry = CacheEntry.new(cache_client, url, policy_group, response)
47
+
48
+ # Only store a new result if we roll a 0
49
+ r = rand(refresh_probability)
50
+ entry.store(expires_in, refresh_window: 1.minute) if r == 0
51
+
52
+ response
53
+ rescue Px::Service::ServiceError => ex
54
+ cache_logger.error "Service responded with exception: #{ex.class.name}: #{ex.message}\n#{ex.backtrace.join('\n')}" if cache_logger
55
+
56
+ entry = CacheEntry.fetch(cache_client, url, policy_group)
57
+ if entry.nil?
58
+ # Re-raise the error, no cached response
59
+ raise ex
60
+ end
61
+
62
+ entry.touch(expires_in, refresh_window: 1.minute)
63
+ entry.data
64
+ end
65
+ end
66
+
67
+ ##
68
+ # Use the cache as a first resort. This path will only make a request if there is no entry in the cache
69
+ # or if the cache entry has expired. It follows logic similar to ActiveSupport::Cache. If the cache entry
70
+ # has expired (but is still present) and the request fails, the cached value is still returned, as if this was
71
+ # cache_last_resort.
72
+ def cache_first_resort(url, policy_group: 'general', expires_in: nil, &block)
73
+ entry = CacheEntry.fetch(cache_client, url, policy_group)
74
+
75
+ if entry
76
+ if entry.expired?
77
+ # Cache entry exists but is expired. This call to cache_first_resort will refresh the cache by
78
+ # calling the block, but to prevent lots of others from also trying to refresh, first it updates
79
+ # the expiry date on the entry so that other callers that come in while we're requesting the update
80
+ # don't also try to update the cache.
81
+ entry.touch(expires_in)
82
+ else
83
+ return entry.data
84
+ end
85
+ end
86
+
87
+ begin
88
+ response = block.call(url)
89
+
90
+ entry = CacheEntry.new(cache_client, url, policy_group, response)
91
+ entry.store(expires_in)
92
+ response
93
+ rescue Px::Service::ServiceError => ex
94
+ cache_logger.error "Service responded with exception: #{ex.class.name}: #{ex.message}\n#{ex.backtrace.join('\n')}" if cache_logger
95
+
96
+ if entry.nil?
97
+ # Re-raise the error, no cached response
98
+ raise ex
99
+ end
100
+
101
+ # Set the entry to be expired again (but reset the refresh window). This allows the next call to try again
102
+ # (assuming the circuit breaker is reset) but keeps the value in the cache in the meantime
103
+ entry.touch(0.seconds)
104
+ entry.data
105
+ end
106
+ end
107
+
108
+ def no_cache(url, &block)
109
+ block.call(url)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,47 @@
1
+ require 'circuit_breaker'
2
+
3
+ module Px::Service::Client
4
+ module CircuitBreaker
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ include ::CircuitBreaker
9
+
10
+ # Default circuit breaker configuration. Can be overridden
11
+ circuit_handler do |handler|
12
+ handler.failure_threshold = 5
13
+ handler.failure_timeout = 7
14
+ handler.invocation_timeout = 5
15
+ handler.excluded_exceptions = [Px::Service::ServiceRequestError]
16
+ end
17
+
18
+ class <<self
19
+ alias_method_chain :circuit_method, :exceptions
20
+ end
21
+ end
22
+
23
+
24
+ module ClassMethods
25
+ ##
26
+ # Takes a splat of method names, and wraps them with the circuit_handler.
27
+ # Overrides the circuit_method provided by ::CircuitBreaker
28
+ def circuit_method_with_exceptions(*methods)
29
+ circuit_handler = self.circuit_handler
30
+
31
+ methods.each do |meth|
32
+ m = instance_method(meth)
33
+ define_method(meth) do |*args|
34
+ begin
35
+ circuit_handler.handle(self.circuit_state, m.bind(self), *args)
36
+ rescue Px::Service::ServiceError, Px::Service::ServiceRequestError => ex
37
+ raise ex
38
+ rescue StandardError => ex
39
+ # Wrap other exceptions, includes CircuitBreaker::CircuitBrokenException
40
+ raise Px::Service::ServiceError.new(ex.message, 503), ex, ex.backtrace
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,91 @@
1
+ # This is based on this code: https://github.com/bitherder/stitch
2
+
3
+ require 'fiber'
4
+
5
+ module Px::Service::Client
6
+ class Future
7
+ class AlreadyCompletedError < StandardError; end
8
+
9
+ ##
10
+ # Create a new future. If a block is given, it is executed and the future is automatically completed
11
+ # with the block's return value
12
+ def initialize
13
+ @completed = false
14
+ @pending_calls = []
15
+
16
+ if block_given?
17
+ Fiber.new do
18
+ begin
19
+ complete(yield)
20
+ rescue Exception => ex
21
+ complete(ex)
22
+ end
23
+ end.resume
24
+ end
25
+ end
26
+
27
+ def complete(value)
28
+ raise AlreadyCompletedError.new if @completed
29
+
30
+ @value = value
31
+ @completed = true
32
+ @pending_calls.each do |pending_call|
33
+ if value.kind_of?(Exception)
34
+ pending_call[:fiber].resume(value)
35
+ else
36
+ result = nil
37
+ begin
38
+ if pending_call[:method]
39
+ result = value.send(pending_call[:method], *pending_call[:args])
40
+ else
41
+ result = value
42
+ end
43
+ rescue Exception => ex
44
+ result = ex
45
+ end
46
+ pending_call[:fiber].resume(result)
47
+ end
48
+ end
49
+ end
50
+
51
+ def value
52
+ if @completed
53
+ @value
54
+ else
55
+ wait_for_value(nil)
56
+ end
57
+ end
58
+
59
+ def completed?
60
+ @completed
61
+ end
62
+
63
+ def method_missing(method, *args)
64
+ if @completed
65
+ raise @value if @value.kind_of?(Exception)
66
+
67
+ super unless respond_to_missing?(method)
68
+ @value.send(method, *args)
69
+ else
70
+ result = wait_for_value(method, *args)
71
+ raise result if result.kind_of?(Exception)
72
+ result
73
+ end
74
+ end
75
+
76
+ def respond_to_missing?(method, include_private = false)
77
+ # NoMethodError is handled by method_missing here, so that exceptions
78
+ # are raised properly even though they don't respond_to the same things
79
+ # as the future values themselves
80
+ true
81
+ end
82
+
83
+ private
84
+
85
+ def wait_for_value(method, *args)
86
+ # TODO: check for root fiber
87
+ @pending_calls << { fiber: Fiber.current, method: method, args: args }
88
+ Fiber.yield
89
+ end
90
+ end
91
+ end