brow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f08f2a47a1034332966c9ef42acdecc5629052fd8255857a61cdaa8483be502
4
+ data.tar.gz: f42dac7bdfe22a48b7e7d5e0ef5ecb91407505f9ef0fb91393a263b194a8845c
5
+ SHA512:
6
+ metadata.gz: abb687ce5fe388f7c87826752255ff1227dd275365ed599d3e78519f1402a2bf4a6010e9f6da307f8f7265be8a69e6f26baea43e30b7bd9adc2cd9f3253ca447
7
+ data.tar.gz: 57722a879c3fa49461ffbf8334a5674333fce12bb30dc2037b7dcb09f65d3e2335e57d4abb47359a7ed2b032154e1e9494e92c3a5f1a9e00a99cd0627feb1488
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2021-10-14
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+ gemspec
5
+
6
+ gem "rake", "~> 13.0"
7
+ gem "minitest", "~> 5.0"
8
+ gem "minitest-heat", "~> 0.0"
9
+ gem "webmock", "~> 3.10.0"
10
+
11
+ group(:guard) do
12
+ gem "guard", "~> 2.18.0"
13
+ gem "guard-minitest", "~> 2.4.6"
14
+ gem "guard-bundler", "~> 3.0.0"
15
+ gem "rb-fsevent", "~> 0.10"
16
+ end
data/Guardfile ADDED
@@ -0,0 +1,54 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec features) \
6
+ # .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}
7
+
8
+ ## Note: if you are using the `directories` clause above and you are not
9
+ ## watching the project directory ('.'), then you will want to move
10
+ ## the Guardfile to a watched dir and symlink it back, e.g.
11
+ #
12
+ # $ mkdir config
13
+ # $ mv Guardfile config/
14
+ # $ ln -s config/Guardfile .
15
+ #
16
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
17
+
18
+ guard :bundler do
19
+ require 'guard/bundler'
20
+ require 'guard/bundler/verify'
21
+ helper = Guard::Bundler::Verify.new
22
+
23
+ files = ['Gemfile']
24
+ files += Dir['*.gemspec'] if files.any? { |f| helper.uses_gemspec?(f) }
25
+
26
+ # Assume files are symlinked from somewhere
27
+ files.each { |file| watch(helper.real_path(file)) }
28
+ end
29
+
30
+ guard :minitest do
31
+ # with Minitest::Unit
32
+ watch(%r{^test/(.*)\/?(.*)_test\.rb$})
33
+ watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}#{m[2]}_test.rb" }
34
+ watch(%r{^test/test_helper\.rb$}) { 'test' }
35
+
36
+ # with Minitest::Spec
37
+ # watch(%r{^spec/(.*)_spec\.rb$})
38
+ # watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
39
+ # watch(%r{^spec/spec_helper\.rb$}) { 'spec' }
40
+
41
+ # Rails 4
42
+ # watch(%r{^app/(.+)\.rb$}) { |m| "test/#{m[1]}_test.rb" }
43
+ # watch(%r{^app/controllers/application_controller\.rb$}) { 'test/controllers' }
44
+ # watch(%r{^app/controllers/(.+)_controller\.rb$}) { |m| "test/integration/#{m[1]}_test.rb" }
45
+ # watch(%r{^app/views/(.+)_mailer/.+}) { |m| "test/mailers/#{m[1]}_mailer_test.rb" }
46
+ # watch(%r{^lib/(.+)\.rb$}) { |m| "test/lib/#{m[1]}_test.rb" }
47
+ # watch(%r{^test/.+_test\.rb$})
48
+ # watch(%r{^test/test_helper\.rb$}) { 'test' }
49
+
50
+ # Rails < 4
51
+ # watch(%r{^app/controllers/(.*)\.rb$}) { |m| "test/functional/#{m[1]}_test.rb" }
52
+ # watch(%r{^app/helpers/(.*)\.rb$}) { |m| "test/helpers/#{m[1]}_test.rb" }
53
+ # watch(%r{^app/models/(.*)\.rb$}) { |m| "test/unit/#{m[1]}_test.rb" }
54
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 John Nunemaker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # Brow
2
+
3
+ A generic background thread worker for shipping events via https to some API backend.
4
+
5
+ I've been wanting to build something like this for a while. This might be a terrible start. But its a start.
6
+
7
+ I noticed a lot of companies copied segment's [analytics-ruby](https://github.com/segmentio/analytics-ruby) project and are using it successfully.
8
+
9
+ So that's where I began. Seems safe to assume that project has been around long enough and is production hardened enough. I guess I'll find out. :)
10
+
11
+ Things around here are pretty basic for now. But I'm looking to spruce it up and production test it over the coming months &mdash; likely with [Flipper](https://github.com/jnunemaker/flipper) and [Flipper Cloud](https://www.flippercloud.io/?utm_source=brow&utm_medium=web&utm_campaign=readme).
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'brow'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle install
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install brow
28
+
29
+ ## Usage
30
+
31
+ ```ruby
32
+ require "brow"
33
+
34
+ client = Brow::Client.new({
35
+ url: "https://requestbin.net/r/rna67for",
36
+ })
37
+
38
+ 50.times do |n|
39
+ client.record({
40
+ number: n,
41
+ now: Time.now.utc,
42
+ })
43
+ end
44
+
45
+ # batch of 50 events sent to api url above as json
46
+ client.flush
47
+ ```
48
+
49
+ ## Development
50
+
51
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
52
+
53
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
54
+
55
+ ## Contributing
56
+
57
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jnunemaker/brow.
58
+
59
+ ## License
60
+
61
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "brow"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/examples/basic.rb ADDED
@@ -0,0 +1,14 @@
1
+ require_relative "../lib/brow"
2
+
3
+ client = Brow::Client.new({
4
+ url: "https://requestbin.net/r/rna67for",
5
+ })
6
+
7
+ 50.times do |n|
8
+ client.record({
9
+ number: n,
10
+ now: Time.now.utc,
11
+ })
12
+ end
13
+
14
+ client.flush
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brow
4
+ class BackoffPolicy
5
+ # Private: The default minimum timeout between intervals in milliseconds.
6
+ MIN_TIMEOUT_MS = 100
7
+
8
+ # Private: The default maximum timeout between intervals in milliseconds.
9
+ MAX_TIMEOUT_MS = 10000
10
+
11
+ # Private: The value to multiply the current interval with for each
12
+ # retry attempt.
13
+ MULTIPLIER = 1.5
14
+
15
+ # Private: The randomization factor to use to create a range around the
16
+ # retry interval.
17
+ RANDOMIZATION_FACTOR = 0.5
18
+
19
+ # Public: Create new instance of backoff policy.
20
+ #
21
+ # options - The Hash of options.
22
+ # :min_timeout_ms - The minimum backoff timeout.
23
+ # :max_timeout_ms - The maximum backoff timeout.
24
+ # :multiplier - The value to multiply the current interval with for each
25
+ # retry attempt.
26
+ # :randomization_factor - The randomization factor to use to create a range
27
+ # around the retry interval.
28
+ def initialize(options = {})
29
+ @min_timeout_ms = options[:min_timeout_ms] || MIN_TIMEOUT_MS
30
+ @max_timeout_ms = options[:max_timeout_ms] || MAX_TIMEOUT_MS
31
+ @multiplier = options[:multiplier] || MULTIPLIER
32
+ @randomization_factor = options[:randomization_factor] || RANDOMIZATION_FACTOR
33
+
34
+ @attempts = 0
35
+ end
36
+
37
+ # Public: Returns the next backoff interval in milliseconds.
38
+ def next_interval
39
+ interval = @min_timeout_ms * (@multiplier**@attempts)
40
+ interval = add_jitter(interval, @randomization_factor)
41
+
42
+ @attempts += 1
43
+
44
+ [interval, @max_timeout_ms].min
45
+ end
46
+
47
+ private
48
+
49
+ def add_jitter(base, randomization_factor)
50
+ random_number = rand
51
+ max_deviation = base * randomization_factor
52
+ deviation = random_number * max_deviation
53
+
54
+ if random_number < 0.5
55
+ base - deviation
56
+ else
57
+ base + deviation
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+ require 'time'
5
+
6
+ require_relative 'utils'
7
+ require_relative 'worker'
8
+ require_relative 'test_queue'
9
+
10
+ module Brow
11
+ class Client
12
+ # Private: Default # of items that can be in queue before we start dropping data.
13
+ MAX_QUEUE_SIZE = 10_000
14
+
15
+ # Public: Create a new instance of a client.
16
+ #
17
+ # options - The Hash of options.
18
+ # :max_queue_size - The maximum number of calls to be remain queued.
19
+ # :on_error - The Proc that handles error calls from the API.
20
+ def initialize(options = {})
21
+ options = Brow::Utils.symbolize_keys(options)
22
+
23
+ @worker_thread = nil
24
+ @worker_mutex = Mutex.new
25
+ @test = options[:test]
26
+ @max_queue_size = options[:max_queue_size] || MAX_QUEUE_SIZE
27
+ @logger = options.fetch(:logger) { Brow.logger }
28
+ @queue = options.fetch(:queue) { Queue.new }
29
+ @worker = options.fetch(:worker) { Worker.new(@queue, options) }
30
+
31
+ at_exit { @worker_thread && @worker_thread[:should_exit] = true }
32
+ end
33
+
34
+ # Public: Synchronously waits until the worker has flushed the queue.
35
+ #
36
+ # Use only for scripts which are not long-running, and will
37
+ # specifically exit.
38
+ def flush
39
+ while !@queue.empty? || @worker.requesting?
40
+ ensure_worker_running
41
+ sleep(0.1)
42
+ end
43
+ end
44
+
45
+ # Public: Enqueues the event.
46
+ #
47
+ # event - The Hash of event data.
48
+ #
49
+ # Returns Boolean of whether the item was added to the queue.
50
+ def record(event)
51
+ raise ArgumentError, "event must be a Hash" unless event.is_a?(Hash)
52
+
53
+ event = Brow::Utils.symbolize_keys(event)
54
+ event = Brow::Utils.isoify_dates(event)
55
+ enqueue event
56
+ end
57
+
58
+ # Public: Returns the number of messages in the queue.
59
+ def queued_messages
60
+ @queue.length
61
+ end
62
+
63
+ # Public: For test purposes only. If test: true is passed to #initialize
64
+ # then all recording of events will go to test queue in memory so they can
65
+ # be verified with assertions.
66
+ def test_queue
67
+ unless @test
68
+ raise 'Test queue only available when setting :test to true.'
69
+ end
70
+
71
+ @test_queue ||= TestQueue.new
72
+ end
73
+
74
+ private
75
+
76
+ # Private: Enqueues the event.
77
+ #
78
+ # Returns Boolean of whether the item was added to the queue.
79
+ def enqueue(action)
80
+ if @test
81
+ test_queue << action
82
+ return true
83
+ end
84
+
85
+ if @queue.length < @max_queue_size
86
+ @queue << action
87
+ ensure_worker_running
88
+
89
+ true
90
+ else
91
+ @logger.warn 'Queue is full, dropping events. The :max_queue_size configuration parameter can be increased to prevent this from happening.'
92
+ false
93
+ end
94
+ end
95
+
96
+ def ensure_worker_running
97
+ return if worker_running?
98
+ @worker_mutex.synchronize do
99
+ return if worker_running?
100
+ @worker_thread = Thread.new do
101
+ @worker.run
102
+ end
103
+ end
104
+ end
105
+
106
+ def worker_running?
107
+ @worker_thread && @worker_thread.alive?
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'forwardable'
5
+
6
+ module Brow
7
+ # Internal: A batch of messages to be sent to the API.
8
+ class MessageBatch
9
+ extend Forwardable
10
+
11
+ # Private: The error raised when a message cannot be serialized to json.
12
+ class JSONGenerationError < ::Brow::Error; end
13
+
14
+ # Private: Maximum bytes for an individual message.
15
+ MAX_BYTES_PER_MESSAGE = 32_768 # 32Kb
16
+
17
+ # Private: Maximum total bytes for a batch.
18
+ MAX_BYTES = 512_000 # 500Kb
19
+
20
+ # Private: Maximum number of messages in a batch.
21
+ MAX_SIZE = 100
22
+
23
+ def_delegators :@messages, :empty?
24
+ def_delegators :@messages, :length
25
+
26
+ attr_reader :uuid, :json_size
27
+
28
+ def initialize(options = {})
29
+ clear
30
+ @max_size = options[:max_size] || MAX_SIZE
31
+ @logger = options.fetch(:logger) { Brow.logger }
32
+ end
33
+
34
+ def <<(message)
35
+ begin
36
+ message_json = message.to_json
37
+ rescue StandardError => error
38
+ raise JSONGenerationError, "Serialization error: #{error}"
39
+ end
40
+
41
+ message_json_size = message_json.bytesize
42
+
43
+ if message_too_big?(message_json_size)
44
+ @logger.error('a message exceeded the maximum allowed size')
45
+ else
46
+ @messages << message
47
+ @json_size += message_json_size + 1 # One byte for the comma
48
+ end
49
+ end
50
+
51
+ def full?
52
+ item_count_exhausted? || size_exhausted?
53
+ end
54
+
55
+ def clear
56
+ @messages = []
57
+ @json_size = 0
58
+ @uuid = SecureRandom.uuid
59
+ end
60
+
61
+ def as_json
62
+ {
63
+ uuid: @uuid,
64
+ messages: @messages,
65
+ }
66
+ end
67
+
68
+ def to_json
69
+ JSON.generate(as_json)
70
+ end
71
+
72
+ private
73
+
74
+ def item_count_exhausted?
75
+ @messages.length >= @max_size
76
+ end
77
+
78
+ def message_too_big?(message_json_size)
79
+ message_json_size > MAX_BYTES_PER_MESSAGE
80
+ end
81
+
82
+ # We consider the max size here as just enough to leave room for one more
83
+ # message of the largest size possible. This is a shortcut that allows us
84
+ # to use a native Ruby `Queue` that doesn't allow peeking. The tradeoff
85
+ # here is that we might fit in less messages than possible into a batch.
86
+ #
87
+ # The alternative is to use our own `Queue` implementation that allows
88
+ # peeking, and to consider the next message size when calculating whether
89
+ # the message can be accomodated in this batch.
90
+ def size_exhausted?
91
+ @json_size >= (MAX_BYTES - MAX_BYTES_PER_MESSAGE)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,25 @@
1
+ module Brow
2
+ # Internal: Wraps an existing logger and adds a prefix to all messages.
3
+ class PrefixedLogger
4
+ def initialize(logger, prefix)
5
+ @logger = logger
6
+ @prefix = prefix
7
+ end
8
+
9
+ def debug(message)
10
+ @logger.debug("#{@prefix} #{message}")
11
+ end
12
+
13
+ def info(message)
14
+ @logger.info("#{@prefix} #{message}")
15
+ end
16
+
17
+ def warn(message)
18
+ @logger.warn("#{@prefix} #{message}")
19
+ end
20
+
21
+ def error(message)
22
+ @logger.error("#{@prefix} #{message}")
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brow
4
+ class Response
5
+ attr_reader :status, :error
6
+
7
+ # Public: Simple class to wrap responses from the API.
8
+ def initialize(status = 200, error = nil)
9
+ @status = status
10
+ @error = error
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brow
4
+ # Public: The test queue to use if the `Client` is in test mode. Keeps all
5
+ # messages in an array so you can add assertions.
6
+ #
7
+ # Be sure to reset before each test case.
8
+ class TestQueue
9
+ attr_reader :messages
10
+
11
+ def initialize
12
+ reset
13
+ end
14
+
15
+ def count
16
+ messages.count
17
+ end
18
+ alias_method :size, :count
19
+ alias_method :length, :count
20
+
21
+ def <<(message)
22
+ messages << message
23
+ end
24
+
25
+ def reset
26
+ @messages = []
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'net/https'
5
+ require 'json'
6
+
7
+ require_relative 'response'
8
+ require_relative 'backoff_policy'
9
+
10
+ module Brow
11
+ class Transport
12
+ RETRIES = 10
13
+ HEADERS = {
14
+ "Accept" => "application/json",
15
+ "Content-Type" => "application/json",
16
+ "User-Agent" => "brow-ruby/#{Brow::VERSION}",
17
+ "Client-Language" => "ruby",
18
+ "Client-Language-Version" => "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})",
19
+ "Client-Platform" => RUBY_PLATFORM,
20
+ "Client-Engine" => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "",
21
+ "Client-Pid" => Process.pid.to_s,
22
+ "Client-Thread" => Thread.current.object_id.to_s,
23
+ "Client-Hostname" => Socket.gethostname,
24
+ }
25
+
26
+ attr_reader :url
27
+
28
+ def initialize(options = {})
29
+ @url = options[:url] || raise(ArgumentError, ":url is required to be present so we know where to send batches")
30
+ @uri = URI.parse(@url)
31
+
32
+ # Default path if people forget a slash.
33
+ if @uri.path.nil? || @uri.path.empty?
34
+ @uri.path = "/"
35
+ end
36
+
37
+ @headers = HEADERS.merge(options[:headers] || {})
38
+ @retries = options[:retries] || RETRIES
39
+
40
+ @logger = options.fetch(:logger) { Brow.logger }
41
+ @backoff_policy = options.fetch(:backoff_policy) {
42
+ Brow::BackoffPolicy.new
43
+ }
44
+
45
+ @http = Net::HTTP.new(@uri.host, @uri.port)
46
+ @http.use_ssl = @uri.scheme == "https"
47
+ @http.read_timeout = options[:read_timeout] || 8
48
+ @http.open_timeout = options[:open_timeout] || 4
49
+ end
50
+
51
+ # Sends a batch of messages to the API
52
+ #
53
+ # @return [Response] API response
54
+ def send_batch(batch)
55
+ @logger.debug("Sending request for #{batch.length} items")
56
+
57
+ last_response, exception = retry_with_backoff(@retries) do
58
+ response = send_request(batch)
59
+ status_code = response.code.to_i
60
+ should_retry = should_retry_request?(status_code, response.body)
61
+ @logger.debug("Response status code: #{status_code}")
62
+
63
+ [Response.new(status_code, nil), should_retry]
64
+ end
65
+
66
+ if exception
67
+ @logger.error(exception.message)
68
+ exception.backtrace.each { |line| @logger.error(line) }
69
+ Response.new(-1, exception.to_s)
70
+ else
71
+ last_response
72
+ end
73
+ end
74
+
75
+ # Closes a persistent connection if it exists
76
+ def shutdown
77
+ @http.finish if @http.started?
78
+ end
79
+
80
+ private
81
+
82
+ def should_retry_request?(status_code, body)
83
+ if status_code >= 500
84
+ # Server error. Retry and log.
85
+ @logger.info("Server error: status=#{status_code}, body=#{body}")
86
+ true
87
+ elsif status_code == 429
88
+ # Rate limited
89
+ @logger.info "Rate limit error"
90
+ true
91
+ elsif status_code >= 400
92
+ # Client error. Do not retry, but log.
93
+ @logger.error("Client error: status=#{status_code}, body=#{body}")
94
+ false
95
+ else
96
+ false
97
+ end
98
+ end
99
+
100
+ # Takes a block that returns [result, should_retry].
101
+ #
102
+ # Retries upto `retries_remaining` times, if `should_retry` is false or
103
+ # an exception is raised. `@backoff_policy` is used to determine the
104
+ # duration to sleep between attempts
105
+ #
106
+ # Returns [last_result, raised_exception]
107
+ def retry_with_backoff(retries_remaining, &block)
108
+ result, caught_exception = nil
109
+ should_retry = false
110
+
111
+ begin
112
+ result, should_retry = yield
113
+ return [result, nil] unless should_retry
114
+ rescue StandardError => error
115
+ @logger.debug "Request error: #{error}"
116
+ should_retry = true
117
+ caught_exception = error
118
+ end
119
+
120
+ if should_retry && (retries_remaining > 1)
121
+ @logger.debug("Retrying request, #{retries_remaining} retries left")
122
+ sleep(@backoff_policy.next_interval.to_f / 1000)
123
+ retry_with_backoff(retries_remaining - 1, &block)
124
+ else
125
+ [result, caught_exception]
126
+ end
127
+ end
128
+
129
+ # Sends a request for the batch, returns [status_code, body]
130
+ def send_request(batch)
131
+ payload = batch.to_json
132
+ @http.start unless @http.started? # Maintain a persistent connection
133
+ request = Net::HTTP::Post.new(@uri.path, @headers)
134
+ @http.request(request, payload)
135
+ end
136
+ end
137
+ end
data/lib/brow/utils.rb ADDED
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Brow
6
+ module Utils
7
+ extend self
8
+
9
+ # Internal: Return a new hash with keys converted from strings to symbols
10
+ def symbolize_keys(hash)
11
+ hash.each_with_object({}) do |(k, v), memo|
12
+ memo[k.to_sym] = v
13
+ end
14
+ end
15
+
16
+ # Internal: Returns a new hash with all the date values in the into
17
+ # iso8601 strings
18
+ def isoify_dates(hash)
19
+ hash.each_with_object({}) do |(k, v), memo|
20
+ memo[k] = datetime_in_iso8601(v)
21
+ end
22
+ end
23
+
24
+ # Internal
25
+ def datetime_in_iso8601(datetime)
26
+ case datetime
27
+ when Time
28
+ time_in_iso8601 datetime
29
+ when DateTime
30
+ time_in_iso8601 datetime.to_time
31
+ when Date
32
+ date_in_iso8601 datetime
33
+ else
34
+ datetime
35
+ end
36
+ end
37
+
38
+ # Internal
39
+ def time_in_iso8601(time)
40
+ "#{time.strftime('%Y-%m-%dT%H:%M:%S.%6N')}#{formatted_offset(time, true, 'Z')}"
41
+ end
42
+
43
+ # Internal
44
+ def date_in_iso8601(date)
45
+ date.strftime('%F')
46
+ end
47
+
48
+ # Internal
49
+ def formatted_offset(time, colon = true, alternate_utc_string = nil)
50
+ time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
51
+ end
52
+
53
+ # Internal
54
+ def seconds_to_utc_offset(seconds, colon = true)
55
+ (colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON) % [(seconds < 0 ? '-' : '+'), (seconds.abs / 3600), ((seconds.abs % 3600) / 60)]
56
+ end
57
+
58
+ # Internal
59
+ UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
60
+
61
+ # Internal
62
+ UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brow
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'message_batch'
4
+ require_relative 'transport'
5
+ require_relative 'utils'
6
+
7
+ module Brow
8
+ # Internal: The Worker to pull items off the queue and put them
9
+ class Worker
10
+ DEFAULT_ON_ERROR = proc { |response| }
11
+
12
+ # Internal: Creates a new worker
13
+ #
14
+ # The worker continuously takes messages off the queue and makes requests to
15
+ # the api.
16
+ #
17
+ # queue - Queue synchronized between client and worker
18
+ # options - The Hash of worker options.
19
+ # batch_size - Fixnum of how many items to send in a batch.
20
+ # on_error - Proc of what to do on an error.
21
+ # transport - The Transport object to deliver batches.
22
+ # logger - The Logger object for all log messages.
23
+ # batch - The MessageBatch to collect messages and deliver batches
24
+ # via Transport.
25
+ def initialize(queue, options = {})
26
+ @queue = queue
27
+ @lock = Mutex.new
28
+ options = Brow::Utils.symbolize_keys(options)
29
+ @on_error = options[:on_error] || DEFAULT_ON_ERROR
30
+ @transport = options.fetch(:transport) { Transport.new(options) }
31
+ @logger = options.fetch(:logger) { Brow.logger }
32
+ @batch = options.fetch(:batch) { MessageBatch.new(max_size: options[:batch_size]) }
33
+ end
34
+
35
+ # Internal: Continuously runs the loop to check for new events
36
+ def run
37
+ until Thread.current[:should_exit]
38
+ return if @queue.empty?
39
+
40
+ @lock.synchronize do
41
+ consume_message_from_queue! until @batch.full? || @queue.empty?
42
+ end
43
+
44
+ response = @transport.send_batch @batch
45
+ @on_error.call(response) unless response.status == 200
46
+
47
+ @lock.synchronize { @batch.clear }
48
+ end
49
+ ensure
50
+ @transport.shutdown
51
+ end
52
+
53
+ # Internal: Check whether we have outstanding requests.
54
+ def requesting?
55
+ @lock.synchronize { !@batch.empty? }
56
+ end
57
+
58
+ private
59
+
60
+ def consume_message_from_queue!
61
+ @batch << @queue.pop
62
+ rescue MessageBatch::JSONGenerationError => error
63
+ @on_error.call(Response.new(-1, error))
64
+ end
65
+ end
66
+ end
data/lib/brow.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "brow/version"
4
+ require "logger"
5
+
6
+ module Brow
7
+ class Error < StandardError; end
8
+
9
+ # Public: Returns the logger instance to use for logging of things.
10
+ def self.logger
11
+ return @logger if @logger
12
+
13
+ base_logger = if defined?(Rails)
14
+ Rails.logger
15
+ else
16
+ Logger.new(STDOUT)
17
+ end
18
+
19
+ @logger = PrefixedLogger.new(base_logger, "[brow]")
20
+ end
21
+
22
+ # Public: Sets the logger instance to use for logging things.
23
+ def self.logger=(new_logger)
24
+ @logger = new_logger
25
+ end
26
+ end
27
+
28
+ require_relative "brow/client"
29
+ require_relative "brow/prefixed_logger"
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: brow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - John Nunemaker
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-10-20 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - nunemaker@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - Gemfile
22
+ - Guardfile
23
+ - LICENSE.txt
24
+ - README.md
25
+ - Rakefile
26
+ - bin/console
27
+ - bin/setup
28
+ - examples/basic.rb
29
+ - lib/brow.rb
30
+ - lib/brow/backoff_policy.rb
31
+ - lib/brow/client.rb
32
+ - lib/brow/message_batch.rb
33
+ - lib/brow/prefixed_logger.rb
34
+ - lib/brow/response.rb
35
+ - lib/brow/test_queue.rb
36
+ - lib/brow/transport.rb
37
+ - lib/brow/utils.rb
38
+ - lib/brow/version.rb
39
+ - lib/brow/worker.rb
40
+ homepage: https://github.com/jnunemaker/brow
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ homepage_uri: https://github.com/jnunemaker/brow
45
+ source_code_uri: https://github.com/jnunemaker/brow
46
+ changelog_uri: https://github.com/jnunemaker/brow/blob/main/CHANGELOG.md
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 2.6.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.0.3
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: A generic background thread worker for shipping events via https to some
66
+ API backend.
67
+ test_files: []