status_lib 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,17 @@
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
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 1.9.3-p484
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in status_lib.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,5 @@
1
+ guard :rspec, notification: false, all_after_pass: true do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
4
+ watch('spec/spec_helper.rb') { "spec" }
5
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Kevin McConnell
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # StatusLib
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'status_lib'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install status_lib
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/guard ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # This file was generated by Bundler.
4
+ #
5
+ # The application 'guard' is installed as part of a gem, and
6
+ # this file is here to facilitate running it.
7
+ #
8
+
9
+ require 'pathname'
10
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
11
+ Pathname.new(__FILE__).realpath)
12
+
13
+ require 'rubygems'
14
+ require 'bundler/setup'
15
+
16
+ load Gem.bin_path('guard', 'guard')
@@ -0,0 +1,33 @@
1
+ class StatusLib::Cache
2
+ class << self
3
+ def fetch(item, options={})
4
+ expires_in = options[:expires_in]
5
+ stored = items[item]
6
+ if stored.nil? || expired?(expires_in, timestamps[item])
7
+ stored = items[item] = yield
8
+ timestamps[item] = Time.now
9
+ end
10
+ stored
11
+ end
12
+
13
+ def clear!
14
+ @timestamps = @cached_items = nil
15
+ end
16
+
17
+ private
18
+
19
+ def items
20
+ @cached_items ||= {}
21
+ end
22
+
23
+ def timestamps
24
+ @timestamps ||= {}
25
+ end
26
+
27
+ def expired?(duration, recorded_time)
28
+ return false if duration.nil?
29
+ return true if recorded_time.nil?
30
+ (Time.now - recorded_time) > duration
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,61 @@
1
+ class StatusLib::Config
2
+ DEFAULT_CACHE_TIME = 5
3
+ DEFAULT_API_CALL_TIMEOUT = 1
4
+ DEFAULT_DOWN_DURATION = 0
5
+ DEFAULT_BLOCK_TIMEOUT = 5
6
+
7
+ ATTRIBUTES = [:server,
8
+ :exception_handler, :stats_handler,
9
+ :cache_time, :api_call_timeout,
10
+ :down_duration, :block_timeout]
11
+
12
+ attr_reader *ATTRIBUTES
13
+
14
+ def initialize
15
+ reset_to_defaults
16
+ end
17
+
18
+ def update(args)
19
+ ATTRIBUTES.each do |arg|
20
+ self.instance_variable_set("@#{arg}", args[arg] || defaults[arg]) if args.include?(arg)
21
+ end
22
+ clear_memoized
23
+ end
24
+
25
+ def status_info
26
+ @status_info ||= StatusLib::StatusInfo.new(status_api)
27
+ end
28
+
29
+ def status_api
30
+ @status_api ||= status_api_for_config
31
+ end
32
+
33
+ private
34
+
35
+ def reset_to_defaults
36
+ update(Hash[ATTRIBUTES.map { |attr| [attr, nil] }])
37
+ end
38
+
39
+ def defaults
40
+ {
41
+ cache_time: DEFAULT_CACHE_TIME,
42
+ api_call_timeout: DEFAULT_API_CALL_TIMEOUT,
43
+ down_duration: DEFAULT_DOWN_DURATION,
44
+ block_timeout: DEFAULT_BLOCK_TIMEOUT,
45
+ stats_handler: NullStatsHandler
46
+ }
47
+ end
48
+
49
+ def clear_memoized
50
+ @status_info = nil
51
+ @status_api = nil
52
+ end
53
+
54
+ def status_api_for_config
55
+ if server.nil?
56
+ StatusLib::NullStatusApi.new
57
+ else
58
+ StatusLib::StatusApi.new
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,6 @@
1
+ module NullObject
2
+ def method_missing(meth, *args, &block)
3
+ return meth.call(*args, &block) if respond_to?(meth)
4
+ yield if block_given?
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ class NullStatsHandler
2
+ extend NullObject
3
+ end
@@ -0,0 +1,62 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'timeout'
4
+ require 'uri'
5
+
6
+ class StatusLib::ApiDownError < Exception; end
7
+
8
+ class StatusLib::NullStatusApi
9
+ def get_status_list
10
+ end
11
+
12
+ def send_status_update(name, status, expires)
13
+ end
14
+ end
15
+
16
+ class StatusLib::StatusApi
17
+ def get_status_list
18
+ response = send_request('GET', "/api/status")
19
+ parse_status_response(response.body)
20
+ end
21
+
22
+ def send_status_update(name, status, expires)
23
+ if expires.kind_of?(Time)
24
+ expires = expires.utc.to_i
25
+ end
26
+
27
+ payload = { status: status, expires: expires }.to_json
28
+
29
+ response = send_request('PUT', "/api/status/#{name}", payload)
30
+ response_successful(response)
31
+ end
32
+
33
+ private
34
+
35
+ def parse_status_response(body)
36
+ Hash[
37
+ JSON.parse(body).map { |name, status| [name.to_sym, status.to_sym ] }
38
+ ]
39
+ end
40
+
41
+ def http_request
42
+ uri = URI.parse(StatusLib.config.server)
43
+ http = Net::HTTP.new(uri.host, uri.port)
44
+ end
45
+
46
+ def send_request(*args)
47
+ Timeout::timeout(StatusLib.config.api_call_timeout) do
48
+ response = http_request.send_request(*args)
49
+ raise ArgumentError.new("service not found") if response.code == "404"
50
+ raise StatusLib::ApiDownError unless response_successful(response)
51
+ response
52
+ end
53
+ rescue ArgumentError
54
+ raise
55
+ rescue
56
+ raise StatusLib::ApiDownError
57
+ end
58
+
59
+ def response_successful(response)
60
+ response.kind_of? Net::HTTPSuccess
61
+ end
62
+ end
@@ -0,0 +1,72 @@
1
+ class StatusLib::StatusInfo
2
+ def initialize(api)
3
+ @api = api
4
+ @statuses = {}
5
+ @expiries = {}
6
+ @updates = {}
7
+ end
8
+
9
+ def up?(name)
10
+ sync_status_list
11
+ status = @statuses[name]
12
+ expiry = @expiries[name]
13
+
14
+ if status == :down && (expiry.nil? || expiry > now)
15
+ return false
16
+ else
17
+ return true
18
+ end
19
+ end
20
+
21
+ def switch(name, status, options={})
22
+ unless [:up, :down].include?(status)
23
+ raise ArgumentError.new("unknown status")
24
+ end
25
+
26
+ expires = options.fetch(:for, StatusLib.config.down_duration)
27
+ expires = now + expires unless expires.nil?
28
+
29
+ @statuses[name] = status
30
+ @expiries[name] = expires
31
+
32
+ add_pending_update(name, status, expires)
33
+ send_pending_updates
34
+
35
+ StatusLib.config.stats_handler.increment("status.change.#{name}.#{status}")
36
+ end
37
+
38
+ private
39
+
40
+ def now
41
+ Time.now
42
+ end
43
+
44
+ def sync_status_list
45
+ expires_in = StatusLib.config.cache_time
46
+
47
+ @statuses = StatusLib::Cache.fetch('statuses', expires_in: expires_in) do
48
+ send_pending_updates
49
+ begin
50
+ @api.get_status_list || @statuses
51
+ rescue StatusLib::ApiDownError
52
+ # couldn't get the info; just use our local copy
53
+ @statuses
54
+ end
55
+ end
56
+ end
57
+
58
+ def add_pending_update(name, status, expires)
59
+ @updates.delete(name)
60
+ @updates[name] = [status, expires]
61
+ end
62
+
63
+ def send_pending_updates
64
+ @updates.delete_if do |name, args|
65
+ begin
66
+ @api.send_status_update(name, *args)
67
+ rescue StatusLib::ApiDownError
68
+ false
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,3 @@
1
+ module StatusLib
2
+ VERSION = "0.0.2"
3
+ end
data/lib/status_lib.rb ADDED
@@ -0,0 +1,119 @@
1
+ require 'status_lib/version'
2
+
3
+ require 'status_lib/cache'
4
+ require 'status_lib/config'
5
+ require 'status_lib/null_object'
6
+ require 'status_lib/null_stats_handler'
7
+ require 'status_lib/status_api'
8
+ require 'status_lib/status_info'
9
+
10
+ require 'timeout'
11
+
12
+ module StatusLib
13
+ class ServiceDownError < StandardError
14
+ attr_reader :original_exception
15
+
16
+ def initialize(original_exception = nil)
17
+ super("Service is down")
18
+ @original_exception = original_exception
19
+ end
20
+ end
21
+
22
+ class << self
23
+ attr_reader :server
24
+
25
+ # Checks whether a particular service is up or down.
26
+ #
27
+ # Params:
28
+ # +name+:: the name of the service to check
29
+ #
30
+ def up?(name)
31
+ status_info.up?(name)
32
+ end
33
+
34
+ # Sets the status of a service to up or down
35
+ #
36
+ # Params:
37
+ # +name+:: the name of the service to update
38
+ # +status+:: the desired status; either :up or :down
39
+ # +options+:: additional options. can contain:
40
+ # +:for+:: the time to keep the service down, in seconds
41
+ #
42
+ def switch(name, status, options={})
43
+ status_info.switch(name, status, options)
44
+ end
45
+
46
+ # Helper method for guarding a block of code with a curcuit breaker.
47
+ #
48
+ # If the code in the block raises an exception or exceeds its timeout, then
49
+ # the associated service status will be set to down for the specified
50
+ # interval, and a StatusLib::ServiceDownError exception will be raised.
51
+ #
52
+ # If the service is already makred down then the block won't be evaluated at
53
+ # all, and StatusLib::ServiceDownError will be raised immediately.
54
+ #
55
+ # +name+:: the name of the service to update
56
+ # +options+:: additional options. can contain:
57
+ # +:timeout+:: upper limit on how long the block can take to run
58
+ # +:down_for+:: the time to keep the service down, in seconds
59
+ #
60
+ def with_circuit_breaker(name, options={})
61
+ raise ServiceDownError unless up?(name)
62
+
63
+ Timeout::timeout( determine_timeout(options) ) do
64
+ yield
65
+ end
66
+ rescue Timeout::Error => e
67
+ handle_exception(e, name, options)
68
+ raise e
69
+ rescue => e
70
+ handle_exception(e, name, options)
71
+ raise ServiceDownError.new(e)
72
+ end
73
+
74
+ # Configure the gem
75
+ #
76
+ # Params:
77
+ # +settings+:: the configuration settings. A hash that may contain:
78
+ # +:server+:: the URL of the status page service
79
+ # +:cache_time+:: time to cache status reponses from the API
80
+ # +:api_call_timeout+:: maximum time to wait for a call to status API
81
+ # +:down_duration+:: default time to switch a service down for
82
+ # +:block_timeout+:: default time that a `with_circuit_breaker` can take
83
+ #
84
+ def configure(settings)
85
+ config.update(settings)
86
+ end
87
+
88
+ # Read configuration settings
89
+ #
90
+ # The values that are set to #configure are available here.
91
+ #
92
+ def config
93
+ @config ||= Config.new
94
+ end
95
+
96
+ private
97
+
98
+ def handle_exception(e, name, options)
99
+ report_exception(e)
100
+ switch(name, :down, options) if has_down_for?(options)
101
+ end
102
+
103
+ def status_info
104
+ config.status_info
105
+ end
106
+
107
+ def determine_timeout(options)
108
+ options.delete(:timeout) || config.block_timeout
109
+ end
110
+
111
+ def has_down_for?(options)
112
+ (options[:for] || 0) > 0
113
+ end
114
+
115
+ def report_exception(e)
116
+ config.exception_handler.call(e) unless config.exception_handler.nil?
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,45 @@
1
+ require 'spec_helper'
2
+
3
+ describe StatusLib::Cache do
4
+ let(:target) { double(:target, message: "test") }
5
+
6
+ before do
7
+ StatusLib::Cache.clear!
8
+ end
9
+
10
+ context "#fetch" do
11
+ context "with no prior data" do
12
+ it "fetches the value" do
13
+ value = StatusLib::Cache.fetch('key') { target.message }
14
+
15
+ expect(value).to eq('test')
16
+ end
17
+ end
18
+
19
+ context "when cached" do
20
+ it "does not evalute the block multiple times" do
21
+ value = StatusLib::Cache.fetch('key') { target.message }
22
+ value = StatusLib::Cache.fetch('key') { target.message }
23
+
24
+ expect(value).to eq('test')
25
+ expect(target).to have_received(:message).once
26
+ end
27
+ end
28
+
29
+ context "when cached for a specific amount of time" do
30
+ it "only evaluates the block after the timeout elapses" do
31
+ Timecop.freeze(0)
32
+ StatusLib::Cache.fetch('key', expires_in: 30) { target.message }
33
+ expect(target).to have_received(:message).once
34
+
35
+ Timecop.freeze(15)
36
+ StatusLib::Cache.fetch('key', expires_in: 30) { target.message }
37
+ expect(target).to have_received(:message).once
38
+
39
+ Timecop.freeze(60)
40
+ StatusLib::Cache.fetch('key', expires_in: 30) { target.message }
41
+ expect(target).to have_received(:message).twice
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,100 @@
1
+ require 'spec_helper'
2
+
3
+ describe StatusLib::Config do
4
+ shared_examples_for "it has default values" do
5
+ it "has no default server address" do
6
+ expect(config.server).to be_nil
7
+ end
8
+
9
+ it "uses a NullStatusApi by default" do
10
+ expect(config.status_api).to be_an_instance_of(StatusLib::NullStatusApi)
11
+ end
12
+
13
+ it "has no default exception handler" do
14
+ expect(config.exception_handler).to be_nil
15
+ end
16
+
17
+ it "has a null stats handler by default" do
18
+ expect(config.stats_handler).to eq(NullStatsHandler)
19
+ end
20
+
21
+ it "has a default cache expiry time" do
22
+ expect(config.cache_time).to eq(5)
23
+ end
24
+
25
+ it "has a default API call timeout" do
26
+ expect(config.api_call_timeout).to eq(1)
27
+ end
28
+
29
+ it "has a default down duration" do
30
+ expect(config.down_duration).to eq(0)
31
+ end
32
+
33
+ it "has a default block timeout" do
34
+ expect(config.block_timeout).to eq(5)
35
+ end
36
+ end
37
+
38
+ context "when initially created" do
39
+ let(:config) { StatusLib::Config.new }
40
+
41
+ it_behaves_like "it has default values"
42
+ end
43
+
44
+ context "updating" do
45
+ let(:config) { StatusLib::Config.new }
46
+ let(:stats) { double(:stats) }
47
+
48
+ it "sets the values form the arguments" do
49
+ config.update(server: "http://example.com:123",
50
+ exception_handler: ->(e) { puts e },
51
+ stats_handler: stats,
52
+ cache_time: 60,
53
+ api_call_timeout: 0.5,
54
+ down_duration: 120,
55
+ block_timeout: 36)
56
+
57
+ expect(config.server).to eq("http://example.com:123")
58
+ expect(config.exception_handler).to be_kind_of(Proc)
59
+ expect(config.stats_handler).to eq(stats)
60
+ expect(config.cache_time).to eq(60)
61
+ expect(config.api_call_timeout).to eq(0.5)
62
+ expect(config.down_duration).to eq(120)
63
+ expect(config.block_timeout).to eq(36)
64
+ expect(config.status_api).to be_an_instance_of(StatusLib::StatusApi)
65
+ end
66
+ end
67
+
68
+ context "setting back to nil" do
69
+ let(:config) { StatusLib::Config.new }
70
+ let(:stats) { double(:stats) }
71
+
72
+ before do
73
+ config.update(server: "http://example.com:123",
74
+ exception_handler: ->(e) { puts e },
75
+ stats_handler: stats,
76
+ cache_time: 60,
77
+ api_call_timeout: 0.5,
78
+ down_duration: 120,
79
+ block_timeout: 36)
80
+
81
+ config.update(server: nil,
82
+ exception_handler: nil,
83
+ stats_handler: nil,
84
+ cache_time: nil,
85
+ api_call_timeout: nil,
86
+ down_duration: nil,
87
+ block_timeout: nil)
88
+ end
89
+
90
+ it_behaves_like "it has default values"
91
+ end
92
+
93
+ context "#status_info" do
94
+ let(:config) { StatusLib::Config.new }
95
+
96
+ it "has an instance of StatusInfo" do
97
+ expect(config.status_info).to be_an_instance_of(StatusLib::StatusInfo)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,28 @@
1
+ require 'spec_helper'
2
+
3
+ describe NullObject do
4
+ let(:klass) {
5
+ Class.new do
6
+ include NullObject
7
+ end
8
+ }
9
+ let(:instance) { klass.new }
10
+
11
+ it "quietly ignores method calls" do
12
+ expect {
13
+ instance.do_something_i_just_made_up('with', 'args')
14
+ }.to_not raise_error
15
+ end
16
+
17
+ context "with a block" do
18
+ let(:inner) { double(:inner).as_null_object }
19
+
20
+ it "calls the supplied block on ignored methods" do
21
+ instance.do_something_with_a_block do
22
+ inner.block_target('test')
23
+ end
24
+
25
+ expect(inner).to have_received(:block_target).with('test')
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ require 'spec_helper'
2
+
3
+ describe NullStatsHandler do
4
+ it "quietly ignores calls to ::increment" do
5
+ expect {
6
+ NullStatsHandler.increment('my.counter')
7
+ }.to_not raise_error
8
+ end
9
+ end