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 +4 -4
- data/README.md +17 -1
- data/lib/super_pdp/client.rb +55 -6
- data/lib/super_pdp/error.rb +18 -0
- data/lib/super_pdp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c19819ce17d392aee66a03b36d190a6b33cc032c2e76a52fdf42586801b9d5e
|
|
4
|
+
data.tar.gz: 232d936312f901357e196a037f116606eff4e270ff65dcc56fad88fa847d7c6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/super_pdp/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
data/lib/super_pdp/error.rb
CHANGED
|
@@ -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
|
data/lib/super_pdp/version.rb
CHANGED