px-service-client 1.0.1

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