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.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/Guardfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +140 -0
- data/Rakefile +9 -0
- data/lib/px/service/client/base.rb +41 -0
- data/lib/px/service/client/caching/cache_entry.rb +95 -0
- data/lib/px/service/client/caching/log_subscriber.rb +23 -0
- data/lib/px/service/client/caching/railtie.rb +11 -0
- data/lib/px/service/client/caching.rb +112 -0
- data/lib/px/service/client/circuit_breaker.rb +47 -0
- data/lib/px/service/client/future.rb +91 -0
- data/lib/px/service/client/list_response.rb +80 -0
- data/lib/px/service/client/multiplexer.rb +34 -0
- data/lib/px/service/client/retriable_response_future.rb +98 -0
- data/lib/px/service/client/version.rb +7 -0
- data/lib/px/service/client.rb +19 -0
- data/lib/px/service/errors.rb +28 -0
- data/lib/px-service-client.rb +1 -0
- data/px-service-client.gemspec +35 -0
- data/spec/px/service/client/base_spec.rb +49 -0
- data/spec/px/service/client/caching/caching_spec.rb +209 -0
- data/spec/px/service/client/circuit_breaker_spec.rb +113 -0
- data/spec/px/service/client/future_spec.rb +182 -0
- data/spec/px/service/client/list_response_spec.rb +118 -0
- data/spec/px/service/client/multiplexer_spec.rb +63 -0
- data/spec/px/service/client/retriable_response_future_spec.rb +99 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/vcr/Px_Service_Client_Multiplexer/with_multiple_requests/when_the_requests_depend_on_each_other/runs_the_requests.yml +91 -0
- 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
- data/spec/vcr/Px_Service_Client_Multiplexer/with_one_request/returns_a_ResponseFuture.yml +47 -0
- data/spec/vcr/Px_Service_Client_Multiplexer/with_one_request/runs_the_requests.yml +47 -0
- 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
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
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
|
+
[](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,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,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
|