├── .ruby-version ├── .ruby-gemset ├── .rspec ├── lib ├── px-service-client.rb └── px │ └── service │ ├── client │ ├── version.rb │ ├── caching │ │ ├── railtie.rb │ │ ├── log_subscriber.rb │ │ └── cache_entry.rb │ ├── circuit_breaker_retriable_response_future.rb │ ├── null_statsd_client.rb │ ├── multiplexer.rb │ ├── hmac_signing.rb │ ├── list_response.rb │ ├── circuit_breaker.rb │ ├── retriable_response_future.rb │ ├── future.rb │ ├── base.rb │ └── caching.rb │ ├── client.rb │ └── errors.rb ├── Gemfile ├── Rakefile ├── .gitignore ├── spec ├── spec_helper.rb ├── vcr │ └── Px_Service_Client_Multiplexer │ │ ├── with_one_request │ │ ├── runs_the_requests.yml │ │ └── returns_a_ResponseFuture.yml │ │ └── with_multiple_requests │ │ ├── when_the_requests_depend_on_each_other │ │ └── runs_the_requests.yml │ │ └── when_the_requests_don_t_depend_on_each_other │ │ └── runs_the_requests.yml └── px │ └── service │ └── client │ ├── hmac_signing_spec.rb │ ├── multiplexer_spec.rb │ ├── retriable_response_future_spec.rb │ ├── list_response_spec.rb │ ├── circuit_breaker_spec.rb │ ├── future_spec.rb │ ├── caching │ └── caching_spec.rb │ └── base_spec.rb ├── Guardfile ├── LICENSE.txt ├── px-service-client.gemspec └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.1.0 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | px-service-client 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format documentation 3 | -------------------------------------------------------------------------------- /lib/px-service-client.rb: -------------------------------------------------------------------------------- 1 | require 'px/service/client' 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in px-service-client.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/px/service/client/version.rb: -------------------------------------------------------------------------------- 1 | module Px 2 | module Service 3 | module Client 4 | VERSION = "2.0.11" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 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 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 23 | -------------------------------------------------------------------------------- /lib/px/service/client/caching/railtie.rb: -------------------------------------------------------------------------------- 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 12 | -------------------------------------------------------------------------------- /lib/px/service/client/circuit_breaker_retriable_response_future.rb: -------------------------------------------------------------------------------- 1 | module Px::Service::Client 2 | class CircuitBreakerRetriableResponseFuture < RetriableResponseFuture 3 | 4 | ## 5 | # Sets the value of a RetriableResponseFuture to the exception 6 | # raised when opening the circuit breaker. 7 | def initialize(ex) 8 | super() 9 | 10 | complete(ex) 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/px/service/client/null_statsd_client.rb: -------------------------------------------------------------------------------- 1 | module Px::Service::Client 2 | # Does nothing, gracefully 3 | class NullStatsdClient 4 | def increment(*args) 5 | end 6 | 7 | def gauge(*args) 8 | end 9 | 10 | def histogram(*args) 11 | end 12 | 13 | def time(*args) 14 | yield if block_given? 15 | end 16 | 17 | def timing(*args) 18 | end 19 | 20 | def set(*args) 21 | end 22 | 23 | def count(*args) 24 | end 25 | 26 | def batch(*args) 27 | yield(self) if block_given? 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | Bundler.setup 3 | 4 | require 'px/service/client' 5 | require 'timecop' 6 | require 'pry' 7 | require 'vcr' 8 | 9 | ## 10 | # VCR config 11 | VCR.configure do |c| 12 | c.cassette_library_dir = 'spec/vcr' 13 | c.hook_into :typhoeus, :webmock 14 | c.allow_http_connections_when_no_cassette = true 15 | c.configure_rspec_metadata! 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.before(:each) do 20 | Typhoeus::Expectation.clear 21 | end 22 | 23 | config.after(:each) do 24 | Timecop.return 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/px/service/client.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_support/core_ext' 3 | require 'px/service/errors' 4 | require 'typhoeus' 5 | 6 | module Px 7 | module Service 8 | module Client 9 | DEFAULT_SECRET = "devsecret" 10 | DEFAULT_KEYSPAN = 300 11 | end 12 | end 13 | end 14 | 15 | require "px/service/client/version" 16 | require "px/service/client/future" 17 | require "px/service/client/null_statsd_client" 18 | require "px/service/client/caching" 19 | require "px/service/client/circuit_breaker" 20 | require "px/service/client/hmac_signing" 21 | require "px/service/client/list_response" 22 | require "px/service/client/base" 23 | require "px/service/client/multiplexer" 24 | require "px/service/client/retriable_response_future" 25 | require "px/service/client/circuit_breaker_retriable_response_future" 26 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 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 | 20 | -------------------------------------------------------------------------------- /lib/px/service/client/caching/log_subscriber.rb: -------------------------------------------------------------------------------- 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 24 | -------------------------------------------------------------------------------- /lib/px/service/errors.rb: -------------------------------------------------------------------------------- 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 29 | -------------------------------------------------------------------------------- /lib/px/service/client/multiplexer.rb: -------------------------------------------------------------------------------- 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.new(request_or_future, retries: retries) 21 | elsif !request_or_future.is_a?(RetriableResponseFuture) || request_or_future.completed? 22 | return request_or_future 23 | end 24 | 25 | # Will automatically queue the request on the hydra 26 | response.hydra = hydra 27 | response 28 | end 29 | 30 | ## 31 | # Start the multiplexer. 32 | def run 33 | hydra.run 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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. 23 | -------------------------------------------------------------------------------- /spec/vcr/Px_Service_Client_Multiplexer/with_one_request/runs_the_requests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: http://localhost:3000/status 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | User-Agent: 11 | - Typhoeus - https://github.com/typhoeus/typhoeus 12 | response: 13 | status: 14 | code: 200 15 | message: OK 16 | headers: 17 | X-Frame-Options: 18 | - SAMEORIGIN 19 | Content-Type: 20 | - text/html; charset=utf-8 21 | X-UA-Compatible: 22 | - IE=Edge 23 | ETag: 24 | - "\"e0aa021e21dddbd6d8cecec71e9cf564\"" 25 | Cache-Control: 26 | - max-age=0, private, must-revalidate 27 | Set-Cookie: 28 | - landing_page=%2Fstatus; path=/ 29 | - referrer_type=other; path=/ 30 | - _hpx1=BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiJWUzYmY2YzZmNGE1MmJmYzMxMjQxY2U3YzQxOTU5MmM0BjsAVEkiCWhvc3QGOwBGIg5sb2NhbGhvc3Q%3D--709947f7a6ab22af59a6d421e60f4854dfa4c4de; 31 | path=/; HttpOnly 32 | X-Request-Id: 33 | - fdea9264a71fbaef0842dcf941c04c25 34 | X-Runtime: 35 | - '0.026443' 36 | Connection: 37 | - close 38 | Server: 39 | - thin 1.5.0 codename Knife 40 | body: 41 | encoding: UTF-8 42 | string: OK 43 | http_version: '1.1' 44 | adapter_metadata: 45 | effective_url: http://localhost:3000/status 46 | recorded_at: Tue, 11 Nov 2014 20:22:51 GMT 47 | recorded_with: VCR 2.9.3 48 | -------------------------------------------------------------------------------- /spec/vcr/Px_Service_Client_Multiplexer/with_one_request/returns_a_ResponseFuture.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: http://localhost:3000/status 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | User-Agent: 11 | - Typhoeus - https://github.com/typhoeus/typhoeus 12 | response: 13 | status: 14 | code: 200 15 | message: OK 16 | headers: 17 | X-Frame-Options: 18 | - SAMEORIGIN 19 | Content-Type: 20 | - text/html; charset=utf-8 21 | X-UA-Compatible: 22 | - IE=Edge 23 | ETag: 24 | - "\"e0aa021e21dddbd6d8cecec71e9cf564\"" 25 | Cache-Control: 26 | - max-age=0, private, must-revalidate 27 | Set-Cookie: 28 | - landing_page=%2Fstatus; path=/ 29 | - referrer_type=other; path=/ 30 | - _hpx1=BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiJWRjZDUwNzlmZjk1ODk5YzE1Y2EzZTc1NmY1ODYxOTMzBjsAVEkiCWhvc3QGOwBGIg5sb2NhbGhvc3Q%3D--a8c89b4b744bcced78ab027bd26c99f560af9847; 31 | path=/; HttpOnly 32 | X-Request-Id: 33 | - f27de50bbf4924df91daf6d12ae76b06 34 | X-Runtime: 35 | - '22.558991' 36 | Connection: 37 | - close 38 | Server: 39 | - thin 1.5.0 codename Knife 40 | body: 41 | encoding: UTF-8 42 | string: OK 43 | http_version: '1.1' 44 | adapter_metadata: 45 | effective_url: http://localhost:3000/status 46 | recorded_at: Tue, 11 Nov 2014 20:22:51 GMT 47 | recorded_with: VCR 2.9.3 48 | -------------------------------------------------------------------------------- /px-service-client.gemspec: -------------------------------------------------------------------------------- 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", "Zimu Liu"] 11 | spec.email = ["cdmicacc@gmail.com", "zimu@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", "~> 4.0" 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" 30 | spec.add_development_dependency "pry-byebug" 31 | spec.add_development_dependency "vcr" 32 | #spec.add_development_dependency "guard" 33 | #spec.add_development_dependency "guard-rspec" 34 | spec.add_development_dependency "rspec", "~> 2.99" 35 | spec.add_development_dependency "timecop", "~> 0.5" 36 | end 37 | -------------------------------------------------------------------------------- /spec/px/service/client/hmac_signing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Px::Service::Client::HmacSigning do 4 | let(:subject_class) { 5 | Class.new(Px::Service::Client::Base).tap do |c| 6 | # Anonymous classes don't have a name. Stub out :name so that things work 7 | allow(c).to receive(:name).and_return("HmacSigning") 8 | c.include(Px::Service::Client::HmacSigning) 9 | end 10 | } 11 | 12 | let(:another_class) { 13 | Class.new(Px::Service::Client::Base).tap do |c| 14 | c.include(Px::Service::Client::HmacSigning) 15 | 16 | c.configure do |config| 17 | config.hmac_secret = "different secret" 18 | end 19 | end 20 | } 21 | 22 | subject { subject_class.new } 23 | let(:another_object) { another_class.new } 24 | 25 | describe '#make_request' do 26 | context "when the underlying request method succeeds" do 27 | let(:url) { 'http://localhost:3000/path' } 28 | let(:resp) { subject.send(:make_request, 'get', url) } 29 | let(:headers) { resp.request.options[:headers] } 30 | 31 | it "returns a Future" do 32 | expect(resp).to be_a_kind_of(Px::Service::Client::RetriableResponseFuture) 33 | end 34 | 35 | it "contains a header with auth signature" do 36 | expect(headers).to have_key("X-Service-Auth") 37 | expect(headers).to have_key("Timestamp") 38 | end 39 | 40 | let(:resp2) { another_object.send(:make_request, 'get', url) } 41 | let(:headers2) { resp2.request.options[:headers] } 42 | it "is different from the object of another class with a different key" do 43 | expect(headers["X-Service-Auth"]).not_to eq(headers2["X-Service-Auth"]) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/px/service/client/multiplexer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Px::Service::Client::Multiplexer do 4 | let(:client) { Px::Service::Client::Base.send(:new) } 5 | 6 | context '.new' do 7 | it 'should work with no argments' do 8 | expect { 9 | Px::Service::Client::Multiplexer.new 10 | }.not_to raise_error 11 | end 12 | end 13 | 14 | context 'with one request', vcr: true do 15 | let(:req1) { client.send(:make_request, :get, 'http://localhost:3000/status') } 16 | 17 | it "returns a ResponseFuture" do 18 | subject.context do 19 | resp1 = subject.do(req1) 20 | 21 | expect(resp1).to be_a(Px::Service::Client::RetriableResponseFuture) 22 | end.run 23 | end 24 | 25 | it "runs the requests" do 26 | subject.context do 27 | resp1 = subject.do(req1) 28 | 29 | expect(resp1.body).to eq("OK") 30 | end.run 31 | end 32 | end 33 | 34 | context 'with multiple requests', vcr: true do 35 | let(:req1) { client.send(:make_request, :get, 'http://localhost:3000/status') } 36 | let(:req2) { client.send(:make_request, :get, 'http://localhost:3000/status') } 37 | 38 | context "when the requests don't depend on each other" do 39 | it "runs the requests" do 40 | subject.context do 41 | resp1 = subject.do(req1) 42 | resp2 = subject.do(req2) 43 | 44 | expect(resp2.body).to eq("OK") 45 | expect(resp1.body).to eq("OK") 46 | end.run 47 | end 48 | end 49 | 50 | context "when the requests depend on each other" do 51 | it "runs the requests" do 52 | subject.context do 53 | resp1 = subject.do(req1) 54 | client.send(:make_request, :get, "http://localhost:3000/status?#{resp1.body}") 55 | resp2 = subject.do(req2) 56 | 57 | expect(resp2.body).to eq("OK") 58 | expect(resp1.body).to eq("OK") 59 | end.run 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/px/service/client/hmac_signing.rb: -------------------------------------------------------------------------------- 1 | module Px::Service::Client 2 | module HmacSigning 3 | extend ActiveSupport::Concern 4 | included do 5 | alias_method_chain :_make_request, :signing 6 | 7 | cattr_accessor :secret do 8 | DEFAULT_SECRET 9 | end 10 | 11 | cattr_accessor :keyspan do 12 | DEFAULT_KEYSPAN 13 | end 14 | 15 | # Default config for signing 16 | configure do |config| 17 | config.hmac_secret = DEFAULT_SECRET 18 | config.hmac_keyspan = DEFAULT_KEYSPAN 19 | end 20 | 21 | ## 22 | # DEPRECATED: Use .config (base class method) instead 23 | alias_method :hmac_signing, :configure 24 | end 25 | 26 | module ClassMethods 27 | ## 28 | # Generate a nonce that's used to expire message after keyspan seconds 29 | def generate_signature(method, uri, query, body, timestamp) 30 | secret = self.config.hmac_secret 31 | keyspan = self.config.hmac_keyspan 32 | nonce = (timestamp - (timestamp % keyspan)) + keyspan 33 | data = "#{method.capitalize},#{uri},#{query},#{body},#{nonce.to_s}" 34 | digest = OpenSSL::Digest.new('sha256') 35 | digest = OpenSSL::HMAC.digest(digest, secret, data) 36 | return Base64.urlsafe_encode64(digest).strip() 37 | end 38 | end 39 | 40 | def _make_request_with_signing(method, uri, query: nil, headers: nil, body: nil, timeout: nil, stats_tags: []) 41 | timestamp = Time.now.to_i 42 | signature = self.class.generate_signature(method, uri, query, body, timestamp) 43 | 44 | headers = {} if headers.nil? 45 | headers.merge!("X-Service-Auth" => signature) 46 | headers.merge!("Timestamp" => timestamp) 47 | 48 | _make_request_without_signing( 49 | method, 50 | uri, 51 | query: query, 52 | headers: headers, 53 | body: body, 54 | timeout: timeout, 55 | stats_tags: stats_tags) 56 | end 57 | 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/px/service/client/list_response.rb: -------------------------------------------------------------------------------- 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 81 | -------------------------------------------------------------------------------- /lib/px/service/client/circuit_breaker.rb: -------------------------------------------------------------------------------- 1 | require 'circuit_breaker' 2 | 3 | module Px::Service::Client 4 | module CircuitBreaker 5 | extend ActiveSupport::Concern 6 | include ::CircuitBreaker 7 | 8 | included do 9 | # Default circuit breaker configuration. Can be overridden 10 | circuit_handler do |handler| 11 | handler.failure_threshold = 5 12 | handler.failure_timeout = 7 13 | handler.invocation_timeout = 5 14 | handler.excluded_exceptions = [Px::Service::ServiceRequestError] 15 | end 16 | 17 | cattr_accessor :circuit_state do 18 | ::CircuitBreaker::CircuitState.new 19 | end 20 | 21 | alias_method_chain :_make_request, :breaker 22 | end 23 | 24 | ## 25 | # Make the request, respecting the circuit breaker, if configured 26 | def _make_request_with_breaker(method, uri, query: nil, headers: nil, body: nil, timeout: nil, stats_tags: []) 27 | state = self.class.circuit_state 28 | handler = self.class.circuit_handler 29 | 30 | if handler.is_tripped(state) 31 | handler.logger.debug("handle: breaker is tripped, refusing to execute: #{state}") if handler.logger 32 | begin 33 | handler.on_circuit_open(state) 34 | rescue StandardError => ex 35 | # Wrap and reroute other exceptions, includes CircuitBreaker::CircuitBrokenException 36 | error = Px::Service::ServiceError.new(ex.message, 503) 37 | return CircuitBreakerRetriableResponseFuture.new(error) 38 | end 39 | end 40 | 41 | config.statsd_client.increment("breakers.ready.count", tags: stats_tags) if circuit_state.half_open? 42 | 43 | retry_request = _make_request_without_breaker( 44 | method, 45 | uri, 46 | query: query, 47 | headers: headers, 48 | body: body, 49 | timeout: handler.invocation_timeout, 50 | stats_tags: stats_tags) 51 | 52 | retry_request.request.on_complete do |response| 53 | # Wait for request to exhaust retries 54 | if retry_request.completed? 55 | if response.response_code >= 500 || response.response_code == 0 56 | config.statsd_client.increment("breakers.fail.count", tags: stats_tags) 57 | config.statsd_client.increment("breakers.tripped.count", tags: stats_tags) if circuit_state.closed? 58 | 59 | # as this code may be executed after context switch, we want to 60 | # check the state again 61 | handler.on_failure(state) unless handler.is_tripped(state) 62 | else 63 | config.statsd_client.increment("breakers.reset.count", tags: stats_tags) unless circuit_state.closed? 64 | handler.on_success(state) 65 | end 66 | end 67 | end 68 | 69 | retry_request 70 | end 71 | 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/vcr/Px_Service_Client_Multiplexer/with_multiple_requests/when_the_requests_depend_on_each_other/runs_the_requests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: http://localhost:3000/status 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | User-Agent: 11 | - Typhoeus - https://github.com/typhoeus/typhoeus 12 | response: 13 | status: 14 | code: 200 15 | message: OK 16 | headers: 17 | X-Frame-Options: 18 | - SAMEORIGIN 19 | Content-Type: 20 | - text/html; charset=utf-8 21 | X-UA-Compatible: 22 | - IE=Edge 23 | ETag: 24 | - "\"e0aa021e21dddbd6d8cecec71e9cf564\"" 25 | Cache-Control: 26 | - max-age=0, private, must-revalidate 27 | Set-Cookie: 28 | - landing_page=%2Fstatus; path=/ 29 | - referrer_type=other; path=/ 30 | - _hpx1=BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiJTNkODFkMDk4MzEyZDExYWI0MTE1YzAwNWYzZTAxM2ZiBjsAVEkiCWhvc3QGOwBGIg5sb2NhbGhvc3Q%3D--85ef4ecf15347d5b26bf5a0c3adae2ff744341aa; 31 | path=/; HttpOnly 32 | X-Request-Id: 33 | - 7dbb973d1e14238a8749e5febcafa9f9 34 | X-Runtime: 35 | - '0.017194' 36 | Connection: 37 | - close 38 | Server: 39 | - thin 1.5.0 codename Knife 40 | body: 41 | encoding: UTF-8 42 | string: OK 43 | http_version: '1.1' 44 | adapter_metadata: 45 | effective_url: http://localhost:3000/status 46 | recorded_at: Tue, 11 Nov 2014 20:22:51 GMT 47 | - request: 48 | method: get 49 | uri: http://localhost:3000/status 50 | body: 51 | encoding: US-ASCII 52 | string: '' 53 | headers: 54 | User-Agent: 55 | - Typhoeus - https://github.com/typhoeus/typhoeus 56 | response: 57 | status: 58 | code: 200 59 | message: OK 60 | headers: 61 | X-Frame-Options: 62 | - SAMEORIGIN 63 | Content-Type: 64 | - text/html; charset=utf-8 65 | X-UA-Compatible: 66 | - IE=Edge 67 | ETag: 68 | - "\"e0aa021e21dddbd6d8cecec71e9cf564\"" 69 | Cache-Control: 70 | - max-age=0, private, must-revalidate 71 | Set-Cookie: 72 | - landing_page=%2Fstatus; path=/ 73 | - referrer_type=other; path=/ 74 | - _hpx1=BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiJTA1ODdiZDhkOWE2MTk4OWYxMjJhZWM2MjdkZDRkMGNhBjsAVEkiCWhvc3QGOwBGIg5sb2NhbGhvc3Q%3D--61cd8e3e21637857a0eb745049adb6d9e172c1fc; 75 | path=/; HttpOnly 76 | X-Request-Id: 77 | - c8ce1b4570fe232431ca9716a6ee90ea 78 | X-Runtime: 79 | - '0.018474' 80 | Connection: 81 | - close 82 | Server: 83 | - thin 1.5.0 codename Knife 84 | body: 85 | encoding: UTF-8 86 | string: OK 87 | http_version: '1.1' 88 | adapter_metadata: 89 | effective_url: http://localhost:3000/status 90 | recorded_at: Tue, 11 Nov 2014 20:22:51 GMT 91 | recorded_with: VCR 2.9.3 92 | -------------------------------------------------------------------------------- /spec/vcr/Px_Service_Client_Multiplexer/with_multiple_requests/when_the_requests_don_t_depend_on_each_other/runs_the_requests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: http://localhost:3000/status 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | User-Agent: 11 | - Typhoeus - https://github.com/typhoeus/typhoeus 12 | response: 13 | status: 14 | code: 200 15 | message: OK 16 | headers: 17 | X-Frame-Options: 18 | - SAMEORIGIN 19 | Content-Type: 20 | - text/html; charset=utf-8 21 | X-UA-Compatible: 22 | - IE=Edge 23 | ETag: 24 | - "\"e0aa021e21dddbd6d8cecec71e9cf564\"" 25 | Cache-Control: 26 | - max-age=0, private, must-revalidate 27 | Set-Cookie: 28 | - landing_page=%2Fstatus; path=/ 29 | - referrer_type=other; path=/ 30 | - _hpx1=BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiJTRiMWVlZjEyYTA3ZmQ2NTA5NzMzNThlZmRjODUwOTJlBjsAVEkiCWhvc3QGOwBGIg5sb2NhbGhvc3Q%3D--70cae34deed03c594c3ab738150b3d7e20b32fb2; 31 | path=/; HttpOnly 32 | X-Request-Id: 33 | - 7dae1a92439fed487abb36164364741a 34 | X-Runtime: 35 | - '0.017075' 36 | Connection: 37 | - close 38 | Server: 39 | - thin 1.5.0 codename Knife 40 | body: 41 | encoding: UTF-8 42 | string: OK 43 | http_version: '1.1' 44 | adapter_metadata: 45 | effective_url: http://localhost:3000/status 46 | recorded_at: Tue, 11 Nov 2014 20:22:51 GMT 47 | - request: 48 | method: get 49 | uri: http://localhost:3000/status 50 | body: 51 | encoding: US-ASCII 52 | string: '' 53 | headers: 54 | User-Agent: 55 | - Typhoeus - https://github.com/typhoeus/typhoeus 56 | response: 57 | status: 58 | code: 200 59 | message: OK 60 | headers: 61 | X-Frame-Options: 62 | - SAMEORIGIN 63 | Content-Type: 64 | - text/html; charset=utf-8 65 | X-UA-Compatible: 66 | - IE=Edge 67 | ETag: 68 | - "\"e0aa021e21dddbd6d8cecec71e9cf564\"" 69 | Cache-Control: 70 | - max-age=0, private, must-revalidate 71 | Set-Cookie: 72 | - landing_page=%2Fstatus; path=/ 73 | - referrer_type=other; path=/ 74 | - _hpx1=BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiJTgyMjA5OGEzN2Y3YTczN2ViMzk0ODRjOTk4MTEyNDIzBjsAVEkiCWhvc3QGOwBGIg5sb2NhbGhvc3Q%3D--972313d92247acf7eff13cc669f2c8b4c481e92c; 75 | path=/; HttpOnly 76 | X-Request-Id: 77 | - 8ac3f0a3228f949c5599419f2996d8ca 78 | X-Runtime: 79 | - '0.016529' 80 | Connection: 81 | - close 82 | Server: 83 | - thin 1.5.0 codename Knife 84 | body: 85 | encoding: UTF-8 86 | string: OK 87 | http_version: '1.1' 88 | adapter_metadata: 89 | effective_url: http://localhost:3000/status 90 | recorded_at: Tue, 11 Nov 2014 20:22:51 GMT 91 | recorded_with: VCR 2.9.3 92 | -------------------------------------------------------------------------------- /spec/px/service/client/retriable_response_future_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Px::Service::Client::RetriableResponseFuture do 4 | let(:request) { Typhoeus::Request.new('http://localhost:3000/status') } 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 | let(:hydra) { Typhoeus::Hydra.new } 12 | subject { Px::Service::Client::RetriableResponseFuture.new(request) } 13 | 14 | before :each do 15 | Typhoeus.stub(/status/).and_return(response) 16 | end 17 | 18 | describe '#hydra=' do 19 | it "queues the request on the hydra" do 20 | expect(hydra).to receive(:queue).with(request) 21 | 22 | subject.hydra = hydra 23 | end 24 | end 25 | 26 | describe '#method_missing' do 27 | context "when the request is still in progress" do 28 | it "does not call the method on the response" do 29 | Fiber.new do 30 | expect(response).not_to receive(:body) 31 | 32 | subject.hydra = hydra 33 | 34 | subject.body 35 | end.resume 36 | end 37 | end 38 | 39 | context "when the request status is an error" do 40 | let(:response) do 41 | Typhoeus::Response.new( 42 | code: 500, 43 | body: { status: 500, error: "Failed"}.to_json, 44 | headers: { "Content-Type" => "application/json"} ) 45 | end 46 | 47 | it "completes the future only once" do 48 | Fiber.new do 49 | expect { 50 | subject.total_time 51 | }.to raise_error(Px::Service::ServiceError, "Failed") 52 | end.resume 53 | 54 | Fiber.new do 55 | subject.hydra = hydra 56 | hydra.run 57 | end.resume 58 | end 59 | 60 | it "retries the request" do 61 | f = Px::Service::Client::RetriableResponseFuture.new(retries: 3) 62 | 63 | Fiber.new do 64 | expect { 65 | f.response_code 66 | }.to raise_error(Px::Service::ServiceError, "Failed") 67 | end.resume 68 | 69 | Fiber.new do 70 | expect(hydra).to receive(:queue).with(request).exactly(4).times.and_call_original 71 | f.request = request 72 | 73 | f.hydra = hydra 74 | hydra.run 75 | end.resume 76 | end 77 | end 78 | 79 | context "when the request completes" do 80 | it "calls any pending methods on the response" do 81 | expect(response).to receive(:total_time) 82 | called = false 83 | 84 | Fiber.new do 85 | subject.total_time 86 | called = true 87 | end.resume 88 | 89 | Fiber.new do 90 | subject.hydra = hydra 91 | hydra.run 92 | end.resume 93 | 94 | expect(called).to eq(true) 95 | end 96 | end 97 | end 98 | 99 | end 100 | -------------------------------------------------------------------------------- /lib/px/service/client/retriable_response_future.rb: -------------------------------------------------------------------------------- 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 99 | -------------------------------------------------------------------------------- /lib/px/service/client/future.rb: -------------------------------------------------------------------------------- 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 | value = begin 19 | yield 20 | rescue Exception => ex 21 | ex 22 | end 23 | complete(value) 24 | end.resume 25 | end 26 | end 27 | 28 | def complete(value) 29 | raise AlreadyCompletedError.new if @completed 30 | 31 | @value = value 32 | @completed = true 33 | @pending_calls.each do |pending_call| 34 | if value.kind_of?(Exception) 35 | pending_call[:fiber].resume(value) 36 | else 37 | result = nil 38 | begin 39 | if pending_call[:method] 40 | result = value.send(pending_call[:method], *pending_call[:args]) 41 | else 42 | result = value 43 | end 44 | rescue Exception => ex 45 | result = ex 46 | end 47 | pending_call[:fiber].resume(result) 48 | end 49 | end 50 | end 51 | 52 | def value 53 | if @completed 54 | @value 55 | else 56 | wait_for_value(nil) 57 | end 58 | end 59 | 60 | def value! 61 | if @completed 62 | result = @value 63 | else 64 | result = wait_for_value(nil) 65 | end 66 | 67 | if result.kind_of?(Exception) 68 | # Set the backtrack properly to reflect where this method is called 69 | result.set_backtrace(caller) 70 | raise result 71 | end 72 | 73 | result 74 | end 75 | 76 | def completed? 77 | @completed 78 | end 79 | 80 | def method_missing(method, *args) 81 | if @completed 82 | if @value.kind_of?(Exception) 83 | # Set the backtrack properly to reflect where this method is called 84 | @value.set_backtrace(caller) 85 | raise @value 86 | end 87 | 88 | super unless respond_to_missing?(method) 89 | @value.send(method, *args) 90 | else 91 | result = wait_for_value(method, *args) 92 | raise result if result.kind_of?(Exception) 93 | result 94 | end 95 | end 96 | 97 | def respond_to_missing?(method, include_private = false) 98 | # NoMethodError is handled by method_missing here, so that exceptions 99 | # are raised properly even though they don't respond_to the same things 100 | # as the future values themselves 101 | true 102 | end 103 | 104 | private 105 | 106 | def wait_for_value(method, *args) 107 | # TODO: check for root fiber 108 | @pending_calls << { fiber: Fiber.current, method: method, args: args } 109 | Fiber.yield 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/px/service/client/list_response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Px::Service::Client::ListResponse do 4 | let (:response) do 5 | { 6 | "current_page" => 5, 7 | "total_pages" => 1, 8 | "total_items" => 3, 9 | "took" => 123, 10 | "results" => [ 11 | { "id" => 1, "type" => "photo", "score" => 1}, 12 | { "id" => 2, "type" => "photo", "score" => 1}, 13 | { "id" => 3, "type" => "photo", "score" => 1}, 14 | ], 15 | } 16 | end 17 | 18 | let (:empty_response) do 19 | { 20 | "current_page" => 1, 21 | "total_pages" => 1, 22 | "total_items" => 0, 23 | "took" => 123, 24 | "results" => [], 25 | } 26 | end 27 | 28 | subject { Px::Service::Client::ListResponse.new(20, response, "results", name: "value") } 29 | 30 | describe '#results' do 31 | it "results the results" do 32 | expect(subject.results).to eq(response["results"]) 33 | end 34 | end 35 | 36 | 37 | describe '#per_page' do 38 | it "returns the page size" do 39 | expect(subject.per_page).to be(20) 40 | end 41 | end 42 | 43 | describe '#current_page' do 44 | it "returns the current page" do 45 | expect(subject.current_page).to be(5) 46 | end 47 | end 48 | 49 | describe '#total_entries' do 50 | it "returns the total number of entries" do 51 | expect(subject.total_entries).to be(3) 52 | end 53 | end 54 | 55 | describe '#total_pages' do 56 | it "returns the total number of pages" do 57 | expect(subject.total_pages).to be(1) 58 | end 59 | end 60 | 61 | describe '#offset' do 62 | it "returns the offset" do 63 | expect(subject.offset).to be(80) 64 | end 65 | end 66 | 67 | describe '#empty?' do 68 | context "when there are results" do 69 | it "returns false" do 70 | expect(subject.empty?).to be_falsey 71 | end 72 | end 73 | 74 | context "when there are no results" do 75 | subject { Px::Service::Client::ListResponse.new(20, empty_response, "results", name: "value") } 76 | 77 | it "returns true" do 78 | expect(subject.empty?).to be_truthy 79 | end 80 | end 81 | end 82 | 83 | describe '#==' do 84 | let(:other_search) { Px::Service::Client::ListResponse.new(20, response, "results", name: "value") } 85 | 86 | context "when compared with another response" do 87 | it "returns true" do 88 | expect(subject == other_search).to be_truthy 89 | end 90 | end 91 | 92 | context "when compared with an array" do 93 | it "returns true" do 94 | expect(subject == other_search.to_a).to be_truthy 95 | end 96 | end 97 | 98 | context "when compared with nil" do 99 | it "returns false" do 100 | expect(subject == nil).to be_falsey 101 | end 102 | end 103 | 104 | context "when compared with something else" do 105 | it "returns false" do 106 | expect(subject == "stuff").to be_falsey 107 | end 108 | end 109 | end 110 | 111 | describe '#each' do 112 | it "iterates over the list of results" do 113 | subject.each do |result| 114 | expect(result).to be_a(Hash) 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/px/service/client/base.rb: -------------------------------------------------------------------------------- 1 | module Px::Service::Client 2 | class Base 3 | class_attribute :logger, :config 4 | 5 | class DefaultConfig < OpenStruct 6 | def initialize 7 | super 8 | self.statsd_client = NullStatsdClient.new 9 | end 10 | end 11 | 12 | self.config = DefaultConfig.new 13 | 14 | ## 15 | # Configure the client 16 | def self.configure 17 | c = self.config.dup 18 | yield(c) if block_given? 19 | self.config = c 20 | end 21 | 22 | # Make class config available to instances 23 | def configure 24 | self.class.configure { |c| yield(c) } 25 | end 26 | 27 | private 28 | 29 | def parsed_body(response) 30 | if response.success? 31 | Hashie::Mash.new(JSON.parse(response.body)) 32 | else 33 | if response.response_headers["Content-Type"] =~ %r{application/json} 34 | JSON.parse(response.body)["error"] rescue response.body.try(:strip) 35 | else 36 | response.body.strip 37 | end 38 | end 39 | end 40 | 41 | ## 42 | # Make the request 43 | def make_request(method, uri, query: nil, headers: nil, body: nil, timeout: 0, stats_tags: []) 44 | _stats_tags = [ 45 | "remote_method:#{method.downcase}", 46 | ].concat(stats_tags) 47 | 48 | if uri.respond_to?(:host) 49 | _stats_tags << "remote_host:#{uri.host.downcase}" 50 | else 51 | actual_uri = URI(uri) 52 | _stats_tags << "remote_host:#{actual_uri.host.downcase}" 53 | end 54 | 55 | _make_request(method, uri, query: query, headers: headers, body: body, timeout: timeout, stats_tags: _stats_tags) 56 | end 57 | 58 | def _make_request(method, uri, query: nil, headers: nil, body: nil, timeout: nil, stats_tags: []) 59 | req = Typhoeus::Request.new( 60 | uri, 61 | method: method, 62 | params: query, 63 | body: body, 64 | headers: headers, 65 | timeout: timeout, 66 | params_encoding: :rack, 67 | ) 68 | 69 | start_time = Time.now 70 | logger.debug "Making request #{method.to_s.upcase} #{uri}" if logger 71 | 72 | req.on_complete do |response| 73 | elapsed = (Time.now - start_time) * 1000 74 | config.statsd_client.histogram("backend.request.duration", elapsed.to_i, tags: stats_tags) 75 | config.statsd_client.increment("backend.response.count", tags: stats_tags + ["httpstatus:#{response.response_code}"]) 76 | case 77 | when response.response_code > 100 && response.response_code < 199 78 | config.statsd_client.increment("backend.response.status_1xx.count", tags: stats_tags) 79 | when response.response_code > 200 && response.response_code < 299 80 | config.statsd_client.increment("backend.response.status_2xx.count", tags: stats_tags) 81 | when response.response_code > 300 && response.response_code < 399 82 | config.statsd_client.increment("backend.response.status_3xx.count", tags: stats_tags) 83 | when response.response_code > 400 && response.response_code < 499 84 | config.statsd_client.increment("backend.response.status_4xx.count", tags: stats_tags) 85 | when response.response_code > 500 86 | config.statsd_client.increment("backend.response.status_5xx.count", tags: stats_tags) 87 | end 88 | logger.debug "Completed request #{method.to_s.upcase} #{uri}, took #{elapsed.to_i}ms, got status #{response.response_code}" if logger 89 | end 90 | 91 | RetriableResponseFuture.new(req) 92 | end 93 | 94 | 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/px/service/client/caching/cache_entry.rb: -------------------------------------------------------------------------------- 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 | raise ArgumentError.new('Cache client has not been set.') unless cache_client.present? 22 | 23 | self.expires_at = DateTime.now + expires_in 24 | 25 | ActiveSupport::Notifications.instrument("store.caching", { url: url, policy_group: policy_group, expires_in: expires_in} ) do 26 | real_expiry = real_cache_expiry(expires_in, refresh_window: refresh_window) 27 | cache_client.multi do 28 | data_json = data.is_a?(Hash) ? data.to_json : data 29 | cache_client.set(cache_key(:data), data_json, real_expiry) 30 | cache_client.set(cache_key(:meta), metadata, real_expiry) 31 | end 32 | end 33 | end 34 | 35 | ## 36 | # Fetch an entry from the cache. Returns the entry if it's present, otherwise returns nil 37 | def self.fetch(cache_client, url, policy_group) 38 | raise ArgumentError.new('Cache client has not been set.') unless cache_client.present? 39 | 40 | key_values = nil 41 | data_key = cache_key(url, policy_group, :data) 42 | meta_key = cache_key(url, policy_group, :meta) 43 | ActiveSupport::Notifications.instrument("get.caching", { url: url, policy_group: policy_group } ) do 44 | key_values = cache_client.get_multi(data_key, meta_key) 45 | end 46 | 47 | data_json = key_values[data_key] 48 | meta_json = key_values[meta_key] 49 | if data_json && meta_json 50 | data = JSON.parse(data_json) 51 | meta = JSON.parse(meta_json) 52 | CacheEntry.new(cache_client, meta['url'], meta['pg'], data, meta['expires_at']) 53 | else 54 | nil 55 | end 56 | end 57 | 58 | ## 59 | # Touch this entry in the cache, updating its expiry time but not its data 60 | def touch(expires_in, refresh_window: 5.minutes) 61 | raise ArgumentError.new('Cache client has not been set.') unless cache_client.present? 62 | 63 | self.expires_at = DateTime.now + expires_in 64 | 65 | ActiveSupport::Notifications.instrument("touch.caching", { url: url, policy_group: policy_group, expires_in: expires_in} ) do 66 | real_expiry = real_cache_expiry(expires_in, refresh_window: refresh_window) 67 | 68 | cache_client.touch(cache_key(:data), real_expiry) 69 | cache_client.set(cache_key(:meta), metadata, real_expiry) 70 | end 71 | end 72 | 73 | private 74 | 75 | def metadata 76 | { 77 | "url" => url, 78 | "pg" => policy_group, 79 | "expires_at" => expires_at, 80 | }.to_json 81 | end 82 | 83 | def cache_key(type) 84 | self.class.cache_key(url, policy_group, type) 85 | end 86 | 87 | def self.cache_key(url, policy_group, type) 88 | "#{policy_group}_#{cache_key_base(url)}_#{type}" 89 | end 90 | 91 | ## 92 | # Get the cache key for the given query string 93 | def self.cache_key_base(url) 94 | md5 = Digest::MD5.hexdigest(url.to_s) 95 | "#{self.class.name.parameterize}_#{md5}" 96 | end 97 | 98 | def real_cache_expiry(expires_in, refresh_window: nil) 99 | (expires_in + refresh_window).to_i 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/px/service/client/circuit_breaker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Px::Service::Client::CircuitBreaker do 4 | let(:subject_class) { 5 | Class.new(Px::Service::Client::Base).tap do |c| 6 | # Anonymous classes don't have a name. Stub out :name so that things work 7 | allow(c).to receive(:name).and_return("CircuitBreaker") 8 | c.include(Px::Service::Client::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 '#make_request' do 33 | let(:url) { "http://test" } 34 | let(:multi) { Px::Service::Client::Multiplexer.new } 35 | let(:response) do 36 | Typhoeus::Response.new( 37 | code: 200, 38 | body: { status: 200, message: "Success" }.to_json, 39 | headers: { "Content-Type" => "application/json"} ) 40 | end 41 | 42 | let(:request) do 43 | req = @object.send(:make_request, 'get', url) 44 | 45 | multi.context do 46 | multi.do(req) 47 | end.run 48 | 49 | req 50 | end 51 | 52 | before :each do 53 | @object = subject 54 | Typhoeus.stub(url).and_return(response) 55 | end 56 | 57 | context "when the underlying request method succeeds" do 58 | it "returns a RetriableResponseFuture" do 59 | expect(subject.send(:make_request, 'get', url)).to be_a_kind_of(Px::Service::Client::RetriableResponseFuture) 60 | end 61 | 62 | it "returns the return value" do 63 | expect(request.value!).to eq(response) 64 | end 65 | 66 | context "when the breaker is open" do 67 | before :each do 68 | allow(subject_class.circuit_handler).to receive(:is_timeout_exceeded).and_return(true) 69 | 70 | subject.circuit_state.trip 71 | subject.circuit_state.last_failure_time = Time.now 72 | subject.circuit_state.failure_count = 5 73 | end 74 | 75 | it "resets the failure count of the breaker" do 76 | expect { 77 | request.value! 78 | }.to change{subject.class.circuit_state.failure_count}.to(0) 79 | end 80 | 81 | it "closes the breaker" do 82 | expect { 83 | request.value! 84 | }.to change{subject.class.circuit_state.closed?}.from(false).to(true) 85 | end 86 | end 87 | end 88 | 89 | context "when the wrapped method fails with a ServiceRequestError" do 90 | let(:response) do 91 | Typhoeus::Response.new( 92 | code: 404, 93 | body: { status: 404, error: "Not Found"}.to_json, 94 | headers: { "Content-Type" => "application/json"} ) 95 | end 96 | 97 | it "raises a ServiceRequestError" do 98 | expect { 99 | request.value! 100 | }.to raise_error(Px::Service::ServiceRequestError, "Not Found") 101 | end 102 | 103 | it "does not increment the failure count of the breaker" do 104 | expect { 105 | request.value! rescue nil 106 | }.not_to change{subject.class.circuit_state.failure_count} 107 | end 108 | end 109 | 110 | context "when the wrapped method fails with a ServiceError" do 111 | let(:response) do 112 | Typhoeus::Response.new( 113 | code: 500, 114 | body: { status: 500, error: "Error"}.to_json, 115 | headers: { "Content-Type" => "application/json"} ) 116 | end 117 | 118 | it "raises a ServiceError" do 119 | expect { 120 | request.value! 121 | }.to raise_error(Px::Service::ServiceError, "Error") 122 | end 123 | 124 | it "increments the failure count of the breaker" do 125 | expect { 126 | request.value! rescue nil 127 | }.to change{subject.class.circuit_state.failure_count}.by(1) 128 | end 129 | end 130 | 131 | context "when the circuit is open" do 132 | before :each do 133 | subject.circuit_state.trip 134 | subject.circuit_state.last_failure_time = Time.now 135 | end 136 | 137 | it "raises a ServiceError" do 138 | expect { 139 | request.value! 140 | }.to raise_error(Px::Service::ServiceError) 141 | end 142 | end 143 | 144 | context "with multiple classes" do 145 | let(:other_class) { 146 | Class.new(Px::Service::Client::Base).tap do |c| 147 | # Anonymous classes don't have a name. Stub out :name so that things work 148 | allow(c).to receive(:name).and_return("OtherCircuitBreaker") 149 | c.include(Px::Service::Client::CircuitBreaker) 150 | end 151 | } 152 | 153 | let(:other) { other_class.new } 154 | 155 | context "when the breaker opens on the first instance" do 156 | before :each do 157 | subject.circuit_state.trip 158 | subject.circuit_state.last_failure_time = Time.now 159 | end 160 | 161 | it "raises a ServiceError on the first instance" do 162 | expect { 163 | request.value! 164 | }.to raise_error(Px::Service::ServiceError) 165 | end 166 | 167 | it "does not raise a ServiceError on the second instance" do 168 | @object = other 169 | expect(request.value!).to eq(response) 170 | end 171 | end 172 | end 173 | 174 | context "with multiple instances of the same class" do 175 | let(:other) { subject_class.new } 176 | 177 | context "when the breaker opens on the first instance" do 178 | before :each do 179 | subject.circuit_state.trip 180 | subject.circuit_state.last_failure_time = Time.now 181 | end 182 | 183 | it "raises a ServiceError on the first instance" do 184 | expect { 185 | request.value! 186 | }.to raise_error(Px::Service::ServiceError) 187 | end 188 | 189 | it "raises a ServiceError on the second instance" do 190 | @object = other 191 | expect { 192 | request.value! 193 | }.to raise_error(Px::Service::ServiceError) 194 | end 195 | end 196 | end 197 | 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/px/service/client/caching.rb: -------------------------------------------------------------------------------- 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 | 22 | configure do |config| 23 | config.cache_expiry = 30.seconds 24 | config.cache_default_policy_group = 'general' 25 | config.cache_logger = nil 26 | config.cache_client = nil 27 | end 28 | 29 | # DEPRECATED: Use .configure (base class method) instead 30 | alias_method :caching, :configure 31 | end 32 | 33 | def cache_request(url, strategy: nil, policy_group: config.cache_default_policy_group, expires_in: config.cache_expiry, refresh_probability: 1) 34 | case strategy 35 | when :last_resort 36 | cache_last_resort(url, policy_group: policy_group, expires_in: expires_in, refresh_probability: refresh_probability) { yield } 37 | when :first_resort 38 | cache_first_resort(url, policy_group: policy_group, expires_in: expires_in) { yield } 39 | else 40 | no_cache { yield } 41 | end 42 | end 43 | 44 | private 45 | 46 | ## 47 | # Use the cache as a last resort. This path will make the request each time, caching the result 48 | # on success. If an exception occurs, the cache is checked for a result. If the cache has a result, it's 49 | # returned and the cache entry is touched to prevent expiry. Otherwise, the original exception is re-raised. 50 | def cache_last_resort(url, policy_group: 'general', expires_in: nil, refresh_probability: 1) 51 | tags = [ 52 | "cache_type:last_resort", 53 | "cache_policy_group:#{policy_group}", 54 | ] 55 | 56 | # Note we use a smaller refresh window here (technically, could even use 0) 57 | # since we don't really need the "expired but not really expired" behaviour when caching as a last resort. 58 | retry_response = yield 59 | 60 | Future.new do 61 | begin 62 | raise ArgumentError.new('Block did not return a Future.') unless retry_response.is_a?(Future) 63 | resp = retry_response.value! 64 | entry = CacheEntry.new(config.cache_client, url, policy_group, resp) 65 | 66 | # Only store a new result if we roll a 0 67 | r = rand(refresh_probability) 68 | if r == 0 69 | entry.store(expires_in, refresh_window: 1.minute) 70 | config.statsd_client.increment("caching.write.count", tags: tags) 71 | end 72 | resp 73 | rescue Px::Service::ServiceError => ex 74 | config.cache_logger.error "Service responded with exception: #{ex.class.name}: #{ex.message}\n#{ex.backtrace.join('\n')}" if config.cache_logger 75 | entry = CacheEntry.fetch(config.cache_client, url, policy_group) 76 | if entry.nil? 77 | # Re-raise the error, no cached response 78 | config.statsd_client.increment("caching.fetch.count", tags: tags + ["result:miss"]) 79 | raise ex 80 | end 81 | 82 | config.statsd_client.increment("caching.fetch.count", tags: tags + ["result:hit"]) 83 | entry.touch(expires_in, refresh_window: 1.minute) 84 | entry.data 85 | end 86 | end 87 | end 88 | 89 | ## 90 | # Use the cache as a first resort. This path will only make a request if there is no entry in the cache 91 | # or if the cache entry has expired. It follows logic similar to ActiveSupport::Cache. If the cache entry 92 | # has expired (but is still present) and the request fails, the cached value is still returned, as if this was 93 | # cache_last_resort. 94 | def cache_first_resort(url, policy_group: 'general', expires_in: nil) 95 | tags = [ 96 | "cache_type:last_resort", 97 | "cache_policy_group:#{policy_group}", 98 | ] 99 | entry = CacheEntry.fetch(config.cache_client, url, policy_group) 100 | 101 | if entry 102 | if entry.expired? 103 | # Cache entry exists but is expired. This call to cache_first_resort will refresh the cache by 104 | # calling the block, but to prevent lots of others from also trying to refresh, first it updates 105 | # the expiry date on the entry so that other callers that come in while we're requesting the update 106 | # don't also try to update the cache. 107 | config.statsd_client.increment("caching.fetch.count", tags: tags + ["result:expired"]) 108 | entry.touch(expires_in) 109 | else 110 | config.statsd_client.increment("caching.fetch.count", tags: tags + ["result:hit"]) 111 | return Future.new { entry.data } 112 | end 113 | end 114 | 115 | retry_response = yield 116 | 117 | Future.new do 118 | begin 119 | raise ArgumentError.new('Block did not return a Future.') unless retry_response.is_a?(Future) 120 | resp = retry_response.value! 121 | entry = CacheEntry.new(config.cache_client, url, policy_group, resp) 122 | entry.store(expires_in) 123 | config.statsd_client.increment("caching.write.count", tags: tags) 124 | resp 125 | rescue Px::Service::ServiceError => ex 126 | config.cache_logger.error "Service responded with exception: #{ex.class.name}: #{ex.message}\n#{ex.backtrace.join('\n')}" if config.cache_logger 127 | 128 | entry = CacheEntry.fetch(config.cache_client, url, policy_group) 129 | if entry.nil? 130 | # Re-raise the error, no cached response 131 | # config.statsd_client.increment("caching.fetch.count", tags: tags + ["result:miss"]) 132 | raise ex 133 | end 134 | config.statsd_client.increment("caching.fetch.count", tags: tags + ["result:hit"]) 135 | 136 | # Set the entry to be expired again (but reset the refresh window). This allows the next call to try again 137 | # (assuming the circuit breaker is reset) but keeps the value in the cache in the meantime 138 | entry.touch(0.seconds) 139 | entry.data 140 | end 141 | end 142 | 143 | rescue ArgumentError => ex 144 | Future.new { ex } 145 | end 146 | 147 | def no_cache 148 | retry_response = yield 149 | 150 | Future.new do 151 | raise ArgumentError.new('Block did not return a Future.') unless retry_response.is_a?(Future) 152 | 153 | retry_response.value! 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/px/service/client/future_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Px::Service::Client::Future do 4 | subject { Px::Service::Client::Future.new } 5 | let(:value) { "value" } 6 | 7 | describe '#complete' do 8 | it "calls any pending methods on the response" do 9 | expect(value).to receive(:size) 10 | called = false 11 | subject 12 | 13 | Fiber.new do 14 | subject.size 15 | called = true 16 | end.resume 17 | 18 | Fiber.new do 19 | subject.complete(value) 20 | end.resume 21 | 22 | expect(called).to eq(true) 23 | end 24 | end 25 | 26 | describe '#completed?' do 27 | context "when the future is completed" do 28 | it "returns true" do 29 | Fiber.new do 30 | subject.complete(value) 31 | end.resume 32 | 33 | expect(subject.completed?).to eq(true) 34 | end 35 | end 36 | 37 | context "when the future is not completed" do 38 | it "returns false" do 39 | expect(subject.completed?).to eq(false) 40 | end 41 | end 42 | end 43 | 44 | describe '#value' do 45 | context "when the future is not complete" do 46 | it "does not call the method on the value" do 47 | called = false 48 | Fiber.new do 49 | subject.value 50 | called = true 51 | end.resume 52 | 53 | expect(called).to eq(false) 54 | end 55 | end 56 | 57 | context "when the future is already complete" do 58 | it "returns the value" do 59 | subject.complete(value) 60 | expect(subject.value).to eq(value) 61 | end 62 | end 63 | 64 | context "when the value is an exception" do 65 | it "returns the exception" do 66 | Fiber.new do 67 | expect(subject.value).to be_a(ArgumentError) 68 | end.resume 69 | 70 | Fiber.new do 71 | subject.complete(ArgumentError.new("Error")) 72 | end.resume 73 | end 74 | end 75 | 76 | context "when the method returns a value" do 77 | it "returns the value" do 78 | Fiber.new do 79 | expect(subject.value).to eq(value) 80 | end.resume 81 | 82 | Fiber.new do 83 | subject.complete(value) 84 | end.resume 85 | end 86 | end 87 | end 88 | 89 | describe '#value!' do 90 | context "when the future is not complete" do 91 | it "does not call the method on the value" do 92 | called = false 93 | Fiber.new do 94 | subject.value! 95 | called = true 96 | end.resume 97 | 98 | expect(called).to eq(false) 99 | end 100 | end 101 | 102 | context "when the future is already complete" do 103 | it "returns the value" do 104 | subject.complete(value) 105 | expect(subject.value!).to eq(value) 106 | end 107 | end 108 | 109 | context "when the value is an exception" do 110 | it "returns the exception" do 111 | Fiber.new do 112 | expect { 113 | subject.value! 114 | }.to raise_error(ArgumentError) 115 | end.resume 116 | 117 | Fiber.new do 118 | subject.complete(ArgumentError.new("Error")) 119 | end.resume 120 | end 121 | end 122 | 123 | context "when the method returns a value" do 124 | it "returns the value" do 125 | Fiber.new do 126 | expect(subject.value!).to eq(value) 127 | end.resume 128 | 129 | Fiber.new do 130 | subject.complete(value) 131 | end.resume 132 | end 133 | end 134 | end 135 | 136 | describe '#method_missing' do 137 | context "when the future is already complete" do 138 | context "when the method raised an exception" do 139 | before :each do 140 | subject.complete(nil) 141 | end 142 | 143 | it "raises the exception" do 144 | Fiber.new do 145 | expect { 146 | subject.size 147 | }.to raise_error(NoMethodError) 148 | end.resume 149 | end 150 | end 151 | 152 | context "when the method doesn't exist" do 153 | before :each do 154 | subject.complete("I am a string") 155 | end 156 | 157 | it "raises an exception" do 158 | Fiber.new do 159 | expect { 160 | subject.not_exist 161 | }.to raise_error(NoMethodError) 162 | end.resume 163 | end 164 | end 165 | 166 | context "when the method returns a value" do 167 | before :each do 168 | subject.complete(value) 169 | end 170 | 171 | it "returns the value" do 172 | Fiber.new do 173 | expect(subject.size).to eq(value.size) 174 | end.resume 175 | end 176 | end 177 | end 178 | 179 | context "when the future is not complete" do 180 | it "does not call the method on the value" do 181 | expect(value).not_to receive(:size) 182 | 183 | Fiber.new do 184 | subject.size 185 | end.resume 186 | end 187 | 188 | context "when the method raised an exception" do 189 | it "raises the exception" do 190 | Fiber.new do 191 | expect { 192 | subject.size 193 | }.to raise_error(NoMethodError) 194 | end.resume 195 | 196 | Fiber.new do 197 | subject.complete(nil) 198 | end.resume 199 | end 200 | end 201 | 202 | context "when the method doesn't exist on the eventual value" do 203 | it "raises an exception" do 204 | Fiber.new do 205 | expect { 206 | subject.not_exist 207 | }.to raise_error(NoMethodError) 208 | end.resume 209 | 210 | Fiber.new do 211 | subject.complete("I am a string") 212 | end.resume 213 | end 214 | end 215 | 216 | context "when the method returns a value" do 217 | it "returns the value" do 218 | Fiber.new do 219 | expect(subject.size).to eq(value.size) 220 | end.resume 221 | 222 | Fiber.new do 223 | subject.complete(value) 224 | end.resume 225 | end 226 | end 227 | end 228 | end 229 | 230 | describe '#initialize' do 231 | context 'when a block is given' do 232 | context 'when the block returns a value' do 233 | let(:value) { rand } 234 | 235 | subject { Px::Service::Client::Future.new { value } } 236 | 237 | it 'passes the value to the pending call' do 238 | expect(subject.value).to eq(value) 239 | end 240 | end 241 | 242 | context 'when the block throws an error' do 243 | let(:error) { RuntimeError.new('error') } 244 | subject { Px::Service::Client::Future.new { raise error } } 245 | 246 | it 'passes the error to the pending call' do 247 | expect(subject.value).to eq(error) 248 | end 249 | end 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | px-service-client 2 | ================= 3 | 4 | [![Build Status](https://semaphoreci.com/api/v1/500px/px-service-client/branches/master/badge.svg)](https://semaphoreci.com/500px/px-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 < Px::Service::Client::Base 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::Base 41 | This class provides a basic `make_request(method, url, ...)` method that produces an asynchronous request. The method immediately returns a `Future`. It works together with `Multiplexer`(discussed below) and uses [Typhoeus](https://github.com/typhoeus/typhoeus) as the underlying HTTP client to support asynchronicity. 42 | 43 | **Clients should subclass this class and include other features/mixins, if needed.** 44 | 45 | # Optional 46 | config do |config| 47 | config.statsd_client = Statsd.new(host, port) 48 | end 49 | 50 | 51 | See the following section for an example of how to use `make_request` and `Multiplexer`. 52 | 53 | #### Px::Service::Client::Multiplexer 54 | This class works together with `Px::Service::Client::Base` sub-classes to support request parallel execution. 55 | 56 | Example: 57 | 58 | ```Ruby 59 | multi = Px::Service::Client::Multiplexer.new 60 | 61 | multi.context do 62 | method = :get 63 | url = 'http://www.example.com' 64 | req = make_request(method, url) # returns a Future 65 | multi.do(req) # queues the request/future into hydra 66 | end 67 | 68 | multi.run # a blocking call, like hydra.run 69 | 70 | ``` 71 | `multi.context` encapsulates the block into a [`Fiber`](http://ruby-doc.org/core-2.2.0/Fiber.html) object and immediately runs (or `resume`, in Fiber's term) that fiber until the block explicitly gives up control. The method returns `multi` itself. 72 | 73 | `multi.do(request_or_future,retries)` queues the request into `hydra`. It always returns a `Future`. A [`Typhoeus::Request`](https://github.com/typhoeus/typhoeus) will be converted into a `Future ` in this call. 74 | 75 | Finally, `multi.run` starts `hydra` to execute the requests in parallel. The request is made as soon as the multiplexer is started. You get the results of the request by evaluating the value of the `Future`. 76 | 77 | #### Px::Service::Client::Caching 78 | 79 | Provides client-side response caching of service requests. 80 | 81 | ```ruby 82 | include Px::Service::Client::Caching 83 | 84 | # Optional 85 | config do |config| 86 | config.cache_expiry = 30.seconds 87 | config.cache_default_policy_group = 'general' 88 | config.cache_client = Dalli::Client.new(...) 89 | config.cache_logger = Logger.new(STDOUT) # or Rails.logger, for example. Can be nil. 90 | end 91 | 92 | # An example of a cached request 93 | result = cache_request(url, :last_resort, refresh_probability: 1) do 94 | req = make_request(method, url) 95 | response = @multi.do(req) 96 | 97 | # cache_request() expects a future that returns the result to be cached 98 | Px::Service::Client::Future.new do 99 | JSON.parse(response.body) 100 | end 101 | end 102 | ``` 103 | 104 | `cache_request` expects a block that returns a `Future` object. The return value (usually the response body) of that future will be cached. `cache_request` always returns a future. By evaluating the future, i.e., via the `Future.value!` call, you get the result (whether cached or not). 105 | 106 | 107 | **Note**: DO NOT cache the `Typhoeus::Response` directly (See the below code snippet), because the response object cannot be serializable to be stored in memcached. That's the reason why we see warning message: `You are trying to cache a Ruby object which cannot be serialized to memcached.` 108 | 109 | ``` 110 | # An incorrect example of using cache_request() 111 | cache_request(url, :last_resort) do 112 | req = make_request(method, url) 113 | response = @multi.do(req) # DO NOT do this 114 | end 115 | 116 | ``` 117 | Responses are cached in either a *last-resort* or *first-resort* manner. 118 | 119 | *last-resort* means that the cached value is only used when the service client request fails (with a 120 | `ServiceError`). If the service client request succeeds, there is a chance that the cache value may get refreshed. The `refresh_probability` is provided to let the cached value 121 | be refreshed probabilistically (rather than on every request). 122 | 123 | If the service client request fails and there is a `ServiceError`, `cache_logger` will record the exception message, and attempt to read the existing cache value. 124 | 125 | *first-resort* means that the cached value is always used, if present. If the cached value is present but expired, the it sends the service client request and, if the request succeeds, it refreshes the cached value expiry. If the request fails, it uses the expired cached value, but the value remain expired. A retry may be needed. 126 | 127 | 128 | 129 | #### Px::Service::Client::CircuitBreaker 130 | This mixin overrides `Px::Service::Client::Base#make_request` method and implements the circuit breaker pattern. 131 | 132 | ```ruby 133 | include Px::Service::Client::CircuitBreaker 134 | 135 | # Optional 136 | circuit_handler do |handler| 137 | handler.logger = Logger.new(STDOUT) 138 | handler.failure_threshold = 5 139 | handler.failure_timeout = 5 140 | handler.invocation_timeout = 10 141 | handler.excluded_exceptions += [NotConsideredFailureException] 142 | end 143 | 144 | # An example of a make a request with circuit breaker 145 | req = make_request(method, url) # overrides Px::Service::Client::Base 146 | ``` 147 | 148 | Adds a circuit breaker to the client. `make_request` always returns `Future` 149 | 150 | The circuit will open on any exception from the wrapped method, or if the request runs for longer than the `invocation_timeout`. 151 | 152 | If the circuit is open, any future request will be get an error message wrapped in `Px::Service::ServiceError`. 153 | 154 | By default, `Px::Service::ServiceRequestError` is excluded by the handler. That is, when the request fails with a `ServiceRequestError` exceptions, the same `ServiceRequestError` will be raised. But it does NOT increase the failure count or trip the breaker, as these exceptions indicate an error on the caller's part (e.g. an HTTP 4xx error). 155 | 156 | Every instance of the class that includes the `CircuitBreaker` concern will share the same circuit state. You should therefore include `Px::Service::Client::CircuitBreaker` in the most-derived class that subclasses 157 | `Px::Service::Client::Base`. 158 | 159 | This module is based on (and uses) the [Circuit Breaker](https://github.com/wsargent/circuit_breaker) gem by Will Sargent. 160 | 161 | #### Px::Service::Client::HmacSigning 162 | Similar to `Px::Service::Client::CircuitBreaker`, this mixin overrides `Px::Service::Client::Base#make_request` method and appends a HMAC signature in the request header. 163 | 164 | To use this mixin: 165 | 166 | ```ruby 167 | class MyClient < Px::Service::Client::Base 168 | include Px::Service::Client::HmacSigning 169 | 170 | #optional 171 | config do |config| 172 | config.hmac_secret = 'mykey' 173 | config.hmac_keyspan = 300 174 | end 175 | end 176 | ``` 177 | 178 | Note: `key` and `keyspan` are class variables and shared among instances of the same class. 179 | 180 | The signature is produced from the secret key, a nonce, HTTP method, url, query, body. The nonce is generated from the timestamp. 181 | 182 | To retrieve and verify the signature: 183 | 184 | ```ruby 185 | # Make a request with signed headers 186 | resp = make_request(method, url, query, headers, body) 187 | 188 | signature = resp.request.options[:headers]["X-Service-Auth"] 189 | timestamp = resp.request.options[:headers]["Timestamp"] 190 | 191 | # Call the class method to regenerate the signature 192 | expected_signature = MyClient.generate_signature(method, url, query, body, timestamp) 193 | 194 | # assert signature == expected_signature 195 | ``` 196 | 197 | #### Px::Service::Client::ListResponse 198 | 199 | ```ruby 200 | def get_something(page, page_size) 201 | response = JSON.parse(http_get("http://some/url?p=#{page}&l=#{page_size}")) 202 | return Px::Service::Client::ListResponse(page_size, response, "items") 203 | end 204 | ``` 205 | 206 | Wraps a deserialized response. A `ListResponse` implements the Ruby `Enumerable` module, as well 207 | as the methods required to work with [WillPaginate](https://github.com/mislav/will_paginate). 208 | 209 | It assumes that the response resembles this form: 210 | ```json 211 | { 212 | "current_page": 1, 213 | "total_items": 100, 214 | "total_pages": 10, 215 | "items": [ 216 | { /* item 1 */ }, 217 | { /* item 2 */ }, 218 | ... 219 | ] 220 | } 221 | ``` 222 | 223 | The name of the `"items"` key is given in the third argument. 224 | 225 | License 226 | ------- 227 | 228 | The MIT License (MIT) 229 | 230 | Copyright (c) 2014 500px, Inc. 231 | 232 | Permission is hereby granted, free of charge, to any person obtaining a copy 233 | of this software and associated documentation files (the "Software"), to deal 234 | in the Software without restriction, including without limitation the rights 235 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 236 | copies of the Software, and to permit persons to whom the Software is 237 | furnished to do so, subject to the following conditions: 238 | 239 | The above copyright notice and this permission notice shall be included in 240 | all copies or substantial portions of the Software. 241 | 242 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 243 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 244 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 245 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 246 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 247 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 248 | THE SOFTWARE. 249 | -------------------------------------------------------------------------------- /spec/px/service/client/caching/caching_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'dalli' 3 | 4 | describe Px::Service::Client::Caching do 5 | let(:dalli_host) { ENV['PX_MEMCACHED_HOST'] } 6 | let(:dalli_options) { { :namespace => "service-client-test", expires_in: 3600, compress: false, failover: false } } 7 | let(:dalli) { Dalli::Client.new(dalli_host, dalli_options) } 8 | 9 | subject { 10 | Class.new(Px::Service::Client::Base).tap do |c| 11 | c.include(Px::Service::Client::Caching) 12 | 13 | # Anonymous classes don't have a name. Stub out :name so that things work 14 | allow(c).to receive(:name).and_return("Caching") 15 | 16 | c.configure do |config| 17 | config.cache_client = dalli 18 | end 19 | end.new 20 | } 21 | 22 | let (:url) { "http://search/foo?bar=baz" } 23 | let(:multi) { Px::Service::Client::Multiplexer.new } 24 | let(:request) { Typhoeus::Request.new(url, method: :get) } 25 | let(:future) { Px::Service::Client::RetriableResponseFuture.new(request) } 26 | let(:response) do 27 | Typhoeus::Response.new( 28 | code: 200, 29 | body: { status: 200, message: "Success" }.to_json, 30 | headers: { "Content-Type" => "application/json"} ) 31 | end 32 | let(:entry) { Px::Service::Client::Caching::CacheEntry.new(dalli, url, 'general', response.options) } 33 | let(:strategy) { :none } 34 | 35 | before :each do 36 | dalli.flush_all 37 | Typhoeus.stub(url).and_return(response) 38 | end 39 | 40 | shared_examples_for "a successful request" do 41 | it "should call the block" do 42 | called = false 43 | subject.cache_request(url, strategy: strategy) do 44 | Px::Service::Client::Future.new { called = true } 45 | end 46 | 47 | expect(called).to be_truthy 48 | end 49 | 50 | it "should return the block's return value" do 51 | expect(subject.cache_request(url, strategy: strategy) do 52 | resp = nil 53 | multi.context do 54 | resp = multi.do(future) 55 | end.run 56 | 57 | resp 58 | end.value!.options).to eq(response.options) 59 | end 60 | end 61 | 62 | shared_examples_for "a failed uncacheable request" do 63 | it "should raise the exception raised by the block" do 64 | expect { 65 | subject.cache_request(url, strategy: strategy) do 66 | # Px::Service::ServiceRequestError is not cachable 67 | # and does not trigger a fallback to a cached response 68 | raise Px::Service::ServiceRequestError.new("Error", 404) 69 | end.value! 70 | }.to raise_error(Px::Service::ServiceRequestError) 71 | end 72 | end 73 | 74 | shared_examples_for "a request with no cached response" do 75 | it "raises the exception" do 76 | expect { 77 | subject.cache_request(url, strategy: strategy) do 78 | raise Px::Service::ServiceError.new("Error", 500) 79 | end.value! 80 | }.to raise_error(Px::Service::ServiceError) 81 | end 82 | end 83 | 84 | context "when not caching" do 85 | it_behaves_like "a successful request" 86 | it_behaves_like "a failed uncacheable request" 87 | 88 | context 'when cache client is not set' do 89 | before :each do 90 | subject.configure do |config| 91 | config.cache_client = nil 92 | end 93 | end 94 | 95 | it 'does not raise an exception' do 96 | expect { 97 | subject.cache_request(url, strategy: strategy) do 98 | nil 99 | end 100 | }.to_not raise_error 101 | end 102 | end 103 | end 104 | 105 | context "when caching as a last resort" do 106 | let(:strategy) { :last_resort } 107 | 108 | it_behaves_like "a successful request" 109 | it_behaves_like "a failed uncacheable request" 110 | 111 | context "when there is a cached response" do 112 | context 'when cache client is not set' do 113 | before :each do 114 | subject.configure do |config| 115 | config.cache_client = nil 116 | end 117 | end 118 | 119 | it 'raises an argument exception' do 120 | expect { 121 | subject.cache_request(url, strategy: strategy) do 122 | Px::Service::Client::Future.new { raise Px::Service::ServiceError.new("Error", 500) } 123 | end.value! 124 | }.to raise_error(ArgumentError) 125 | end 126 | end 127 | 128 | context 'when the cache client is set' do 129 | before :each do 130 | Px::Service::Client::Caching::CacheEntry.stub(:fetch).and_return(entry) 131 | end 132 | 133 | it "returns the cached response on failure" do 134 | expect(subject.cache_request(url, strategy: strategy) do 135 | Px::Service::Client::Future.new { raise Px::Service::ServiceError.new("Error", 500) } 136 | end.value!).to eq(response.options) 137 | end 138 | 139 | it "does not returns the cached response on request error" do 140 | expect { 141 | subject.cache_request(url, strategy: strategy) do 142 | Px::Service::Client::Future.new { raise Px::Service::ServiceRequestError.new("Error", 404) } 143 | end.value! 144 | }.to raise_error(Px::Service::ServiceRequestError) 145 | end 146 | 147 | it "touches the cache entry on failure" do 148 | expect(dalli).to receive(:touch).with(a_kind_of(String), a_kind_of(Fixnum)) 149 | 150 | subject.cache_request(url, strategy: strategy) do 151 | Px::Service::Client::Future.new { raise Px::Service::ServiceError.new("Error", 500) } 152 | end 153 | end 154 | end 155 | end 156 | 157 | it_behaves_like "a request with no cached response" 158 | end 159 | 160 | context "when caching as a first resort" do 161 | let(:strategy) { :first_resort } 162 | 163 | it_behaves_like "a successful request" 164 | it_behaves_like "a failed uncacheable request" 165 | 166 | context "when there is a cached response" do 167 | context 'when cache client is not set' do 168 | before :each do 169 | subject.configure do |config| 170 | config.cache_client = nil 171 | end 172 | end 173 | 174 | it 'raises an argument exception' do 175 | expect { 176 | subject.cache_request(url, strategy: strategy) do 177 | nil 178 | end.value! 179 | }.to raise_error(ArgumentError) 180 | end 181 | end 182 | 183 | context 'when the cache client is set' do 184 | before :each do 185 | Px::Service::Client::Caching::CacheEntry.stub(:fetch).and_return(entry) 186 | entry.expires_at = DateTime.now + 1.day 187 | end 188 | 189 | it "does not invoke the block" do 190 | called = false 191 | subject.cache_request(url, strategy: strategy) do 192 | called = true 193 | end 194 | 195 | expect(called).to be_falsey 196 | end 197 | 198 | it "returns the response" do 199 | expect(subject.cache_request(url, strategy: strategy) do 200 | Future.new do 201 | nil 202 | end 203 | end.value!).to eq(response.options) 204 | end 205 | end 206 | end 207 | 208 | context "when there is an expired cached response" do 209 | before :each do 210 | Px::Service::Client::Caching::CacheEntry.stub(:fetch).and_return(entry) 211 | entry.expires_at = DateTime.now - 1.day 212 | end 213 | 214 | let(:response) do 215 | Typhoeus::Response.new( 216 | code: 200, 217 | body: { status: 200, message: "New response" }.to_json, 218 | headers: { "Content-Type" => "application/json"} ) 219 | end 220 | 221 | it "invokes the block" do 222 | called = false 223 | subject.cache_request(url, strategy: strategy) do |u| 224 | Px::Service::Client::Future.new do 225 | called = true 226 | { stub: "stub str" }.to_hash 227 | end 228 | end 229 | 230 | expect(called).to be_truthy 231 | end 232 | 233 | it "returns the new response" do 234 | result = subject.cache_request(url, strategy: strategy) do 235 | resp = nil 236 | multi.context do 237 | resp = multi.do(future) 238 | end.run 239 | 240 | resp 241 | end.value! 242 | 243 | body = JSON.parse(result.body) 244 | 245 | expect(body[:message]).to eq(JSON.parse(response.body)[:message]) 246 | end 247 | 248 | it "updates the cache entry before making the request" do 249 | subject.cache_request(url, strategy: strategy) do 250 | # A bit goofy, but basically, make a request, but in the block 251 | # check that another request that happens while we're in the block 252 | # gets the cached result and doesn't invoke its own block 253 | called = false 254 | expect(subject.cache_request(url, strategy: strategy) do 255 | called = true 256 | resp = nil 257 | multi.context do 258 | resp = multi.do(future) 259 | end.run 260 | 261 | resp 262 | end.value!).to eq(response.options) 263 | 264 | expect(called).to be_falsey 265 | 266 | response 267 | end 268 | end 269 | 270 | it "caches the new response" do 271 | subject.cache_request(url, strategy: strategy) do 272 | resp = nil 273 | multi.context do 274 | resp = multi.do(future) 275 | end.run 276 | 277 | resp 278 | end 279 | 280 | expect(subject.cache_request(url, strategy: strategy) do 281 | nil 282 | end.value).to eq(response.options) 283 | end 284 | 285 | it "returns the cached response on failure" do 286 | expect(subject.cache_request(url, strategy: strategy) do 287 | Px::Service::Client::Future.new { raise Px::Service::ServiceError.new("Error", 500) } 288 | end.value!).to eq(response.options) 289 | end 290 | 291 | it "does not returns the cached response on request error" do 292 | expect { 293 | subject.cache_request(url, strategy: strategy) do 294 | Px::Service::Client::Future.new { raise Px::Service::ServiceRequestError.new("Error", 404) } 295 | end.value! 296 | }.to raise_error(Px::Service::ServiceRequestError) 297 | end 298 | 299 | it "touches the cache entry on failure" do 300 | expect(dalli).to receive(:touch).with(a_kind_of(String), a_kind_of(Fixnum)).twice 301 | 302 | subject.cache_request(url, strategy: strategy) do 303 | Px::Service::Client::Future.new { raise Px::Service::ServiceError.new("Error", 500) } 304 | end 305 | end 306 | end 307 | 308 | it_behaves_like "a request with no cached response" 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /spec/px/service/client/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Px::Service::Client::Base do 4 | let(:dalli_host) { ENV['PX_MEMCACHED_HOST'] } 5 | let(:dalli_options) { { :namespace => "service-client-test", expires_in: 3600, compress: false, failover: false } } 6 | let(:dalli) { Dalli::Client.new(dalli_host, dalli_options) } 7 | 8 | subject { 9 | Class.new(Px::Service::Client::Base).tap do |c| 10 | c.include(Px::Service::Client::Caching) 11 | c.configure do |config| 12 | config.cache_client = dalli 13 | end 14 | end.new 15 | } 16 | 17 | let(:successful_response) do 18 | Typhoeus::Response.new( 19 | code: 200, 20 | body: { status: 200, message: "Success" }.to_json, 21 | headers: { "Content-Type" => "application/json"} ) 22 | end 23 | 24 | describe '#config' do 25 | let(:other_subclass) { 26 | Class.new(Px::Service::Client::Base).tap do |c| 27 | c.include(Px::Service::Client::Caching) 28 | end.new 29 | } 30 | 31 | context "when there are separate subclasses" do 32 | before :each do 33 | subject.configure do |c| 34 | c.subject_field = "value" 35 | end 36 | end 37 | 38 | it "sets the config value on the subject" do 39 | expect(subject.config.subject_field).to eq("value") 40 | end 41 | 42 | it "does not set the config value on other subclasses" do 43 | expect(other_subclass.config.subject_field).not_to eq("value") 44 | end 45 | end 46 | 47 | context "when the subclass is itself inherited" do 48 | let(:subsubclass) { 49 | Class.new(subject.class).new 50 | } 51 | 52 | before :each do 53 | subject.configure do |c| 54 | c.subject_field = "value" 55 | end 56 | end 57 | 58 | it "sets the config value on the subject" do 59 | expect(subject.config.subject_field).to eq("value") 60 | end 61 | 62 | it "inherits the config value on the sub-subclass" do 63 | expect(subsubclass.config.subject_field).to eq("value") 64 | end 65 | 66 | context "when the config value is changed on the sub-subclass" do 67 | before :each do 68 | subsubclass.configure do |c| 69 | c.subject_field = "other" 70 | end 71 | end 72 | 73 | it "does not change the config value on the subject" do 74 | expect(subject.config.subject_field).to eq("value") 75 | end 76 | 77 | it "changes the config value on the sub-subclass" do 78 | expect(subsubclass.config.subject_field).to eq("other") 79 | end 80 | end 81 | end 82 | end 83 | 84 | describe '#make_request' do 85 | let(:url) { 'http://localhost:3000/path' } 86 | 87 | it "returns a future response" do 88 | expect(subject.send(:make_request, 'get', url)).to be_a(Px::Service::Client::Future) 89 | end 90 | 91 | context "with a header" do 92 | let(:expected_headers) { 93 | { 94 | "Cookie" => "_hpx1=cookie", 95 | } 96 | } 97 | 98 | let(:resp) { subject.send(:make_request, 'get', url, headers: expected_headers) } 99 | let(:headers) { resp.request.options[:headers] } 100 | 101 | it "sets the expected header" do 102 | expect(headers).to include(expected_headers) 103 | end 104 | end 105 | 106 | context "with a query" do 107 | let(:expected_query) { 108 | { 109 | "one" => "a", 110 | "two" => "b", 111 | } 112 | } 113 | 114 | let(:resp) { subject.send(:make_request, 'get', url, query: expected_query) } 115 | 116 | it "sets the query" do 117 | expect(resp.request.url).to include("one=a&two=b") 118 | end 119 | end 120 | 121 | context "when the caching strategy is set" do 122 | let(:multi) { Px::Service::Client::Multiplexer.new } 123 | let(:request) { Typhoeus::Request.new(url, method: :get) } 124 | let(:future) { Px::Service::Client::RetriableResponseFuture.new(request) } 125 | 126 | before :each do 127 | dalli.flush_all 128 | Typhoeus.stub(url).and_return(response) 129 | end 130 | 131 | shared_examples_for 'a request that returns a cached response body' do 132 | let(:cache_entry) { Px::Service::Client::Caching::CacheEntry.new(dalli, url, 'general', response.body, Time.now + 1.year) } 133 | 134 | before :each do 135 | Typhoeus::Expectation.clear 136 | Typhoeus.stub(url).and_return(successful_response) 137 | 138 | req = subject.send(:make_request, 'get', url) 139 | subject.cache_request(req.request.url, strategy: strategy) do 140 | resp = nil 141 | multi.context do 142 | resp = multi.do(req) 143 | end.run 144 | 145 | Px::Service::Client::Future.new do 146 | resp.options[:body] 147 | end 148 | end 149 | end 150 | 151 | it 'does not return a new response' do 152 | req = subject.send(:make_request, 'get', url) 153 | 154 | expect(Px::Service::Client::RetriableResponseFuture).to_not receive(:new) 155 | subject.cache_request(req.request.url, strategy: strategy) do 156 | resp = nil 157 | multi.context do 158 | resp = multi.do(req) 159 | end.run 160 | 161 | Px::Service::Client::Future.new do 162 | resp.options[:body] 163 | end 164 | end 165 | end 166 | 167 | it 'returns the cached response body' do 168 | Typhoeus::Expectation.clear 169 | Typhoeus.stub(url).and_return(response) 170 | req = subject.send(:make_request, 'get', url) 171 | subject.cache_request(req.request.url, strategy: strategy) do 172 | resp = nil 173 | 174 | multi.context do 175 | resp = multi.do(req) 176 | expect(resp.options[:body]).to eq(cache_entry.data) 177 | end 178 | 179 | Px::Service::Client::Future.new do 180 | resp.options[:body] 181 | end 182 | end 183 | end 184 | end 185 | 186 | context 'to first_resort' do 187 | let(:strategy) { :first_resort } 188 | let(:response) { successful_response } 189 | 190 | it_behaves_like 'a request that returns a cached response body' 191 | 192 | context 'when the request fails' do 193 | let(:response) do 194 | Typhoeus::Response.new( 195 | code: 500, 196 | body: { status: 500, error: "Failed"}.to_json, 197 | headers: { "Content-Type" => "application/json"} ) 198 | end 199 | 200 | context 'when no response is cached' do 201 | it 'makes the request' do 202 | called = false 203 | req = subject.send(:make_request, 'get', url) 204 | 205 | subject.cache_request(req.request.url, strategy: strategy) do 206 | resp = nil 207 | multi.context do 208 | resp = multi.do(req) 209 | called = true 210 | end.run 211 | 212 | Px::Service::Client::Future.new do 213 | resp.options[:body] 214 | end 215 | end 216 | 217 | expect(called).to be_truthy 218 | end 219 | 220 | it 'returns an error' do 221 | req = subject.send(:make_request, 'get', url) 222 | expect { 223 | subject.cache_request(req.request.url, strategy: strategy) do 224 | resp = nil 225 | multi.context do 226 | resp = multi.do(req) 227 | end.run 228 | 229 | Px::Service::Client::Future.new do 230 | resp.options[:body] 231 | end 232 | end.value! 233 | }.to raise_error(Px::Service::ServiceError, 'Failed') 234 | end 235 | end 236 | 237 | context 'when a response has been cached' do 238 | it_behaves_like 'a request that returns a cached response body' 239 | end 240 | end 241 | end 242 | 243 | context 'to last_resort' do 244 | let(:strategy) { :last_resort } 245 | let(:response) { successful_response } 246 | 247 | it 'makes the request' do 248 | called = false 249 | req = subject.send(:make_request, 'get', url) 250 | 251 | subject.cache_request(req.request.url, strategy: strategy) do 252 | resp = nil 253 | multi.context do 254 | resp = multi.do(req) 255 | called = true 256 | end.run 257 | 258 | Px::Service::Client::Future.new do 259 | resp.options[:body] 260 | end 261 | end 262 | 263 | expect(called).to be_truthy 264 | end 265 | 266 | context 'when the request fails' do 267 | let(:response) do 268 | Typhoeus::Response.new( 269 | code: 500, 270 | body: { status: 500, error: "Failed"}.to_json, 271 | headers: { "Content-Type" => "application/json"} ) 272 | end 273 | 274 | context 'when no response is cached' do 275 | it 'makes the request' do 276 | called = false 277 | req = subject.send(:make_request, 'get', url) 278 | 279 | subject.cache_request(req.request.url, strategy: strategy, refresh_probability: 0) do 280 | resp = nil 281 | multi.context do 282 | resp = multi.do(req) 283 | called = true 284 | end.run 285 | 286 | Px::Service::Client::Future.new do 287 | resp.options[:body] 288 | end 289 | end 290 | 291 | expect(called).to be_truthy 292 | end 293 | 294 | it 'raises an error' do 295 | req = subject.send(:make_request, 'get', url) 296 | 297 | expect { 298 | subject.cache_request(req.request.url, strategy: strategy) do 299 | resp = nil 300 | multi.context do 301 | resp = multi.do(req) 302 | end.run 303 | 304 | Px::Service::Client::Future.new do 305 | resp.options[:body] 306 | end 307 | end.value! 308 | }.to raise_error(Px::Service::ServiceError, 'Failed') 309 | end 310 | end 311 | 312 | context 'when a response body has been cached' do 313 | before :each do 314 | Typhoeus::Expectation.clear 315 | Typhoeus.stub(url).and_return(successful_response) 316 | 317 | req = subject.send(:make_request, 'get', url) 318 | 319 | subject.cache_request(req.request.url, strategy: strategy) do 320 | resp = nil 321 | multi.context do 322 | resp = multi.do(req) 323 | end.run 324 | 325 | Px::Service::Client::Future.new do 326 | resp.options[:body] 327 | end 328 | end 329 | end 330 | 331 | it 'makes the request' do 332 | called = false 333 | req = subject.send(:make_request, 'get', url) 334 | subject.cache_request(req.request.url, strategy: strategy) do 335 | resp = nil 336 | multi.context do 337 | resp = multi.do(req) 338 | called = true 339 | end.run 340 | 341 | Px::Service::Client::Future.new do 342 | resp.options[:body] 343 | end 344 | end 345 | 346 | expect(called).to be_truthy 347 | end 348 | 349 | it 'returns the cached response body' do 350 | Typhoeus::Expectation.clear 351 | Typhoeus.stub(url).and_return(response) 352 | req = subject.send(:make_request, 'get', url) 353 | 354 | expect(subject.cache_request(req.request.url, strategy: strategy) do 355 | resp = nil 356 | multi.context do 357 | resp = multi.do(req) 358 | end.run 359 | 360 | Px::Service::Client::Future.new do 361 | resp.options[:body] 362 | end 363 | end.value!['status']).to be(200) 364 | end 365 | end 366 | 367 | end 368 | end 369 | end 370 | end 371 | end 372 | --------------------------------------------------------------------------------