onetime 0.5.0 → 0.6.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.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onetime
4
+ # Base class for every error raised by this library.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the client is misconfigured (bad base_url, missing
8
+ # credentials for an authenticated call, unsupported api_version, ...).
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised when an operation is not available on the configured API
12
+ # version (e.g. asking the v1 API for a secret's status).
13
+ class UnsupportedOperationError < Error; end
14
+
15
+ # Raised for transport-level failures: DNS, connection refused, reset
16
+ # connections, TLS errors. Wraps the underlying exception in #cause.
17
+ class TransportError < Error; end
18
+
19
+ # Raised when a request exceeds the configured open/read timeout.
20
+ class TimeoutError < TransportError; end
21
+
22
+ # Base class for errors returned by the API (HTTP status >= 400).
23
+ #
24
+ # Carries the structured fields from the ADR-013 wire format
25
+ # ({ error:, error_type:, ... }) as well as the legacy v1 { message: }
26
+ # shape, so callers get a consistent interface across API versions.
27
+ class APIError < Error
28
+ attr_reader :http_status, :error_type, :code, :field, :error_key,
29
+ :retry_after, :entitlement, :body, :response
30
+
31
+ def initialize(message = nil, http_status: nil, error_type: nil, code: nil,
32
+ field: nil, error_key: nil, retry_after: nil, entitlement: nil,
33
+ body: nil, response: nil)
34
+ super(message)
35
+ @http_status = http_status
36
+ @error_type = error_type
37
+ @code = code
38
+ @field = field
39
+ @error_key = error_key
40
+ @retry_after = retry_after
41
+ @entitlement = entitlement
42
+ @body = body
43
+ @response = response
44
+ end
45
+ end
46
+
47
+ class BadRequestError < APIError; end # 400 / FormError
48
+ class AuthenticationError < APIError; end # 401
49
+ class ForbiddenError < APIError; end # 403 / Forbidden, GuestRoutesDisabled
50
+ class EntitlementError < ForbiddenError; end # EntitlementRequired
51
+ class NotFoundError < APIError; end # 404 / RecordNotFound
52
+ class ConflictError < APIError; end # 409
53
+ class RateLimitError < APIError; end # 429 / LimitExceeded
54
+ class ServerError < APIError; end # 5xx / ServerError
55
+
56
+ # The operation is only available to an authenticated account. The server
57
+ # reports this as a field/key rather than a status of its own (historically
58
+ # a 400 FormError with field "requires_account"), which made it look like a
59
+ # malformed request body. It is an authentication problem, so it subclasses
60
+ # AuthenticationError; #field is preserved for callers that need it.
61
+ class AccountRequiredError < AuthenticationError; end
62
+
63
+ # Builds the appropriate APIError subclass from an HTTP response.
64
+ module Errors
65
+ module_function
66
+
67
+ # Used only when the server sends no message of its own.
68
+ ACCOUNT_REQUIRED_MESSAGE =
69
+ "This operation requires an authenticated account: send your " \
70
+ "customer extid and API token (see Onetime::Client.new)."
71
+
72
+ # Maps the ADR-013 error_type (the machine-readable class name the
73
+ # server sends) to a client exception class. Falls through to status
74
+ # code mapping when the type is absent or unrecognized.
75
+ ERROR_TYPE_MAP = {
76
+ "FormError" => BadRequestError,
77
+ "RequiresAccount" => AccountRequiredError,
78
+ "AccountRequired" => AccountRequiredError,
79
+ "RecordNotFound" => NotFoundError,
80
+ "NotFound" => NotFoundError,
81
+ "Forbidden" => ForbiddenError,
82
+ "GuestRoutesDisabled" => ForbiddenError,
83
+ "EntitlementRequired" => EntitlementError,
84
+ "LimitExceeded" => RateLimitError,
85
+ "ServerError" => ServerError,
86
+ }.freeze
87
+
88
+ # The account requirement can arrive in any of several slots depending on
89
+ # the endpoint and API version: as the FormError `field`, as the i18n
90
+ # `error_key`, or as a `code`. Matching all of them keeps the mapping
91
+ # stable across server versions.
92
+ ACCOUNT_REQUIRED_KEYS = %w[error_type field error_key code].freeze
93
+ # Matches the bare token as well as prefixed codes such as
94
+ # "GUEST_CONCEAL_REQUIRES_ACCOUNT" (underscores are word characters, so
95
+ # \b is no help here).
96
+ ACCOUNT_REQUIRED_PATTERN =
97
+ /(?:\A|[^a-z0-9])(requires[_-]?account|account[_-]?required)(?:\z|[^a-z0-9])/i
98
+
99
+ STATUS_MAP = {
100
+ 400 => BadRequestError,
101
+ 401 => AuthenticationError,
102
+ 403 => ForbiddenError,
103
+ 404 => NotFoundError,
104
+ 409 => ConflictError,
105
+ 422 => BadRequestError,
106
+ 429 => RateLimitError,
107
+ }.freeze
108
+
109
+ # @param response [Onetime::Response]
110
+ # @return [Onetime::APIError]
111
+ def from_response(response)
112
+ body = response.data.is_a?(Hash) ? response.data : {}
113
+ status = response.http_status
114
+
115
+ account = account_required?(body)
116
+ klass = account ? AccountRequiredError : error_class(body["error_type"], status)
117
+ message = body["error"] || body["message"] ||
118
+ (account ? ACCOUNT_REQUIRED_MESSAGE : default_message(status))
119
+
120
+ klass.new(
121
+ message,
122
+ http_status: status,
123
+ error_type: body["error_type"],
124
+ code: body["code"],
125
+ field: body["field"],
126
+ error_key: body["error_key"],
127
+ retry_after: body["retry_after"],
128
+ entitlement: body["entitlement"],
129
+ body: response.data,
130
+ response: response,
131
+ )
132
+ end
133
+
134
+ # True when the body says the operation needs an authenticated account,
135
+ # in whichever slot this server version reports it.
136
+ def account_required?(body)
137
+ ACCOUNT_REQUIRED_KEYS.any? do |key|
138
+ ACCOUNT_REQUIRED_PATTERN.match?(body[key].to_s)
139
+ end
140
+ end
141
+
142
+ def error_class(error_type, status)
143
+ ERROR_TYPE_MAP[error_type] ||
144
+ STATUS_MAP[status] ||
145
+ (status.to_i >= 500 ? ServerError : APIError)
146
+ end
147
+
148
+ def default_message(status)
149
+ case status
150
+ when 400 then "Bad request"
151
+ when 401 then "Authentication failed"
152
+ when 403 then "Forbidden"
153
+ when 404 then "Not found"
154
+ when 429 then "Rate limit exceeded"
155
+ when 500..599 then "Server error (#{status})"
156
+ else "Request failed (#{status})"
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onetime
4
+ module Resources
5
+ # Receipt operations (creator-facing secret metadata): show, recent,
6
+ # burn, update.
7
+ #
8
+ # "Receipt" is the modern vocabulary; the v1 API also accepts the legacy
9
+ # /private and /metadata aliases, but this client uses the canonical
10
+ # /receipt paths that both versions support.
11
+ class Receipts
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ # Show a receipt by its key.
17
+ def show(key, guest: false)
18
+ identifier = key.to_s
19
+ case version
20
+ when :v1
21
+ @client.request(:get, "/receipt/#{identifier}")
22
+ when :v2
23
+ path = guest ? "/guest/receipt/#{identifier}" : "/receipt/#{identifier}"
24
+ @client.request(:get, path)
25
+ end
26
+ end
27
+
28
+ # List the authenticated customer's recent receipts.
29
+ def recent
30
+ @client.request(:get, "/receipt/recent")
31
+ end
32
+
33
+ # Burn (destroy) an unread secret by its receipt key.
34
+ def burn(key, passphrase: nil, continue: true, guest: false)
35
+ identifier = key.to_s
36
+ case version
37
+ when :v1
38
+ form = compact(passphrase: passphrase, continue: continue)
39
+ @client.request(:post, "/receipt/#{identifier}/burn", form: form)
40
+ when :v2
41
+ path = guest ? "/guest/receipt/#{identifier}/burn" : "/receipt/#{identifier}/burn"
42
+ @client.request(:post, path, body: compact(passphrase: passphrase, continue: continue))
43
+ end
44
+ end
45
+
46
+ # Update a receipt's memo. (v2 only)
47
+ def update(key, memo:)
48
+ require_version!(:v2, "receipts.update")
49
+ @client.request(:patch, "/receipt/#{key}", body: { memo: memo })
50
+ end
51
+
52
+ private
53
+
54
+ def version
55
+ @client.api_version
56
+ end
57
+
58
+ def compact(**attrs)
59
+ attrs.reject { |_, v| v.nil? }
60
+ end
61
+
62
+ def require_version!(expected, operation)
63
+ return if version == expected
64
+
65
+ raise UnsupportedOperationError,
66
+ "#{operation} is only available on API #{expected}; client is configured for #{version}"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onetime
4
+ module Resources
5
+ # Secret operations: conceal, generate, reveal, show, status.
6
+ #
7
+ # The request/response contracts differ between API versions:
8
+ # - v1 uses flat form-encoded params and legacy endpoint names
9
+ # (/share, /generate, POST /secret/:key for reveal).
10
+ # - v2 nests create params under a `secret` object, uses JSON bodies,
11
+ # and exposes richer REST endpoints (/secret/conceal, /secret/:id, ...).
12
+ #
13
+ # The version-specific branches are kept side by side in each method so
14
+ # the differences are easy to audit.
15
+ class Secrets
16
+ def initialize(client)
17
+ @client = client
18
+ end
19
+
20
+ # Conceal (share) a secret value you already have.
21
+ #
22
+ # @param secret [String] the secret content
23
+ # @param ttl [Integer, nil] time-to-live in seconds
24
+ # @param passphrase [String, nil] passphrase required to reveal
25
+ # @param recipient [String, Array<String>, nil] email recipient(s)
26
+ # @param share_domain [String, nil] custom share domain
27
+ # @param guest [Boolean] use the anonymous /guest/* endpoint (v2)
28
+ def conceal(secret:, ttl: nil, passphrase: nil, recipient: nil, share_domain: nil, guest: false)
29
+ case version
30
+ when :v1
31
+ form = compact(secret: secret, ttl: ttl, passphrase: passphrase,
32
+ recipient: recipient, share_domain: share_domain)
33
+ @client.request(:post, "/share", form: form)
34
+ when :v2
35
+ path = guest ? "/guest/secret/conceal" : "/secret/conceal"
36
+ @client.request(:post, path, body: { secret: secret_payload(
37
+ secret: secret, ttl: ttl, passphrase: passphrase,
38
+ recipient: recipient, share_domain: share_domain
39
+ ) })
40
+ end
41
+ end
42
+ alias share conceal
43
+
44
+ # Generate a random secret value server-side.
45
+ def generate(ttl: nil, passphrase: nil, recipient: nil, share_domain: nil, guest: false)
46
+ case version
47
+ when :v1
48
+ form = compact(ttl: ttl, passphrase: passphrase,
49
+ recipient: recipient, share_domain: share_domain)
50
+ @client.request(:post, "/generate", form: form)
51
+ when :v2
52
+ path = guest ? "/guest/secret/generate" : "/secret/generate"
53
+ @client.request(:post, path, body: { secret: secret_payload(
54
+ ttl: ttl, passphrase: passphrase,
55
+ recipient: recipient, share_domain: share_domain
56
+ ) })
57
+ end
58
+ end
59
+
60
+ # Reveal (consume) a secret by its key. This is a one-time action.
61
+ #
62
+ # @param key [String] the secret identifier
63
+ # @param passphrase [String, nil] passphrase if one was set
64
+ # @param continue [Boolean] confirm the destructive reveal
65
+ def reveal(key, passphrase: nil, continue: true, guest: false)
66
+ identifier = extract_secret_key(key)
67
+ case version
68
+ when :v1
69
+ form = compact(passphrase: passphrase, continue: continue)
70
+ @client.request(:post, "/secret/#{identifier}", form: form)
71
+ when :v2
72
+ path = guest ? "/guest/secret/#{identifier}/reveal" : "/secret/#{identifier}/reveal"
73
+ @client.request(:post, path, body: compact(passphrase: passphrase, continue: continue))
74
+ end
75
+ end
76
+
77
+ # Show a secret's metadata without revealing its value. (v2 only)
78
+ def show(key, guest: false)
79
+ require_version!(:v2, "secrets.show")
80
+ identifier = extract_secret_key(key)
81
+ path = guest ? "/guest/secret/#{identifier}" : "/secret/#{identifier}"
82
+ @client.request(:get, path)
83
+ end
84
+
85
+ # Check a single secret's status. (v2 only)
86
+ def status(key)
87
+ require_version!(:v2, "secrets.status")
88
+ @client.request(:get, "/secret/#{extract_secret_key(key)}/status")
89
+ end
90
+
91
+ # Check the status of multiple secrets in one call. (v2 only)
92
+ #
93
+ # @param keys [Array<String>, String] identifiers (array or CSV string)
94
+ def status_list(keys)
95
+ require_version!(:v2, "secrets.status_list")
96
+ identifiers = keys.is_a?(Array) ? keys.join(",") : keys.to_s
97
+ @client.request(:post, "/secret/status", body: { identifiers: identifiers })
98
+ end
99
+
100
+ private
101
+
102
+ def version
103
+ @client.api_version
104
+ end
105
+
106
+ # v2 create endpoints expect params nested under a `secret` object.
107
+ def secret_payload(**attrs)
108
+ compact(**attrs)
109
+ end
110
+
111
+ def compact(**attrs)
112
+ attrs.reject { |_, v| v.nil? }
113
+ end
114
+
115
+ # Accept either a bare key or a full secret URL (e.g. the link a user
116
+ # was given). Mirrors the convenience of the historical CLI.
117
+ def extract_secret_key(key)
118
+ str = key.to_s
119
+ if (match = str.match(%r{/secret/([a-zA-Z0-9]+)}))
120
+ match[1]
121
+ else
122
+ str
123
+ end
124
+ end
125
+
126
+ def require_version!(expected, operation)
127
+ return if version == expected
128
+
129
+ raise UnsupportedOperationError,
130
+ "#{operation} is only available on API #{expected}; client is configured for #{version}"
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onetime
4
+ # A thin, indifferent-access wrapper around a parsed API response.
5
+ #
6
+ # The parsed JSON body is exposed via #[], #dig, #fetch and #to_h, all of
7
+ # which accept either String or Symbol keys. The raw body string and HTTP
8
+ # status are also available.
9
+ #
10
+ # res = client.secrets.conceal(secret: "hi")
11
+ # res[:record][:receipt]["identifier"]
12
+ # res.dig("record", "secret", "secret_value")
13
+ # res.success?
14
+ class Response
15
+ attr_reader :http_status, :headers, :raw_body, :data
16
+
17
+ def initialize(http_status:, headers:, raw_body:, data:)
18
+ @http_status = http_status
19
+ @headers = headers || {}
20
+ @raw_body = raw_body
21
+ @data = data
22
+ end
23
+
24
+ # Indifferent key access. Returns nil for non-Hash bodies.
25
+ def [](key)
26
+ return nil unless data.is_a?(Hash)
27
+
28
+ data[normalize(key)]
29
+ end
30
+
31
+ # Indifferent, deep access mirroring Hash#dig.
32
+ def dig(*keys)
33
+ keys.reduce(data) do |memo, key|
34
+ case memo
35
+ when Hash then memo[normalize(key)]
36
+ when Array then memo[key]
37
+ end
38
+ end
39
+ end
40
+
41
+ def fetch(key, *default, &block)
42
+ raise TypeError, "response body is not a Hash" unless data.is_a?(Hash)
43
+
44
+ data.fetch(normalize(key), *default, &block)
45
+ end
46
+
47
+ def key?(key)
48
+ data.is_a?(Hash) && data.key?(normalize(key))
49
+ end
50
+
51
+ # 2xx status.
52
+ def success?
53
+ (200..299).cover?(http_status.to_i)
54
+ end
55
+
56
+ # Alias used by the legacy Onetime::API compatibility shim.
57
+ def code
58
+ http_status
59
+ end
60
+
61
+ def to_h
62
+ data
63
+ end
64
+
65
+ private
66
+
67
+ # Bodies are parsed with string keys; coerce symbol lookups to strings.
68
+ def normalize(key)
69
+ key.is_a?(Symbol) ? key.to_s : key
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ require_relative "version"
8
+ require_relative "response"
9
+ require_relative "errors"
10
+
11
+ module Onetime
12
+ # Zero-dependency HTTP transport built on stdlib Net::HTTP.
13
+ #
14
+ # Responsibilities:
15
+ # - build the request URL, headers and body (form for v1, JSON for v2)
16
+ # - apply HTTP Basic auth when credentials are configured
17
+ # - retry idempotent requests with exponential backoff
18
+ # - parse the JSON response and map error statuses to exceptions
19
+ #
20
+ # It intentionally has no knowledge of API versions or resources; callers
21
+ # pass fully-qualified paths (e.g. "/api/v2/secret/conceal").
22
+ class Transport
23
+ IDEMPOTENT_METHODS = %i[get head].freeze
24
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
25
+ RETRYABLE_ERRORS = [
26
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
27
+ Errno::ETIMEDOUT, EOFError, SocketError, IOError,
28
+ Net::OpenTimeout, Net::ReadTimeout
29
+ ].freeze
30
+
31
+ METHOD_CLASSES = {
32
+ get: Net::HTTP::Get,
33
+ post: Net::HTTP::Post,
34
+ patch: Net::HTTP::Patch,
35
+ put: Net::HTTP::Put,
36
+ delete: Net::HTTP::Delete,
37
+ head: Net::HTTP::Head,
38
+ }.freeze
39
+
40
+ def initialize(config)
41
+ @config = config
42
+ end
43
+
44
+ # Execute an HTTP request.
45
+ #
46
+ # @param method [Symbol] :get, :post, :patch, ...
47
+ # @param path [String] fully-qualified path including the /api/vN prefix
48
+ # @param query [Hash, nil] query-string parameters
49
+ # @param body [Hash, nil] request body serialized as JSON
50
+ # @param form [Hash, nil] request body serialized as form-urlencoded
51
+ # @param headers [Hash] extra request headers
52
+ # @param raise_on_error [Boolean] raise APIError for status >= 400
53
+ # @return [Onetime::Response]
54
+ def request(method, path, query: nil, body: nil, form: nil, headers: {}, raise_on_error: true)
55
+ uri = build_uri(path, query)
56
+ attempt = 0
57
+
58
+ begin
59
+ response = perform(method, uri, body: body, form: form, headers: headers)
60
+
61
+ if response.http_status >= 400
62
+ if retryable_status?(response.http_status) && retry_allowed?(method, attempt)
63
+ attempt += 1
64
+ backoff(attempt)
65
+ raise Retry
66
+ end
67
+ raise Errors.from_response(response) if raise_on_error
68
+ end
69
+
70
+ response
71
+ rescue Retry
72
+ retry
73
+ rescue *RETRYABLE_ERRORS => e
74
+ if retry_allowed?(method, attempt)
75
+ attempt += 1
76
+ backoff(attempt)
77
+ retry
78
+ end
79
+ raise wrap_transport_error(e)
80
+ end
81
+ end
82
+
83
+ # Encodes a Hash as application/x-www-form-urlencoded, expanding Array
84
+ # values into repeated `key[]=` pairs (Rack's array convention, which
85
+ # the v1 API relies on for `recipient`).
86
+ def self.encode_form(hash)
87
+ pairs = []
88
+ hash.each do |key, value|
89
+ next if value.nil?
90
+
91
+ if value.is_a?(Array)
92
+ value.each { |v| pairs << ["#{key}[]", v.to_s] }
93
+ else
94
+ pairs << [key.to_s, value.to_s]
95
+ end
96
+ end
97
+ URI.encode_www_form(pairs)
98
+ end
99
+
100
+ private
101
+
102
+ # Sentinel used to trigger a retry from within the begin/rescue.
103
+ Retry = Class.new(StandardError)
104
+ private_constant :Retry
105
+
106
+ def perform(method, uri, body:, form:, headers:)
107
+ request = build_request(method, uri, body: body, form: form, headers: headers)
108
+ log(:debug) { "#{method.to_s.upcase} #{uri}" }
109
+
110
+ http = Net::HTTP.new(uri.host, uri.port)
111
+ http.use_ssl = uri.scheme == "https"
112
+ http.open_timeout = @config.open_timeout
113
+ http.read_timeout = @config.timeout
114
+
115
+ raw = http.request(request)
116
+ build_response(raw)
117
+ end
118
+
119
+ def build_request(method, uri, body:, form:, headers:)
120
+ klass = METHOD_CLASSES.fetch(method) do
121
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
122
+ end
123
+ request = klass.new(uri)
124
+
125
+ default_headers.merge(headers).each { |k, v| request[k] = v }
126
+
127
+ unless @config.anonymous?
128
+ # HTTP Basic: the customer extid occupies the username slot,
129
+ # the API token occupies the password slot.
130
+ request.basic_auth(@config.customer, @config.api_token)
131
+ end
132
+
133
+ if form
134
+ request["Content-Type"] = "application/x-www-form-urlencoded"
135
+ request.body = self.class.encode_form(form)
136
+ elsif body
137
+ request["Content-Type"] = "application/json"
138
+ request.body = JSON.generate(body)
139
+ end
140
+
141
+ request
142
+ end
143
+
144
+ def build_response(raw)
145
+ status = raw.code.to_i
146
+ raw_body = raw.body.to_s
147
+ data = parse_body(raw, raw_body)
148
+
149
+ Response.new(
150
+ http_status: status,
151
+ headers: raw.to_hash,
152
+ raw_body: raw_body,
153
+ data: data,
154
+ )
155
+ end
156
+
157
+ def parse_body(raw, raw_body)
158
+ return nil if raw_body.empty?
159
+
160
+ content_type = raw["content-type"].to_s
161
+ return raw_body unless content_type.include?("json")
162
+
163
+ JSON.parse(raw_body)
164
+ rescue JSON::ParserError
165
+ # A non-JSON body on an otherwise-JSON endpoint: surface it raw rather
166
+ # than blowing up, so callers can still inspect it.
167
+ raw_body
168
+ end
169
+
170
+ def build_uri(path, query)
171
+ uri = URI.join(ensure_trailing_slash(@config.base_url), path.sub(%r{\A/}, ""))
172
+ if query && !query.empty?
173
+ uri.query = self.class.encode_form(query)
174
+ end
175
+ uri
176
+ end
177
+
178
+ # URI.join treats the base as a directory only when it ends in "/".
179
+ def ensure_trailing_slash(url)
180
+ url.end_with?("/") ? url : "#{url}/"
181
+ end
182
+
183
+ def default_headers
184
+ {
185
+ "Accept" => "application/json",
186
+ "User-Agent" => @config.user_agent || default_user_agent,
187
+ "X-Onetime-Client" => "ruby:#{RUBY_VERSION}/#{Onetime::VERSION}",
188
+ }.merge(@config.default_headers)
189
+ end
190
+
191
+ # Identifies the SDK, not the gem: "onetime-ruby" tells the service which
192
+ # of the per-language clients is calling. The gem itself is `onetime`.
193
+ def default_user_agent
194
+ "onetime-ruby/#{Onetime::VERSION} (Ruby/#{RUBY_VERSION})"
195
+ end
196
+
197
+ def retry_allowed?(method, attempt)
198
+ IDEMPOTENT_METHODS.include?(method) && attempt < @config.max_retries
199
+ end
200
+
201
+ def retryable_status?(status)
202
+ RETRYABLE_STATUSES.include?(status)
203
+ end
204
+
205
+ # Exponential backoff: 0.5s, 1s, 2s, ...
206
+ def backoff(attempt)
207
+ sleep(0.5 * (2**(attempt - 1)))
208
+ end
209
+
210
+ def wrap_transport_error(error)
211
+ if error.is_a?(Net::OpenTimeout) || error.is_a?(Net::ReadTimeout)
212
+ TimeoutError.new("Request timed out: #{error.message}")
213
+ else
214
+ TransportError.new("Transport failure: #{error.message}")
215
+ end
216
+ end
217
+
218
+ def log(level)
219
+ return unless @config.logger
220
+
221
+ @config.logger.public_send(level, "[onetime] #{yield}")
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onetime
4
+ # Library version. Source of truth for the gemspec, the release workflow's
5
+ # tag check, and the X-Onetime-Client / User-Agent request headers.
6
+ #
7
+ # Note for anyone comparing against RubyGems: 0.5.1 (2013) and earlier are
8
+ # the command-line tool that shipped under this gem name. 0.6.0 is the
9
+ # cleaned-up client library.
10
+ VERSION = "0.6.0"
11
+ end