unisender-ruby 0.1.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +89 -0
- data/CONTRIBUTING.md +10 -1
- data/README.md +55 -112
- data/docs/api.md +395 -210
- data/docs/execution.md +89 -0
- data/docs/import-recovery.md +41 -0
- data/docs/live-contracts.md +34 -0
- data/docs/migration-0.2.md +129 -0
- data/docs/rails.md +248 -0
- data/docs/releases.md +4 -4
- data/docs/testing.md +83 -0
- data/docs/usage.md +144 -0
- data/docs/webhooks.md +41 -0
- data/examples/campaign.rb +5 -6
- data/examples/campaign_lifecycle.rb +28 -0
- data/examples/check_connection.rb +1 -1
- data/examples/events.rb +1 -1
- data/examples/export.rb +20 -6
- data/examples/import.rb +3 -2
- data/examples/import_recovery.rb +70 -0
- data/examples/quickstart.rb +1 -1
- data/examples/subscribe.rb +2 -2
- data/lib/unisender/acceptance.rb +104 -0
- data/lib/unisender/attachment.rb +47 -0
- data/lib/unisender/batch_report.rb +115 -0
- data/lib/unisender/batch_summary.rb +87 -0
- data/lib/unisender/batches.rb +41 -12
- data/lib/unisender/campaign_workflow.rb +72 -0
- data/lib/unisender/client.rb +65 -33
- data/lib/unisender/client_configuration.rb +50 -0
- data/lib/unisender/client_execution.rb +43 -0
- data/lib/unisender/client_state.rb +31 -0
- data/lib/unisender/client_validation.rb +97 -0
- data/lib/unisender/configuration.rb +151 -0
- data/lib/unisender/contact_records.rb +251 -0
- data/lib/unisender/diagnostic_urls.rb +68 -0
- data/lib/unisender/diagnostics.rb +83 -0
- data/lib/unisender/download_target.rb +61 -0
- data/lib/unisender/download_transfer.rb +43 -0
- data/lib/unisender/downloader.rb +99 -0
- data/lib/unisender/error_diagnostics.rb +83 -0
- data/lib/unisender/errors.rb +125 -8
- data/lib/unisender/event_batches.rb +40 -0
- data/lib/unisender/event_payload.rb +50 -22
- data/lib/unisender/events.rb +21 -5
- data/lib/unisender/execution_context.rb +114 -0
- data/lib/unisender/form.rb +20 -12
- data/lib/unisender/helpers.rb +70 -10
- data/lib/unisender/hooks.rb +79 -0
- data/lib/unisender/import_cells.rb +62 -0
- data/lib/unisender/input_types.rb +30 -0
- data/lib/unisender/inputs.rb +262 -0
- data/lib/unisender/iteration.rb +15 -0
- data/lib/unisender/iterators.rb +60 -0
- data/lib/unisender/mail_inputs.rb +37 -0
- data/lib/unisender/named_inputs.rb +52 -0
- data/lib/unisender/observation.rb +30 -0
- data/lib/unisender/operation_contracts.rb +106 -0
- data/lib/unisender/operations/contacts_1.rb +4 -3
- data/lib/unisender/operations/contacts_2.rb +23 -4
- data/lib/unisender/operations/messages_1.rb +7 -4
- data/lib/unisender/operations/messages_2.rb +8 -3
- data/lib/unisender/operations/messages_3.rb +1 -1
- data/lib/unisender/operations/notes_1.rb +1 -1
- data/lib/unisender/operations/statistics_1.rb +3 -3
- data/lib/unisender/operations/templates_1.rb +2 -2
- data/lib/unisender/outcomes.rb +320 -0
- data/lib/unisender/page_response.rb +48 -0
- data/lib/unisender/paginated_operations.rb +43 -2
- data/lib/unisender/pagination.rb +53 -20
- data/lib/unisender/persistent_transport.rb +104 -0
- data/lib/unisender/rate_limiter.rb +72 -0
- data/lib/unisender/record.rb +210 -0
- data/lib/unisender/record_rows.rb +69 -0
- data/lib/unisender/records.rb +614 -0
- data/lib/unisender/request_execution.rb +108 -0
- data/lib/unisender/response.rb +13 -10
- data/lib/unisender/result_registry.rb +72 -0
- data/lib/unisender/results.rb +105 -0
- data/lib/unisender/retry_policy.rb +10 -16
- data/lib/unisender/serialization.rb +54 -0
- data/lib/unisender/statistic_records.rb +129 -0
- data/lib/unisender/task_response.rb +85 -0
- data/lib/unisender/task_waiter.rb +25 -11
- data/lib/unisender/testing/assertions.rb +48 -0
- data/lib/unisender/testing/fixtures.rb +24 -0
- data/lib/unisender/testing.rb +91 -0
- data/lib/unisender/transport.rb +17 -9
- data/lib/unisender/validation.rb +11 -9
- data/lib/unisender/version.rb +1 -1
- data/lib/unisender/webhooks/tokens.rb +38 -0
- data/lib/unisender/webhooks.rb +230 -0
- data/lib/unisender.rb +42 -0
- data/sig/acceptance.rbs +30 -0
- data/sig/attachment.rbs +15 -0
- data/sig/batch_summary.rbs +38 -0
- data/sig/campaign_workflow.rbs +20 -0
- data/sig/configuration.rbs +62 -0
- data/sig/connection_controls.rbs +55 -0
- data/sig/diagnostics.rbs +37 -0
- data/sig/domain_records.rbs +96 -0
- data/sig/downloads.rbs +53 -0
- data/sig/execution.rbs +52 -0
- data/sig/execution_context.rbs +46 -0
- data/sig/hooks.rbs +18 -0
- data/sig/inputs.rbs +84 -0
- data/sig/iterators.rbs +11 -0
- data/sig/mail_inputs.rbs +8 -0
- data/sig/manifest.yaml +11 -0
- data/sig/operation_contracts.rbs +28 -0
- data/sig/operations.rbs +73 -69
- data/sig/outcomes.rbs +90 -0
- data/sig/record.rbs +77 -0
- data/sig/records.rbs +182 -0
- data/sig/serialization.rbs +13 -0
- data/sig/tasks.rbs +26 -0
- data/sig/testing.rbs +54 -0
- data/sig/unisender.rbs +128 -39
- data/sig/webhooks.rbs +55 -0
- metadata +84 -3
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Unisender
|
|
7
|
+
DownloadReceipt = Data.define(:bytes_written, :http_status)
|
|
8
|
+
|
|
9
|
+
# A failed transfer can leave a partial result in the caller's destination.
|
|
10
|
+
class DownloadError < Error
|
|
11
|
+
attr_reader :reason, :bytes_written, :http_status
|
|
12
|
+
|
|
13
|
+
def category
|
|
14
|
+
:download
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(reason:, bytes_written: 0, http_status: nil)
|
|
18
|
+
@reason = reason
|
|
19
|
+
@bytes_written = bytes_written
|
|
20
|
+
@http_status = http_status
|
|
21
|
+
super("Download failed: #{reason}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Downloads use a separate credential-free connection and never follow redirects.
|
|
26
|
+
class Downloader
|
|
27
|
+
def initialize(open_timeout: 5, read_timeout: 60, write_timeout: 30, resolver: nil, http_factory: nil)
|
|
28
|
+
unless [open_timeout, read_timeout, write_timeout].all? do |value|
|
|
29
|
+
value.is_a?(Numeric) && value.positive? && value.finite?
|
|
30
|
+
end
|
|
31
|
+
raise ArgumentError, "Download timeouts must be positive and finite"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@open_timeout = open_timeout
|
|
35
|
+
@read_timeout = read_timeout
|
|
36
|
+
@write_timeout = write_timeout
|
|
37
|
+
@target = DownloadTarget.new(timeout: open_timeout, resolver:)
|
|
38
|
+
@http_factory = http_factory || ->(uri, address) { build_http(uri, address) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def download(url, io, max_bytes:, execution: nil)
|
|
42
|
+
transfer = DownloadTransfer.new(io, max_bytes, execution:)
|
|
43
|
+
execute(url, transfer, execution)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def execute(url, transfer, execution)
|
|
49
|
+
response = download_response(url, transfer, execution)
|
|
50
|
+
DownloadReceipt.new(bytes_written: transfer.bytes_written, http_status: response.code.to_i)
|
|
51
|
+
rescue CancelledError, DeadlineExceededError => e
|
|
52
|
+
raise e.with_download(bytes_written: transfer.bytes_written), cause: nil
|
|
53
|
+
rescue DownloadError => e
|
|
54
|
+
raise DownloadError.new(reason: e.reason, bytes_written: transfer.bytes_written, http_status: e.http_status),
|
|
55
|
+
cause: nil
|
|
56
|
+
rescue StandardError
|
|
57
|
+
raise DownloadError.new(reason: :transport, bytes_written: transfer.bytes_written), cause: nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def download_response(url, transfer, execution)
|
|
61
|
+
execution&.check!
|
|
62
|
+
uri, address = @target.resolve(url)
|
|
63
|
+
http = configured_http(uri, address, execution)
|
|
64
|
+
http.start do |connection|
|
|
65
|
+
request = Net::HTTP::Get.new(uri, { "Accept-Encoding" => "identity" })
|
|
66
|
+
connection.request(request) { |reply| stream(reply, transfer) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_http(uri, address)
|
|
71
|
+
http = Net::HTTP.new(uri.hostname.to_s, uri.port, nil)
|
|
72
|
+
http.ipaddr = address
|
|
73
|
+
http
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def configured_http(uri, address, execution = nil)
|
|
77
|
+
http = @http_factory.call(uri, address)
|
|
78
|
+
remaining = execution&.remaining
|
|
79
|
+
http.use_ssl = true
|
|
80
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
81
|
+
http.open_timeout = remaining ? [@open_timeout, remaining].min : @open_timeout
|
|
82
|
+
http.read_timeout = remaining ? [@read_timeout, remaining].min : @read_timeout
|
|
83
|
+
http.write_timeout = remaining ? [@write_timeout, remaining].min : @write_timeout
|
|
84
|
+
http.max_retries = 0
|
|
85
|
+
http
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stream(response, transfer)
|
|
89
|
+
status = response.code.to_i
|
|
90
|
+
raise DownloadError.new(reason: :http_error, http_status: status) unless (200..299).cover?(status)
|
|
91
|
+
|
|
92
|
+
response.read_body { |chunk| transfer.write(chunk) }
|
|
93
|
+
length = response["content-length"]
|
|
94
|
+
return unless length&.match?(/\A\d+\z/) && transfer.bytes_written != length.to_i
|
|
95
|
+
|
|
96
|
+
raise DownloadError.new(reason: :incomplete)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Stable categories and safe recovery hints supplement the upstream details.
|
|
5
|
+
class Error
|
|
6
|
+
HINTS = {
|
|
7
|
+
authentication: "Check the API key.", authorization: "Check API access and account permissions.",
|
|
8
|
+
invalid_argument: "Check the operation parameters.", payment: "Check the account balance.",
|
|
9
|
+
rate_limit: "Wait before another request; check whether repeating this operation is safe.",
|
|
10
|
+
not_found: "Check the requested identifier.", client_closed: "Create an open client before making a request.",
|
|
11
|
+
pagination_limit: "Resume from next_offset with an explicit page budget.",
|
|
12
|
+
task_failed: "Inspect the task response before starting another task.",
|
|
13
|
+
task_state: "Inspect the task response and documented states.",
|
|
14
|
+
unknown_task_state: "Inspect the task response and update the supported task states.",
|
|
15
|
+
partial_failure: "Reconcile accepted records before retrying rejected records.",
|
|
16
|
+
download: "Inspect the download reason and discard or retain partial output explicitly."
|
|
17
|
+
}.freeze
|
|
18
|
+
def self.safe_operation(operation)
|
|
19
|
+
OPERATIONS.include?(operation) ? operation.dup.freeze : nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def category
|
|
23
|
+
error = self # : Error
|
|
24
|
+
case error
|
|
25
|
+
when APIError then api_category(error.code)
|
|
26
|
+
when HTTPError then http_category(error.response.http_status)
|
|
27
|
+
when InvalidResponseError then :invalid_response
|
|
28
|
+
when TimeoutError then :timeout
|
|
29
|
+
when TransportError then :transport
|
|
30
|
+
else :unknown
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def transient?
|
|
35
|
+
error = self # : Error
|
|
36
|
+
case error
|
|
37
|
+
when APIError then RetryPolicy::TRANSIENT_CODES.include?(error.code)
|
|
38
|
+
when HTTPError then [429, 502, 503, 504].include?(error.response.http_status)
|
|
39
|
+
when TimeoutError then true
|
|
40
|
+
when TransportError then error.retryable?
|
|
41
|
+
else false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def safe_to_retry?
|
|
46
|
+
name = operation
|
|
47
|
+
name.is_a?(String) && RetryPolicy::READ_ONLY.include?(name) && transient?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def request_id
|
|
51
|
+
response = diagnostic_response
|
|
52
|
+
@context_request_id || response&.headers&.[]("x-request-id")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def recovery_hint
|
|
56
|
+
HINTS.fetch(category, "Inspect the failure and reconcile mutations before repeating the operation.")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def api_category(code)
|
|
62
|
+
case code
|
|
63
|
+
when "invalid_api_key" then :authentication
|
|
64
|
+
when "access_denied" then :authorization
|
|
65
|
+
when "invalid_arg" then :invalid_argument
|
|
66
|
+
when "not_enough_money" then :payment
|
|
67
|
+
when "retry_later" then :transient
|
|
68
|
+
when "api_call_limit_exceeded_for_api_key", "api_call_limit_exceeded_for_ip" then :rate_limit
|
|
69
|
+
else :unknown
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def http_category(status)
|
|
74
|
+
case status
|
|
75
|
+
when 401 then :authentication
|
|
76
|
+
when 403 then :authorization
|
|
77
|
+
when 429 then :rate_limit
|
|
78
|
+
when 500..599 then :service_unavailable
|
|
79
|
+
else :http
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/unisender/errors.rb
CHANGED
|
@@ -13,11 +13,62 @@ module Unisender
|
|
|
13
13
|
createEmailTemplate deleteTemplate getTemplate getTemplates listTemplates updateEmailTemplate
|
|
14
14
|
createField deleteField deleteTag getFields getTags updateField getContactFieldValues
|
|
15
15
|
createSubscriberNote updateSubscriberNote deleteSubscriberNote getSubscriberNote getSubscriberNotes
|
|
16
|
-
track_events
|
|
16
|
+
track_events setHook listHooks removeHook
|
|
17
17
|
].freeze
|
|
18
18
|
private_constant :OPERATIONS
|
|
19
19
|
|
|
20
|
-
attr_reader :operation
|
|
20
|
+
attr_reader :operation, :next_offset, :completed_pages, :last_response
|
|
21
|
+
|
|
22
|
+
def diagnostics
|
|
23
|
+
Diagnostics.for_error(self)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def documentation_url
|
|
27
|
+
Diagnostics::URLS[@operation.to_s]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_http(status:, request_id:)
|
|
31
|
+
@context_http_status = status
|
|
32
|
+
@context_request_id = request_id&.dup&.freeze
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def http_status
|
|
37
|
+
response = diagnostic_response
|
|
38
|
+
@context_http_status || response&.http_status
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def diagnostic_response
|
|
42
|
+
error = self # : Error
|
|
43
|
+
case error
|
|
44
|
+
when APIError, HTTPError, PartialFailure, TaskError, TaskTimeoutError then error.response
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bytes_written
|
|
49
|
+
@download_bytes_written
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def with_download(bytes_written:)
|
|
53
|
+
@download_bytes_written = bytes_written
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def task_uuid
|
|
58
|
+
@polling_task_uuid
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def with_task(task_uuid:, last_response:)
|
|
62
|
+
@polling_task_uuid = task_uuid.dup.freeze
|
|
63
|
+
@last_response = last_response
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def with_pagination(next_offset:, completed_pages:)
|
|
68
|
+
@next_offset = next_offset
|
|
69
|
+
@completed_pages = completed_pages
|
|
70
|
+
self
|
|
71
|
+
end
|
|
21
72
|
|
|
22
73
|
def with_operation(operation)
|
|
23
74
|
@operation = OPERATIONS.include?(operation) ? operation.dup.freeze : nil
|
|
@@ -44,6 +95,11 @@ module Unisender
|
|
|
44
95
|
|
|
45
96
|
attr_reader :response, :code, :details
|
|
46
97
|
|
|
98
|
+
def safe_code
|
|
99
|
+
code = @code
|
|
100
|
+
code if code.is_a?(String) && SAFE_CODES.include?(code)
|
|
101
|
+
end
|
|
102
|
+
|
|
47
103
|
def initialize(response)
|
|
48
104
|
@response = response
|
|
49
105
|
@code = response.data["code"]
|
|
@@ -58,11 +114,30 @@ module Unisender
|
|
|
58
114
|
|
|
59
115
|
def initialize(response)
|
|
60
116
|
@response = response
|
|
61
|
-
super("HTTP #{response.
|
|
117
|
+
super("HTTP #{response.http_status}")
|
|
62
118
|
end
|
|
63
119
|
end
|
|
64
120
|
|
|
65
|
-
|
|
121
|
+
# A malformed response with optional safe structural context.
|
|
122
|
+
class InvalidResponseError < Error
|
|
123
|
+
attr_reader :path, :expected_type, :actual_type
|
|
124
|
+
|
|
125
|
+
def initialize(message = "Invalid response", path: nil, expected_type: nil, actual_type: nil)
|
|
126
|
+
@path = path
|
|
127
|
+
@expected_type = expected_type
|
|
128
|
+
@actual_type = actual_type
|
|
129
|
+
super(message)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def prepend_path(prefix)
|
|
133
|
+
@path = @path ? "#{prefix}.#{@path}".freeze : prefix.dup.freeze
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def to_s
|
|
138
|
+
@path ? "#{super} at #{@path}" : super
|
|
139
|
+
end
|
|
140
|
+
end
|
|
66
141
|
|
|
67
142
|
# Network or TLS failure with no complete HTTP response.
|
|
68
143
|
class TransportError < Error
|
|
@@ -80,6 +155,10 @@ module Unisender
|
|
|
80
155
|
|
|
81
156
|
# A failed event request can still have accepted some events (HTTP 400).
|
|
82
157
|
class EventError < HTTPError
|
|
158
|
+
def report
|
|
159
|
+
EventResult.new({ "success" => false, "uncertain" => response.http_status != 401 })
|
|
160
|
+
end
|
|
161
|
+
|
|
83
162
|
def code
|
|
84
163
|
response.data["code"]
|
|
85
164
|
end
|
|
@@ -89,26 +168,60 @@ module Unisender
|
|
|
89
168
|
end
|
|
90
169
|
|
|
91
170
|
def possibly_partially_accepted?
|
|
92
|
-
response.
|
|
171
|
+
response.http_status == 400
|
|
93
172
|
end
|
|
94
173
|
end
|
|
95
174
|
|
|
96
175
|
# Carries the completed batches and the range whose outcome needs inspection.
|
|
97
176
|
class BatchError < Error
|
|
98
|
-
attr_reader :completed_responses, :failed_range, :error
|
|
177
|
+
attr_reader :completed_responses, :failed_range, :error, :report
|
|
99
178
|
|
|
100
|
-
def initialize(completed_responses:, failed_range:, error:)
|
|
179
|
+
def initialize(completed_responses:, failed_range:, error:, report: nil)
|
|
101
180
|
@completed_responses = completed_responses.dup.freeze
|
|
102
181
|
@failed_range = failed_range.freeze
|
|
103
182
|
@error = error
|
|
183
|
+
@report = report || BatchReport.new(@completed_responses, error:, failed_range:)
|
|
104
184
|
super("UniSender batch failed; inspect the completed responses and failed range")
|
|
105
185
|
end
|
|
186
|
+
|
|
187
|
+
def category
|
|
188
|
+
error = @error
|
|
189
|
+
error.is_a?(Error) || error.is_a?(ValidationError) ? error.category : :unexpected
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def request_id
|
|
193
|
+
error = @error
|
|
194
|
+
error.request_id if error.is_a?(Error)
|
|
195
|
+
end
|
|
106
196
|
end
|
|
107
197
|
|
|
108
|
-
|
|
198
|
+
# Identifies the next unread offset when the page bound is reached.
|
|
199
|
+
class PaginationError < Error
|
|
200
|
+
def category
|
|
201
|
+
:pagination_limit
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def initialize(operation:, next_offset:, completed_pages:)
|
|
205
|
+
@operation = operation
|
|
206
|
+
@next_offset = next_offset
|
|
207
|
+
@completed_pages = completed_pages
|
|
208
|
+
super("Pagination page limit reached")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A singular operation returned no matching record.
|
|
213
|
+
class NotFoundError < Error
|
|
214
|
+
def category
|
|
215
|
+
:not_found
|
|
216
|
+
end
|
|
217
|
+
end
|
|
109
218
|
|
|
110
219
|
# The API accepted the import request but rejected some records.
|
|
111
220
|
class PartialFailure < Error
|
|
221
|
+
def category
|
|
222
|
+
:partial_failure
|
|
223
|
+
end
|
|
224
|
+
|
|
112
225
|
attr_reader :response
|
|
113
226
|
|
|
114
227
|
def initialize(response)
|
|
@@ -119,6 +232,10 @@ module Unisender
|
|
|
119
232
|
|
|
120
233
|
# Includes the last response for an unexpected or failed asynchronous task state.
|
|
121
234
|
class TaskError < Error
|
|
235
|
+
def category
|
|
236
|
+
:task_state
|
|
237
|
+
end
|
|
238
|
+
|
|
122
239
|
attr_reader :response
|
|
123
240
|
|
|
124
241
|
def initialize(response)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Chooses request boundaries before sending any event in a chunk.
|
|
5
|
+
module EventBatches
|
|
6
|
+
def self.each(events, size)
|
|
7
|
+
Batches.validate_size(size, 25)
|
|
8
|
+
Enumerator.new { |output| build(events, size, output) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.build(events, size, output)
|
|
12
|
+
# @type var batch: Array[params]
|
|
13
|
+
batch = []
|
|
14
|
+
events.each_with_index do |event, index|
|
|
15
|
+
validate(event, index)
|
|
16
|
+
batch = append(batch, event, size, output)
|
|
17
|
+
end
|
|
18
|
+
output << batch unless batch.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.append(batch, event, size, output)
|
|
22
|
+
if EventPayload.serialize(batch + [event]).bytesize > EventPayload::MAX_BYTES
|
|
23
|
+
output << batch
|
|
24
|
+
batch = [] # : Array[params]
|
|
25
|
+
end
|
|
26
|
+
batch << event
|
|
27
|
+
return batch unless batch.size == size
|
|
28
|
+
|
|
29
|
+
output << batch
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate(event, index)
|
|
34
|
+
EventPayload.encode([event])
|
|
35
|
+
rescue ArgumentError
|
|
36
|
+
raise ValidationError.new(field: "events[#{index}]", code: :invalid_event, operation: "track_events", index:),
|
|
37
|
+
cause: nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -3,59 +3,87 @@
|
|
|
3
3
|
module Unisender
|
|
4
4
|
# Event Tracking uses JSON with its own batch and payload limits.
|
|
5
5
|
module EventPayload
|
|
6
|
+
MAX_BYTES = 130 * 1024
|
|
7
|
+
|
|
6
8
|
def self.encode(events)
|
|
7
|
-
|
|
9
|
+
unless events.is_a?(Array)
|
|
10
|
+
raise ValidationError.new(field: "events", code: :invalid_type, expected_type: "Array",
|
|
11
|
+
actual_type: Diagnostics.type_name(events))
|
|
12
|
+
end
|
|
13
|
+
raise ValidationError.new(field: "events", code: :invalid_count) unless (1..25).cover?(events.size)
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
raise ArgumentError, "Event payload exceeds 130 KB" if body.bytesize > 130 * 1024
|
|
15
|
+
body = serialize(events)
|
|
16
|
+
raise ValidationError.new(field: "events", code: :too_large) if body.bytesize > MAX_BYTES
|
|
12
17
|
|
|
13
18
|
body
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.serialize(events)
|
|
22
|
+
normalized = events.each_with_index.map do |event, index|
|
|
23
|
+
path = "events[#{index}]"
|
|
24
|
+
validate(normalize(with_timestamp(event), 0, path), path)
|
|
25
|
+
end
|
|
26
|
+
JSON.generate({ "events" => normalized })
|
|
14
27
|
rescue EncodingError, JSON::GeneratorError
|
|
15
|
-
raise
|
|
28
|
+
raise ValidationError.new(field: "events", code: :invalid_encoding), cause: nil
|
|
16
29
|
end
|
|
17
30
|
|
|
18
|
-
def self.
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
def self.with_timestamp(event)
|
|
32
|
+
return event unless event.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
event.to_h do |key, value|
|
|
35
|
+
item = key.to_s == "created" && value.is_a?(Time) ? value.getutc.strftime("%Y-%m-%d %H:%M:%S") : value
|
|
36
|
+
[key, item]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.validate(event, path)
|
|
41
|
+
unless event.is_a?(Hash)
|
|
42
|
+
raise ValidationError.new(field: path, code: :invalid_type, expected_type: "Hash",
|
|
43
|
+
actual_type: Diagnostics.type_name(event))
|
|
44
|
+
end
|
|
45
|
+
raise ValidationError.new(field: path, code: :too_many_fields) if event.size > 25
|
|
21
46
|
|
|
22
|
-
validate_name(event["name"])
|
|
23
|
-
validate_email(event["email"])
|
|
47
|
+
validate_name(event["name"], "#{path}.name")
|
|
48
|
+
validate_email(event["email"], "#{path}.email")
|
|
24
49
|
event
|
|
25
50
|
end
|
|
26
51
|
|
|
27
|
-
def self.validate_name(name)
|
|
52
|
+
def self.validate_name(name, path)
|
|
28
53
|
return if name.is_a?(String) && (1..40).cover?(name.size)
|
|
29
54
|
|
|
30
|
-
raise
|
|
55
|
+
raise ValidationError.new(field: path, code: :invalid_name, expected_type: "String",
|
|
56
|
+
actual_type: Diagnostics.type_name(name))
|
|
31
57
|
end
|
|
32
58
|
|
|
33
|
-
def self.validate_email(email)
|
|
59
|
+
def self.validate_email(email, path)
|
|
34
60
|
return if email.is_a?(String) && email.ascii_only? && email.match?(/\A[^\s@]+@[^\s@]+\z/)
|
|
35
61
|
|
|
36
|
-
raise
|
|
62
|
+
raise ValidationError.new(field: path, code: :invalid_email, expected_type: "String",
|
|
63
|
+
actual_type: Diagnostics.type_name(email))
|
|
37
64
|
end
|
|
38
65
|
|
|
39
|
-
def self.normalize(value, depth)
|
|
40
|
-
raise
|
|
66
|
+
def self.normalize(value, depth, path)
|
|
67
|
+
raise ValidationError.new(field: path, code: :too_deep) if depth > 32
|
|
41
68
|
|
|
42
69
|
case value
|
|
43
|
-
when Hash then normalize_hash(value, depth)
|
|
44
|
-
when Array then value.map { |item| normalize(item, depth + 1) }
|
|
70
|
+
when Hash then normalize_hash(value, depth, path)
|
|
71
|
+
when Array then value.each_with_index.map { |item, index| normalize(item, depth + 1, "#{path}[#{index}]") }
|
|
45
72
|
when String then value.encode(Encoding::UTF_8)
|
|
46
73
|
when Integer, Float, true, false, nil then value
|
|
47
|
-
else raise
|
|
74
|
+
else raise ValidationError.new(field: path, code: :invalid_type, actual_type: Diagnostics.type_name(value))
|
|
48
75
|
end
|
|
49
76
|
end
|
|
50
77
|
|
|
51
|
-
def self.normalize_hash(value, depth)
|
|
78
|
+
def self.normalize_hash(value, depth, path)
|
|
52
79
|
# @type var result: Hash[String, json]
|
|
53
80
|
result = {}
|
|
54
81
|
value.each do |key, item|
|
|
55
82
|
name = key.to_s.encode(Encoding::UTF_8)
|
|
56
|
-
raise
|
|
83
|
+
raise ValidationError.new(field: path, code: :duplicate_key) if result.key?(name)
|
|
57
84
|
|
|
58
|
-
|
|
85
|
+
field = Diagnostics::FIELDS.include?(name) ? name : "*"
|
|
86
|
+
result[name] = normalize(item, depth + 1, "#{path}.#{field}")
|
|
59
87
|
end
|
|
60
88
|
result
|
|
61
89
|
end
|
data/lib/unisender/events.rb
CHANGED
|
@@ -5,15 +5,31 @@ module Unisender
|
|
|
5
5
|
class Client
|
|
6
6
|
def track_events(events:)
|
|
7
7
|
body = EventPayload.encode(events)
|
|
8
|
-
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{@api_key}"
|
|
9
|
-
|
|
8
|
+
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{@api_key}",
|
|
9
|
+
"User-Agent" => USER_AGENT }
|
|
10
|
+
exchange("track_events", URI("https://event.unisender.com/v1/events/"), headers:, body:,
|
|
11
|
+
input_count: events.size) do |http|
|
|
12
|
+
decode_event_response(http, events.size)
|
|
13
|
+
end
|
|
14
|
+
rescue Error, ValidationError => e
|
|
15
|
+
raise e.with_operation("track_events"), cause: nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def decode_event_response(http, count)
|
|
21
|
+
response = parse_event_response(http)
|
|
22
|
+
observe_acceptance("track_events", count, response, nil)
|
|
23
|
+
response
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse_event_response(http)
|
|
10
27
|
response = Response.new(data: Protocol.decode(http), status: http.status, headers: http.headers)
|
|
11
28
|
raise EventError, response, cause: nil unless (200..299).cover?(http.status)
|
|
12
29
|
raise InvalidResponseError, "Missing event success status", cause: nil unless response.data["success"] == true
|
|
13
30
|
|
|
14
|
-
response
|
|
15
|
-
|
|
16
|
-
raise e.with_operation("track_events"), cause: nil
|
|
31
|
+
EventResponse.new(data: response.data, status: response.http_status, headers: response.headers,
|
|
32
|
+
result: EventResult.new(response.data))
|
|
17
33
|
end
|
|
18
34
|
end
|
|
19
35
|
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Thread-safe cooperative cancellation. It never interrupts in-flight work.
|
|
5
|
+
class CancellationToken
|
|
6
|
+
def initialize
|
|
7
|
+
@cancelled = false
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def cancel
|
|
12
|
+
@mutex.synchronize { @cancelled = true }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cancelled?
|
|
16
|
+
@mutex.synchronize { @cancelled }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Cancellation reached a cooperative boundary.
|
|
21
|
+
class CancelledError < Error
|
|
22
|
+
def category
|
|
23
|
+
:cancelled
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The shared monotonic budget expired at a cooperative boundary.
|
|
28
|
+
class DeadlineExceededError < Error
|
|
29
|
+
def category
|
|
30
|
+
:deadline_exceeded
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Separate from request-attempt measurements and free of payloads.
|
|
35
|
+
ExecutionEvent = Data.define(:kind, :operation, :duration, :outcome)
|
|
36
|
+
|
|
37
|
+
# One monotonic budget shared by all work in a scoped client.
|
|
38
|
+
class ExecutionContext
|
|
39
|
+
def initialize(timeout: nil, cancellation: nil, parent: nil, instrumentation: nil, clock: nil)
|
|
40
|
+
if timeout && !(timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?)
|
|
41
|
+
raise ArgumentError, "Execution timeout must be positive and finite"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
45
|
+
@deadline = @clock.call + Float(timeout) if timeout
|
|
46
|
+
@cancellation = cancellation
|
|
47
|
+
@parent = parent
|
|
48
|
+
@instrumentation = instrumentation
|
|
49
|
+
@closed = false
|
|
50
|
+
@owned_clients = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check!
|
|
54
|
+
raise ClientClosedError, "Execution scope is closed" if @closed
|
|
55
|
+
|
|
56
|
+
@parent&.check!
|
|
57
|
+
raise CancelledError, "Execution cancelled" if @cancellation&.cancelled?
|
|
58
|
+
|
|
59
|
+
deadline = @deadline
|
|
60
|
+
raise DeadlineExceededError, "Execution deadline exceeded" if deadline && @clock.call >= deadline
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
@closed = true
|
|
65
|
+
@owned_clients.each(&:close)
|
|
66
|
+
@owned_clients.clear
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def own(client)
|
|
70
|
+
@owned_clients << client
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def remaining
|
|
74
|
+
check!
|
|
75
|
+
deadline = @deadline
|
|
76
|
+
values = [deadline ? deadline - @clock.call : nil, @parent&.remaining].compact
|
|
77
|
+
values.min
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def wait(delay, kind, operation, sleeper)
|
|
81
|
+
measure(kind, operation) do
|
|
82
|
+
endpoint = @clock.call + delay
|
|
83
|
+
loop do
|
|
84
|
+
check!
|
|
85
|
+
remaining = endpoint - @clock.call
|
|
86
|
+
break unless remaining.positive?
|
|
87
|
+
|
|
88
|
+
sleeper.call([remaining, 0.05].min)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def measure(kind, operation)
|
|
94
|
+
started = @clock.call
|
|
95
|
+
outcome = :failure
|
|
96
|
+
begin
|
|
97
|
+
result = yield
|
|
98
|
+
outcome = :success
|
|
99
|
+
result
|
|
100
|
+
ensure
|
|
101
|
+
emit(ExecutionEvent.new(kind:, operation: Error.safe_operation(operation) || "workflow",
|
|
102
|
+
duration: @clock.call - started, outcome:))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def emit(event)
|
|
109
|
+
@instrumentation&.call(event)
|
|
110
|
+
rescue StandardError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|