super_pdp 0.1.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80df88af44b2d803aa180b0d205329440167089782755f4ac1635b94a5066d54
4
- data.tar.gz: 5083f9d514fb7163910a0d8ed9b3129762c1f561e11ceb50ec3f0f214148f56f
3
+ metadata.gz: 1c19819ce17d392aee66a03b36d190a6b33cc032c2e76a52fdf42586801b9d5e
4
+ data.tar.gz: 232d936312f901357e196a037f116606eff4e270ff65dcc56fad88fa847d7c6f
5
5
  SHA512:
6
- metadata.gz: 252df23df32876233d65684b4afb80b2756e788a7b51b0d6dc3fc6cc3b504dd83a15064ab69ff49a09432fd97b2dcccf7d5fb32180d04fbf21f3b19577cb5baf
7
- data.tar.gz: 2ce4577ad5003b603dba86a1bdd1946bc45735b3631c686e82e883ff236e547152478b5992bc18375241fc4a697248a63d4631caa6651c0e54dd11e81e27d895
6
+ metadata.gz: e9282838649f3d7191b8d6eb0f39dd01de19173eaa68ffcea93d80b88754ac29189612f6fdb72711e49a4b9695a742417c190aa2d4c40c85cace33596fc6cb59
7
+ data.tar.gz: 05e4597cf5aae2c9ca7246ea09d08aa4d4d4abe06b28e31d7859285c0e662992849a58badb7afc7a233eae7a04b61620f28c93922b269333507184c2dc9740ce
data/README.md CHANGED
@@ -61,7 +61,23 @@ pdp.post("/some/new/route", { key: "value" })
61
61
  ```
62
62
 
63
63
  Responses are parsed JSON (`Hash`/`Array`). Non-2xx raises `SuperPDP::APIError`
64
- (`#status`, `#body`).
64
+ (`#status`, `#body`), or a status-specific subclass you can rescue directly:
65
+ `UnauthorizedError` (401/403), `NotFoundError` (404), `RateLimitError` (429, with
66
+ `#retry_after` in seconds). All subclass `APIError`, so `rescue SuperPDP::APIError`
67
+ still catches everything.
68
+
69
+ ## Retries
70
+
71
+ Transient failures on **idempotent** requests (`GET`/`DELETE`) are retried
72
+ automatically: HTTP 429 and 502/503/504, plus connection errors. The server's
73
+ `Retry-After` header is honored; otherwise it backs off exponentially. `POST`
74
+ and `PATCH` are never retried (a half-applied write is worse than an error).
75
+
76
+ ```ruby
77
+ SuperPDP.new(client_id: "...", client_secret: "...",
78
+ max_retries: 2, # total attempts = max_retries + 1 (default 2)
79
+ retry_base: 0.5) # backoff seconds: retry_base * 2**(attempt-1)
80
+ ```
65
81
 
66
82
  ## Test
67
83
 
@@ -14,17 +14,27 @@ module SuperPDP
14
14
  class Client
15
15
  DEFAULT_BASE_URL = "https://api.superpdp.tech"
16
16
  API_PREFIX = "/v1.beta"
17
+ USER_AGENT = "super_pdp/#{VERSION} (Ruby #{RUBY_VERSION})".freeze
18
+
19
+ # Transient failures retried automatically (idempotent verbs only).
20
+ RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
21
+ RETRYABLE_METHODS = %i[get delete].freeze
22
+ RETRYABLE_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
23
+ Errno::ECONNREFUSED, IOError].freeze
17
24
 
18
25
  attr_reader :base_url
19
26
 
20
27
  def initialize(access_token: nil, client_id: nil, client_secret: nil,
21
- base_url: DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 60)
28
+ base_url: DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 60,
29
+ max_retries: 2, retry_base: 0.5)
22
30
  @access_token = access_token
23
31
  @client_id = client_id
24
32
  @client_secret = client_secret
25
33
  @base_url = base_url
26
34
  @open_timeout = open_timeout
27
35
  @read_timeout = read_timeout
36
+ @max_retries = max_retries
37
+ @retry_base = retry_base
28
38
  @token_expires_at = nil
29
39
  @token_mutex = Mutex.new
30
40
 
@@ -95,16 +105,44 @@ module SuperPDP
95
105
  def patch(path, body = {}, query = {}) = request(:patch, path, body: body, query: query)
96
106
  def delete(path, query = {}) = request(:delete, path, query: query)
97
107
 
108
+ # Retries transient failures (429 + 502/503/504 + connection errors) with
109
+ # backoff for idempotent verbs only. See RETRYABLE_* constants.
98
110
  def request(method, path, query: {}, body: nil, raw: false)
99
111
  uri = build_uri(path, query)
112
+ attempt = 0
113
+ loop do
114
+ attempt += 1
115
+ res =
116
+ begin
117
+ perform(method, uri, body)
118
+ rescue *RETRYABLE_ERRORS
119
+ raise unless retryable?(method, attempt)
120
+
121
+ sleep backoff(attempt)
122
+ next
123
+ end
124
+ return handle_response(res, raw: raw) unless retry_status?(method, res.code.to_i, attempt)
125
+
126
+ sleep(retry_after(res) || backoff(attempt))
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def perform(method, uri, body)
100
133
  req = build_request(method, uri, body)
101
134
  req["Authorization"] = "Bearer #{token}"
102
-
103
- res = http(uri).request(req)
104
- handle_response(res, raw: raw)
135
+ http(uri).request(req)
105
136
  end
106
137
 
107
- private
138
+ def retryable?(method, attempt) = RETRYABLE_METHODS.include?(method) && attempt <= @max_retries
139
+ def retry_status?(method, status, attempt) = retryable?(method, attempt) && RETRYABLE_STATUSES.include?(status)
140
+ def backoff(attempt) = @retry_base * (2**(attempt - 1))
141
+
142
+ def retry_after(res)
143
+ v = res["Retry-After"]
144
+ v =~ /\A\d+\z/ ? Integer(v) : nil # honor integer seconds; ignore HTTP-date form
145
+ end
108
146
 
109
147
  def build_uri(path, query)
110
148
  full = path.start_with?("/v1") || path.start_with?("http") ? path : "#{API_PREFIX}#{path}"
@@ -128,6 +166,7 @@ module SuperPDP
128
166
  }.fetch(method)
129
167
  req = klass.new(uri)
130
168
  req["Accept"] = "application/json"
169
+ req["User-Agent"] = USER_AGENT
131
170
  if body
132
171
  req["Content-Type"] = "application/json"
133
172
  req.body = body.is_a?(String) ? body : JSON.generate(body)
@@ -148,7 +187,16 @@ module SuperPDP
148
187
  body = raw ? res.body : parse_json(res.body)
149
188
  return body if status.between?(200, 299)
150
189
 
151
- raise APIError.new(status, body)
190
+ raise error_for(status, body, res)
191
+ end
192
+
193
+ def error_for(status, body, res)
194
+ case status
195
+ when 401, 403 then UnauthorizedError.new(status, body)
196
+ when 404 then NotFoundError.new(status, body)
197
+ when 429 then RateLimitError.new(status, body, retry_after(res))
198
+ else APIError.new(status, body)
199
+ end
152
200
  end
153
201
 
154
202
  def parse_json(str)
@@ -183,6 +231,7 @@ module SuperPDP
183
231
  uri = URI.join(@base_url, "/oauth2/token")
184
232
  req = Net::HTTP::Post.new(uri)
185
233
  req["Accept"] = "application/json"
234
+ req["User-Agent"] = USER_AGENT
186
235
  req.set_form_data(
187
236
  grant_type: "client_credentials",
188
237
  client_id: @client_id,
@@ -15,5 +15,23 @@ module SuperPDP
15
15
  end
16
16
  end
17
17
 
18
+ # Status-specific subclasses so callers can rescue by kind. All subclass
19
+ # APIError, so `rescue SuperPDP::APIError` still catches every non-2xx.
20
+ # 401, 403
21
+ class UnauthorizedError < APIError; end
22
+ # 404
23
+ class NotFoundError < APIError; end
24
+
25
+ # 429
26
+ class RateLimitError < APIError
27
+ attr_reader :retry_after # integer seconds from Retry-After, or nil
28
+
29
+ def initialize(status, body, retry_after = nil)
30
+ @retry_after = retry_after
31
+ super(status, body)
32
+ end
33
+ end
34
+
35
+ # Token/credential-refresh failures (not an HTTP APIError).
18
36
  class AuthError < Error; end
19
37
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SuperPDP
4
- VERSION = "0.1.0"
4
+ VERSION = "1.0.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: super_pdp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Demoncy