goodsorcery 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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE +21 -0
  4. data/README.md +299 -0
  5. data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
  6. data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
  7. data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
  8. data/lib/goodsorcery/backtrace/code_context.rb +27 -0
  9. data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
  10. data/lib/goodsorcery/backtrace/parser.rb +38 -0
  11. data/lib/goodsorcery/backtrace/paths.rb +42 -0
  12. data/lib/goodsorcery/bundler_plugin.rb +117 -0
  13. data/lib/goodsorcery/client.rb +180 -0
  14. data/lib/goodsorcery/configuration/validation.rb +59 -0
  15. data/lib/goodsorcery/configuration.rb +204 -0
  16. data/lib/goodsorcery/defaults.rb +40 -0
  17. data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
  18. data/lib/goodsorcery/delivery/async.rb +122 -0
  19. data/lib/goodsorcery/delivery/backoff.rb +20 -0
  20. data/lib/goodsorcery/delivery/custom.rb +27 -0
  21. data/lib/goodsorcery/delivery/event_queue.rb +124 -0
  22. data/lib/goodsorcery/delivery/sync.rb +27 -0
  23. data/lib/goodsorcery/delivery/test.rb +30 -0
  24. data/lib/goodsorcery/delivery/worker.rb +71 -0
  25. data/lib/goodsorcery/delivery.rb +39 -0
  26. data/lib/goodsorcery/delivery_job.rb +26 -0
  27. data/lib/goodsorcery/env_flag.rb +24 -0
  28. data/lib/goodsorcery/environment_gate.rb +61 -0
  29. data/lib/goodsorcery/errors.rb +14 -0
  30. data/lib/goodsorcery/event/breadcrumb.rb +32 -0
  31. data/lib/goodsorcery/event/builder.rb +77 -0
  32. data/lib/goodsorcery/event/cause_chain.rb +39 -0
  33. data/lib/goodsorcery/event/exception_section.rb +68 -0
  34. data/lib/goodsorcery/event/fields.rb +61 -0
  35. data/lib/goodsorcery/event/job_section.rb +34 -0
  36. data/lib/goodsorcery/event/request_section.rb +41 -0
  37. data/lib/goodsorcery/event/runtime_context.rb +52 -0
  38. data/lib/goodsorcery/event/tag_set.rb +41 -0
  39. data/lib/goodsorcery/event/user_section.rb +36 -0
  40. data/lib/goodsorcery/event_filter.rb +54 -0
  41. data/lib/goodsorcery/event_pipeline.rb +35 -0
  42. data/lib/goodsorcery/event_serializer.rb +25 -0
  43. data/lib/goodsorcery/exception_coercion.rb +18 -0
  44. data/lib/goodsorcery/extra_filter_probe.rb +32 -0
  45. data/lib/goodsorcery/ignore_list.rb +15 -0
  46. data/lib/goodsorcery/install_check.rb +58 -0
  47. data/lib/goodsorcery/integrations/action_cable.rb +75 -0
  48. data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
  49. data/lib/goodsorcery/integrations/active_job.rb +39 -0
  50. data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
  51. data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
  52. data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
  53. data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
  54. data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
  55. data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
  56. data/lib/goodsorcery/integrations/rails.rb +56 -0
  57. data/lib/goodsorcery/integrations/rake.rb +68 -0
  58. data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
  59. data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
  60. data/lib/goodsorcery/integrations.rb +27 -0
  61. data/lib/goodsorcery/job_arguments.rb +77 -0
  62. data/lib/goodsorcery/key_filter.rb +99 -0
  63. data/lib/goodsorcery/log.rb +55 -0
  64. data/lib/goodsorcery/masked_key.rb +29 -0
  65. data/lib/goodsorcery/rack/middleware.rb +57 -0
  66. data/lib/goodsorcery/rack/request_details.rb +120 -0
  67. data/lib/goodsorcery/rack/request_enricher.rb +20 -0
  68. data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
  69. data/lib/goodsorcery/railtie.rb +29 -0
  70. data/lib/goodsorcery/release_detector.rb +42 -0
  71. data/lib/goodsorcery/reported_mark.rb +19 -0
  72. data/lib/goodsorcery/sanitizer.rb +71 -0
  73. data/lib/goodsorcery/scope.rb +106 -0
  74. data/lib/goodsorcery/status_report.rb +50 -0
  75. data/lib/goodsorcery/tasks.rb +46 -0
  76. data/lib/goodsorcery/test_event.rb +46 -0
  77. data/lib/goodsorcery/text.rb +35 -0
  78. data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
  79. data/lib/goodsorcery/transport/connection.rb +87 -0
  80. data/lib/goodsorcery/transport/result.rb +103 -0
  81. data/lib/goodsorcery/transport.rb +172 -0
  82. data/lib/goodsorcery/verification.rb +47 -0
  83. data/lib/goodsorcery/version.rb +6 -0
  84. data/lib/goodsorcery.rb +176 -0
  85. data/plugins.rb +6 -0
  86. metadata +130 -0
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Marks exception objects once reported, so the same object reaching
5
+ # several integrations (middleware, Rails.error, Sidekiq) is sent once.
6
+ module ReportedMark
7
+ IVAR = :@__goodsorcery_reported
8
+
9
+ def self.reported?(exception)
10
+ exception.instance_variable_defined?(IVAR)
11
+ end
12
+
13
+ def self.mark(exception)
14
+ exception.instance_variable_set(IVAR, true) unless exception.frozen?
15
+ rescue StandardError
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Turns app data into JSON-safe values within the server's limits
5
+ # (INGEST_API.md "Limits"): nesting depth, array and object sizes, string
6
+ # lengths. Unknown objects become strings; structs become hashes so their
7
+ # keys are filtered too. The result shares no mutable objects with the app.
8
+ class Sanitizer
9
+ TRUNCATED = "[truncated]"
10
+
11
+ def initialize(max_depth: 6, max_items: 50, max_keys: 100, max_string: 4_096)
12
+ @max_depth = max_depth
13
+ @max_items = max_items
14
+ @max_keys = max_keys
15
+ @max_string = max_string
16
+ end
17
+
18
+ def sanitize(value, depth = 1)
19
+ case value
20
+ when Hash then sanitize_hash(value, depth)
21
+ when Array then sanitize_array(value, depth)
22
+ when String then Text.limit(value, @max_string)
23
+ when Symbol then Text.limit(value.name, @max_string)
24
+ when Integer, true, false, nil then value
25
+ else sanitize_object(value, depth)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def sanitize_hash(hash, depth)
32
+ return TRUNCATED if depth > @max_depth
33
+
34
+ hash.first(@max_keys).to_h do |key, value|
35
+ [Text.limit(key, 200), sanitize(value, depth + 1)]
36
+ end
37
+ end
38
+
39
+ def sanitize_array(array, depth)
40
+ return TRUNCATED if depth > @max_depth
41
+
42
+ array.first(@max_items).map { |item| sanitize(item, depth + 1) }
43
+ end
44
+
45
+ def sanitize_object(value, depth)
46
+ return value if value.is_a?(Float) && value.finite?
47
+ return value.utc.iso8601(3) if value.is_a?(Time)
48
+ return sanitize_collection(value, depth) if collection?(value)
49
+
50
+ Text.limit(Text.safe_to_s(value), @max_string)
51
+ end
52
+
53
+ # Objects whose to_s would print their fields (OpenStruct's is its
54
+ # inspect) become hashes or arrays, so their keys are filtered too.
55
+ def collection?(value)
56
+ structured?(value) || value.respond_to?(:to_unsafe_h) || value.is_a?(::Set)
57
+ end
58
+
59
+ def structured?(value)
60
+ value.is_a?(Struct) || (defined?(::Data) && value.is_a?(::Data)) ||
61
+ (defined?(::OpenStruct) && value.is_a?(::OpenStruct)) # rubocop:disable Style/OpenStructUse
62
+ end
63
+
64
+ def sanitize_collection(value, depth)
65
+ return sanitize_array(value.to_a, depth) if value.is_a?(::Set)
66
+ return sanitize_hash(value.to_unsafe_h, depth) if value.respond_to?(:to_unsafe_h)
67
+
68
+ sanitize_hash(value.to_h, depth)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # What is known about the current unit of work (a request, a job): context,
5
+ # user, tags, transaction, request, job and breadcrumbs. Each thread (and
6
+ # fiber) has its own scope. Hashes are replaced rather than mutated, so a
7
+ # saved copy stays valid.
8
+ #
9
+ # An integration that opens a scope can leave an +enricher+ (an object
10
+ # responding to +enrich(scope)+) that fills in details only worth computing
11
+ # when an error is actually reported: the request, the job section, the
12
+ # user, the transaction.
13
+ class Scope
14
+ STORAGE_KEY = :__goodsorcery_scope
15
+
16
+ attr_reader :context, :tags, :breadcrumbs
17
+ attr_accessor :user, :transaction, :request, :request_env, :job, :enricher
18
+
19
+ def self.current
20
+ Thread.current[STORAGE_KEY] ||= new
21
+ end
22
+
23
+ # The scope an integration opened for the current request, job or channel
24
+ # action (GoodSorcery.with_scope), or nil outside one.
25
+ def self.unit_of_work
26
+ scope = Thread.current[STORAGE_KEY]
27
+ scope&.unit_of_work? ? scope : nil
28
+ end
29
+
30
+ def self.current=(scope)
31
+ Thread.current[STORAGE_KEY] = scope
32
+ end
33
+
34
+ def self.clear
35
+ Thread.current[STORAGE_KEY] = nil
36
+ end
37
+
38
+ def initialize(unit_of_work: false)
39
+ @context = {}
40
+ @tags = {}
41
+ @user = nil
42
+ @breadcrumbs = Breadcrumbs.new
43
+ @unit_of_work = unit_of_work
44
+ end
45
+
46
+ def unit_of_work?
47
+ @unit_of_work
48
+ end
49
+
50
+ # Runs the enricher once; later reports reuse what it filled in.
51
+ def enrich
52
+ enricher = @enricher
53
+ return unless enricher
54
+
55
+ @enricher = nil
56
+ enricher.enrich(self)
57
+ end
58
+
59
+ def merge_context(hash)
60
+ @context = @context.merge(hash) if hash.is_a?(Hash)
61
+ @context
62
+ end
63
+
64
+ # Merges +hash+ into the context for the duration of the block.
65
+ def with_context(hash)
66
+ previous = @context
67
+ merge_context(hash)
68
+ yield
69
+ ensure
70
+ @context = previous
71
+ end
72
+
73
+ def merge_tags(hash)
74
+ @tags = @tags.merge(hash) if hash.is_a?(Hash)
75
+ @tags
76
+ end
77
+
78
+ def add_breadcrumb(crumb, limit)
79
+ @breadcrumbs.add(crumb, limit)
80
+ end
81
+ end
82
+
83
+ # A ring buffer of the most recent breadcrumbs in a scope.
84
+ class Breadcrumbs
85
+ def initialize
86
+ @items = []
87
+ end
88
+
89
+ def add(crumb, limit)
90
+ @items << crumb
91
+ @items.shift(@items.size - limit) if @items.size > limit
92
+ end
93
+
94
+ def to_a
95
+ @items.dup
96
+ end
97
+
98
+ def size
99
+ @items.size
100
+ end
101
+
102
+ def clear
103
+ @items.clear
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Prints "[goodsorcery]"-prefixed lines describing the setup, the verify
5
+ # result and the test event result. Shows the masked API key only.
6
+ class StatusReport
7
+ PREFIX = "[goodsorcery]"
8
+
9
+ def initialize(client, out: $stdout)
10
+ @client = client
11
+ @config = client.config
12
+ @out = out
13
+ end
14
+
15
+ def print_settings
16
+ say("endpoint: #{@config.endpoint_label}")
17
+ say("environment: #{@config.environment} (#{enabled_label})")
18
+ say("delivery: #{delivery_label}")
19
+ say("api key: #{@config.masked_api_key}")
20
+ end
21
+
22
+ def print_verification
23
+ verification = @client.verify
24
+ say("verify: #{verification.ok? ? "ok" : "FAILED"}, #{verification.message}")
25
+ verification.ok?
26
+ end
27
+
28
+ def print_test_event
29
+ outcome = TestEvent.new(@client).send_now
30
+ say("test event: #{outcome.ok? ? "ok" : "FAILED"}, #{outcome.message}")
31
+ outcome.ok?
32
+ end
33
+
34
+ def say(line)
35
+ @out.puts("#{PREFIX} #{line}")
36
+ end
37
+
38
+ private
39
+
40
+ def enabled_label
41
+ state = @client.enabled? ? "reporting enabled" : "reporting disabled"
42
+ "#{state}: #{@client.disabled_reason}"
43
+ end
44
+
45
+ def delivery_label
46
+ delivery = @config.delivery
47
+ delivery.is_a?(Symbol) ? delivery.to_s : delivery.class.name.to_s
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require_relative "../goodsorcery"
5
+ require_relative "status_report"
6
+ require_relative "integrations/rake"
7
+
8
+ module GoodSorcery
9
+ # Rake tasks: `require "goodsorcery/tasks"` in a Rakefile (Rails apps get
10
+ # them automatically). They load the app's :environment task when there is
11
+ # one. Requiring this file also reports exceptions that end a rake run
12
+ # (GoodSorcery::Integrations::Rake).
13
+ module Tasks
14
+ extend Rake::DSL
15
+
16
+ def self.load_environment
17
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
18
+ end
19
+
20
+ def self.verify(out: $stdout)
21
+ report = StatusReport.new(GoodSorcery.client, out: out)
22
+ report.print_settings
23
+ report.print_verification
24
+ end
25
+
26
+ def self.send_test_event(out: $stdout)
27
+ StatusReport.new(GoodSorcery.client, out: out).print_test_event
28
+ end
29
+
30
+ namespace :goodsorcery do
31
+ desc "Check the Good Sorcery endpoint and API key (sends nothing)"
32
+ task :verify do
33
+ GoodSorcery::Tasks.load_environment
34
+ abort("[goodsorcery] verification failed") unless GoodSorcery::Tasks.verify
35
+ end
36
+
37
+ desc "Send a test exception to Good Sorcery, in any environment"
38
+ task :test do
39
+ GoodSorcery::Tasks.load_environment
40
+ abort("[goodsorcery] test event failed") unless GoodSorcery::Tasks.send_test_event
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ GoodSorcery::Integrations::Rake.install
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Raises, captures and synchronously sends a GoodSorcery::TestError,
5
+ # regardless of the environment check. Used by `goodsorcery:test` and the
6
+ # install check.
7
+ class TestEvent
8
+ MESSAGE = "Test error from goodsorcery-ruby: if you can see this, error reporting works."
9
+
10
+ Outcome = Struct.new(:ok, :event_id, :message) do
11
+ def ok? = ok
12
+ end
13
+
14
+ def initialize(client)
15
+ @client = client
16
+ end
17
+
18
+ def send_now
19
+ return Outcome.new(false, nil, "no API key configured") if @client.config.api_key.to_s.empty?
20
+
21
+ raise TestError, MESSAGE
22
+ rescue TestError => e
23
+ deliver(e)
24
+ end
25
+
26
+ private
27
+
28
+ def deliver(exception)
29
+ event = @client.prepare_event(exception, transaction: "goodsorcery:test",
30
+ tags: { "source" => "goodsorcery:test" })
31
+ return Outcome.new(false, nil, "the test event was dropped by before_send") unless event
32
+
33
+ json = @client.serializer.dump(event)
34
+ return Outcome.new(false, nil, "the test event could not be serialized") unless json
35
+
36
+ outcome_for(@client.transport.post_event(json), event)
37
+ end
38
+
39
+ def outcome_for(result, event)
40
+ return Outcome.new(false, nil, "the server answered #{result.description}") unless result.success?
41
+
42
+ id = result.event_id || event["event_id"]
43
+ Outcome.new(true, id, "sent test event #{id}")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # String helpers: valid UTF-8, length caps, and copies the app can't mutate
5
+ # after the event was built.
6
+ module Text
7
+ module_function
8
+
9
+ def limit(value, max)
10
+ string = utf8(value.is_a?(String) ? value : value.to_s)
11
+ return string[0, max] if string.length > max
12
+
13
+ string.frozen? ? string : string.dup
14
+ end
15
+
16
+ def utf8(string)
17
+ return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
18
+
19
+ copy = string.dup
20
+ copy.force_encoding(Encoding::UTF_8) if copy.encoding == Encoding::BINARY
21
+ return copy.scrub if copy.encoding == Encoding::UTF_8
22
+
23
+ copy.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
24
+ rescue EncodingError
25
+ string.dup.force_encoding(Encoding::UTF_8).scrub
26
+ end
27
+
28
+ # to_s that never raises.
29
+ def safe_to_s(object)
30
+ object.to_s
31
+ rescue StandardError
32
+ "#<#{object.class}>"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ class Transport
5
+ # Splits serialized events into batch request bodies of at most 100
6
+ # events and about 1.5 MiB of uncompressed JSON (the server accepts 2 MiB).
7
+ module BatchChunks
8
+ MAX_EVENTS = 100
9
+ MAX_BYTES = 1_572_864
10
+
11
+ module_function
12
+
13
+ def split(jsons)
14
+ chunks = []
15
+ current = []
16
+ bytes = 0
17
+ jsons.each do |json|
18
+ if full?(current, bytes, json)
19
+ chunks << current
20
+ current = []
21
+ bytes = 0
22
+ end
23
+ current << json
24
+ bytes += json.bytesize + 1
25
+ end
26
+ current.empty? ? chunks : chunks << current
27
+ end
28
+
29
+ def full?(chunk, bytes, json)
30
+ return false if chunk.empty?
31
+
32
+ chunk.size >= MAX_EVENTS || bytes + json.bytesize > MAX_BYTES
33
+ end
34
+
35
+ def body(chunk)
36
+ "{\"events\":[#{chunk.join(",")}]}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ class Transport
5
+ # One persistent (keep-alive) HTTP connection. A failed request closes it,
6
+ # so the next one reconnects. A request on a reused connection that the
7
+ # server already closed is retried once on a fresh connection; event
8
+ # POSTs are idempotent because the server deduplicates event_id.
9
+ #
10
+ # A connection opened before a fork belongs to the parent: the child
11
+ # drops it without closing it (closing would end the parent's TLS
12
+ # session) and opens its own.
13
+ class Connection
14
+ STALE_ERRORS = [EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED].freeze
15
+ KEEP_ALIVE_SECONDS = 15
16
+
17
+ def initialize(uri, open_timeout:, read_timeout:)
18
+ @uri = uri
19
+ @open_timeout = open_timeout
20
+ @read_timeout = read_timeout
21
+ @http = nil
22
+ @pid = Process.pid
23
+ end
24
+
25
+ def request(request)
26
+ reused = @http&.started?
27
+ perform(request)
28
+ rescue *STALE_ERRORS
29
+ raise unless reused
30
+
31
+ perform(request)
32
+ end
33
+
34
+ # Sends one request on a short-lived connection.
35
+ def request_once(request)
36
+ http = build_http
37
+ http.start { |started| started.request(request) }
38
+ end
39
+
40
+ def close
41
+ forget_inherited_connection
42
+ @http&.finish if @http&.started?
43
+ rescue IOError, SystemCallError
44
+ nil
45
+ ensure
46
+ @http = nil
47
+ end
48
+
49
+ private
50
+
51
+ def perform(request)
52
+ http.request(request)
53
+ rescue StandardError
54
+ close
55
+ raise
56
+ end
57
+
58
+ def http
59
+ forget_inherited_connection
60
+ return @http if @http&.started?
61
+
62
+ @http = build_http
63
+ @http.start
64
+ @http
65
+ end
66
+
67
+ def forget_inherited_connection
68
+ return if @pid == Process.pid
69
+
70
+ @pid = Process.pid
71
+ @http = nil
72
+ end
73
+
74
+ def build_http
75
+ http = Net::HTTP.new(@uri.host, @uri.port)
76
+ http.use_ssl = @uri.scheme == "https"
77
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
78
+ http.open_timeout = @open_timeout
79
+ http.read_timeout = @read_timeout
80
+ http.write_timeout = @read_timeout
81
+ http.keep_alive_timeout = KEEP_ALIVE_SECONDS
82
+ http.max_retries = 0
83
+ http
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ class Transport
5
+ # The outcome of one request, classified for retry decisions:
6
+ #
7
+ # - success: 2xx
8
+ # - rejected: 400, 401, 413, 415, 422 and other 4xx (never retried)
9
+ # - not_found: 404 (an older server without the endpoint)
10
+ # - rate_limited: 429, with retry_after seconds
11
+ # - retryable: 5xx, 408, timeouts and connection errors
12
+ #
13
+ # +pending+ lists the serialized events still to send after a
14
+ # rate_limited or retryable batch result.
15
+ class Result
16
+ KINDS = %i[success rejected not_found rate_limited retryable].freeze
17
+
18
+ attr_reader :kind, :status, :retry_after, :error_class, :body, :pending
19
+
20
+ def self.from_response(response)
21
+ status = response.code.to_i
22
+ new(kind_for(status), status: status, body: ResponseBody.parse(response),
23
+ retry_after: status == 429 ? RetryAfter.parse(response["Retry-After"]) : nil)
24
+ end
25
+
26
+ def self.from_error(error)
27
+ new(:retryable, error_class: error.class.name)
28
+ end
29
+
30
+ def self.kind_for(status)
31
+ return :success if (200..299).cover?(status)
32
+ return :not_found if status == 404
33
+ return :rate_limited if status == 429
34
+ return :retryable if status >= 500 || status == 408
35
+
36
+ :rejected
37
+ end
38
+
39
+ def initialize(kind, status: nil, body: nil, retry_after: nil, error_class: nil, pending: [])
40
+ @kind = kind
41
+ @status = status
42
+ @body = body || {}
43
+ @retry_after = retry_after
44
+ @error_class = error_class
45
+ @pending = pending
46
+ end
47
+
48
+ KINDS.each { |name| define_method(:"#{name}?") { @kind == name } }
49
+
50
+ def retry_later?
51
+ rate_limited? || retryable?
52
+ end
53
+
54
+ def with_pending(pending)
55
+ self.class.new(kind, status: status, body: body, retry_after: retry_after,
56
+ error_class: error_class, pending: pending)
57
+ end
58
+
59
+ def event_id
60
+ body["id"]
61
+ end
62
+
63
+ # Status or error class only, never the body.
64
+ def description
65
+ status ? "HTTP #{status}" : error_class.to_s
66
+ end
67
+ end
68
+
69
+ # Parses small JSON response bodies; anything else becomes {}.
70
+ module ResponseBody
71
+ MAX_BYTES = 65_536
72
+
73
+ def self.parse(response)
74
+ text = response.body.to_s
75
+ return {} if text.empty? || text.bytesize > MAX_BYTES
76
+
77
+ parsed = JSON.parse(text)
78
+ parsed.is_a?(Hash) ? parsed : {}
79
+ rescue JSON::ParserError
80
+ {}
81
+ end
82
+ end
83
+
84
+ # Reads a Retry-After header (seconds or an HTTP date), defaulting to 60 s.
85
+ module RetryAfter
86
+ DEFAULT = 60
87
+ RANGE = (1..3_600)
88
+
89
+ def self.parse(value, now: Time.now)
90
+ seconds = value.to_s.strip.match?(/\A\d+\z/) ? value.to_i : http_date_delay(value, now)
91
+ (seconds || DEFAULT).clamp(RANGE.min, RANGE.max)
92
+ end
93
+
94
+ def self.http_date_delay(value, now)
95
+ return nil if value.nil? || value.to_s.empty?
96
+
97
+ (Time.httpdate(value.to_s) - now).ceil
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end