basecamp-sdk 0.12.0 → 0.14.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 +128 -8
- data/lib/basecamp/client.rb +35 -11
- data/lib/basecamp/config.rb +69 -0
- data/lib/basecamp/error.rb +1 -0
- data/lib/basecamp/error_code.rb +1 -0
- data/lib/basecamp/exit_code.rb +1 -0
- data/lib/basecamp/generated/metadata.json +291 -141
- data/lib/basecamp/generated/services/base_service.rb +37 -16
- data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
- data/lib/basecamp/generated/services/boosts_service.rb +12 -6
- data/lib/basecamp/generated/services/campfires_service.rb +54 -41
- data/lib/basecamp/generated/services/cards_service.rb +6 -3
- data/lib/basecamp/generated/services/checkins_service.rb +28 -15
- data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
- data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
- data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
- data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
- data/lib/basecamp/generated/services/comments_service.rb +6 -3
- data/lib/basecamp/generated/services/documents_service.rb +9 -6
- data/lib/basecamp/generated/services/drafts_service.rb +5 -4
- data/lib/basecamp/generated/services/events_service.rb +6 -3
- data/lib/basecamp/generated/services/everything_service.rb +70 -56
- data/lib/basecamp/generated/services/folders_service.rb +62 -0
- data/lib/basecamp/generated/services/forwards_service.rb +12 -17
- data/lib/basecamp/generated/services/gauges_service.rb +12 -7
- data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
- data/lib/basecamp/generated/services/message_types_service.rb +4 -3
- data/lib/basecamp/generated/services/messages_service.rb +6 -4
- data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
- data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
- data/lib/basecamp/generated/services/people_service.rb +17 -10
- data/lib/basecamp/generated/services/projects_service.rb +26 -4
- data/lib/basecamp/generated/services/recordings_service.rb +6 -13
- data/lib/basecamp/generated/services/reports_service.rb +15 -9
- data/lib/basecamp/generated/services/schedules_service.rb +90 -16
- data/lib/basecamp/generated/services/search_service.rb +6 -4
- data/lib/basecamp/generated/services/templates_service.rb +6 -4
- data/lib/basecamp/generated/services/timeline_service.rb +6 -3
- data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
- data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
- data/lib/basecamp/generated/services/todolists_service.rb +11 -9
- data/lib/basecamp/generated/services/todos_service.rb +6 -14
- data/lib/basecamp/generated/services/uploads_service.rb +28 -6
- data/lib/basecamp/generated/services/vaults_service.rb +6 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
- data/lib/basecamp/generated/types.rb +603 -139
- data/lib/basecamp/http.rb +352 -163
- data/lib/basecamp/limit_exceeded_error.rb +22 -0
- data/lib/basecamp/list_enumerator.rb +29 -0
- data/lib/basecamp/list_meta.rb +44 -0
- data/lib/basecamp/services/authorization_service.rb +11 -2
- data/lib/basecamp/services/cards_extensions.rb +35 -27
- data/lib/basecamp/services/documents_extensions.rb +136 -0
- data/lib/basecamp/services/merge_safe.rb +255 -0
- data/lib/basecamp/services/schedules_extensions.rb +354 -0
- data/lib/basecamp/services/todolists_extensions.rb +274 -0
- data/lib/basecamp/services/todos_extensions.rb +22 -6
- data/lib/basecamp/validation_error.rb +11 -1
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +98 -4
- data/scripts/generate-metadata.rb +3 -1
- data/scripts/generate-services.rb +78 -27
- data/scripts/generate-types.rb +4 -2
- data/scripts/go_type_spellings.rb +26 -0
- metadata +13 -2
data/lib/basecamp/http.rb
CHANGED
|
@@ -161,106 +161,52 @@ module Basecamp
|
|
|
161
161
|
single_request_raw(:put, url, body: body, content_type: content_type, attempt: 1)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
#
|
|
165
|
-
#
|
|
164
|
+
# SPEC §14's declared hop-1 retry set for downloads: a carve-out from the
|
|
165
|
+
# ungoverned GET taxonomy (which retries all retryable 5xx, including 500).
|
|
166
|
+
# Authoritative in BOTH directions, like an operation's declared retryOn.
|
|
167
|
+
DOWNLOAD_RETRY_ON = [ 429, 502, 503, 504 ].freeze
|
|
168
|
+
|
|
169
|
+
# Performs the authenticated hop-1 GET for the download flow (SPEC §14).
|
|
170
|
+
#
|
|
171
|
+
# Retries network errors plus the declared {DOWNLOAD_RETRY_ON} statuses —
|
|
172
|
+
# never 500 — under the public max_retries total-attempt cap, which is
|
|
173
|
+
# floored at one attempt on every path, not just this one (+max_retries: 0+
|
|
174
|
+
# still sends one request). DownloadURL has no behavior-model entry, so the
|
|
175
|
+
# policy is passed directly rather than looked up by operation.
|
|
166
176
|
# @param url [String] absolute URL
|
|
167
177
|
# @return [Response]
|
|
168
|
-
def
|
|
169
|
-
|
|
178
|
+
def get_download(url)
|
|
179
|
+
request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil)
|
|
170
180
|
end
|
|
171
181
|
|
|
172
182
|
# Fetches all pages of a paginated resource.
|
|
183
|
+
# The first page is fetched eagerly, so pagination metadata (and any
|
|
184
|
+
# page-1 error) surfaces at call time; later pages are fetched lazily
|
|
185
|
+
# as enumeration demands them.
|
|
173
186
|
# @param path [String] initial URL path
|
|
174
187
|
# @param params [Hash] query parameters
|
|
188
|
+
# @param max_items [Integer, nil] cap on items yielded across pages;
|
|
189
|
+
# nil or non-positive means no cap
|
|
175
190
|
# @yield [Hash] each item from the response
|
|
176
|
-
# @return [
|
|
177
|
-
def paginate(path, params: {}, operation: nil, &block)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
base_url = build_url(path)
|
|
181
|
-
url = base_url
|
|
182
|
-
page = 0
|
|
183
|
-
|
|
184
|
-
loop do
|
|
185
|
-
page += 1
|
|
186
|
-
break if page > @config.max_pages
|
|
187
|
-
|
|
188
|
-
@hooks.on_paginate(url, page)
|
|
189
|
-
response = get(url, params: page == 1 ? params : {}, operation: operation)
|
|
190
|
-
|
|
191
|
-
Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
192
|
-
|
|
193
|
-
begin
|
|
194
|
-
items = JSON.parse(response.body)
|
|
195
|
-
Http.normalize_person_ids(items)
|
|
196
|
-
rescue JSON::ParserError => e
|
|
197
|
-
raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
|
|
198
|
-
end
|
|
199
|
-
items.each(&block)
|
|
200
|
-
|
|
201
|
-
next_url = parse_next_link(response.headers["Link"])
|
|
202
|
-
break if next_url.nil?
|
|
203
|
-
|
|
204
|
-
next_url = Security.resolve_url(url, next_url)
|
|
205
|
-
|
|
206
|
-
unless Security.same_origin?(next_url, base_url)
|
|
207
|
-
raise Basecamp::ApiError.new(
|
|
208
|
-
"Pagination Link header points to different origin: #{Security.truncate(next_url)}"
|
|
209
|
-
)
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
url = next_url
|
|
213
|
-
end
|
|
191
|
+
# @return [ListEnumerator] metadata-carrying lazy enumerator
|
|
192
|
+
def paginate(path, params: {}, operation: nil, max_items: nil, &block)
|
|
193
|
+
enum = paginated_enumerator(path, params: params, operation: operation, max_items: max_items)
|
|
194
|
+
block ? enum.each(&block) : enum
|
|
214
195
|
end
|
|
215
196
|
|
|
216
197
|
# Fetches all pages of a paginated resource, extracting items from a key.
|
|
217
198
|
# Use this for endpoints that return objects like { "events": [...] }.
|
|
199
|
+
# The first page is fetched eagerly; later pages are fetched lazily.
|
|
218
200
|
# @param path [String] initial URL path
|
|
219
201
|
# @param key [String] the key containing the array of items
|
|
220
202
|
# @param params [Hash] query parameters
|
|
203
|
+
# @param max_items [Integer, nil] cap on items yielded across pages;
|
|
204
|
+
# nil or non-positive means no cap
|
|
221
205
|
# @yield [Hash] each item from the response
|
|
222
|
-
# @return [
|
|
223
|
-
def paginate_key(path, key:, params: {}, operation: nil, &block)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
base_url = build_url(path)
|
|
227
|
-
url = base_url
|
|
228
|
-
page = 0
|
|
229
|
-
|
|
230
|
-
loop do
|
|
231
|
-
page += 1
|
|
232
|
-
break if page > @config.max_pages
|
|
233
|
-
|
|
234
|
-
@hooks.on_paginate(url, page)
|
|
235
|
-
response = get(url, params: page == 1 ? params : {}, operation: operation)
|
|
236
|
-
|
|
237
|
-
Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
238
|
-
|
|
239
|
-
begin
|
|
240
|
-
data = JSON.parse(response.body)
|
|
241
|
-
Http.normalize_person_ids(data)
|
|
242
|
-
rescue JSON::ParserError => e
|
|
243
|
-
raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
|
|
244
|
-
end
|
|
245
|
-
unless data.key?(key)
|
|
246
|
-
warn "[Basecamp SDK] paginate_key: expected key '#{key}' not found in response (page #{page})"
|
|
247
|
-
end
|
|
248
|
-
items = data[key] || []
|
|
249
|
-
items.each(&block)
|
|
250
|
-
|
|
251
|
-
next_url = parse_next_link(response.headers["Link"])
|
|
252
|
-
break if next_url.nil?
|
|
253
|
-
|
|
254
|
-
next_url = Security.resolve_url(url, next_url)
|
|
255
|
-
|
|
256
|
-
unless Security.same_origin?(next_url, base_url)
|
|
257
|
-
raise Basecamp::ApiError.new(
|
|
258
|
-
"Pagination Link header points to different origin: #{Security.truncate(next_url)}"
|
|
259
|
-
)
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
url = next_url
|
|
263
|
-
end
|
|
206
|
+
# @return [ListEnumerator] metadata-carrying lazy enumerator
|
|
207
|
+
def paginate_key(path, key:, params: {}, operation: nil, max_items: nil, &block)
|
|
208
|
+
enum = paginated_enumerator(path, key: key, params: params, operation: operation, max_items: max_items)
|
|
209
|
+
block ? enum.each(&block) : enum
|
|
264
210
|
end
|
|
265
211
|
|
|
266
212
|
# Fetches a wrapped paginated resource, returning wrapper fields + lazy paginated items.
|
|
@@ -268,70 +214,156 @@ module Basecamp
|
|
|
268
214
|
# @param path [String] initial URL path
|
|
269
215
|
# @param key [String] the key containing the array of paginated items
|
|
270
216
|
# @param params [Hash] query parameters
|
|
271
|
-
# @
|
|
272
|
-
|
|
217
|
+
# @param max_items [Integer, nil] cap on items yielded across pages;
|
|
218
|
+
# nil or non-positive means no cap
|
|
219
|
+
# @return [Hash] wrapper fields merged with key => ListEnumerator of all items
|
|
220
|
+
def paginate_wrapped(path, key:, params: {}, operation: nil, max_items: nil)
|
|
221
|
+
wrapper = nil
|
|
222
|
+
events = paginated_enumerator(path, key: key, params: params, operation: operation, \
|
|
223
|
+
max_items: max_items) do |first_data|
|
|
224
|
+
wrapper = first_data.reject { |k, _| k == key }
|
|
225
|
+
end
|
|
226
|
+
wrapper.merge(key => events)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# Shared paginator core behind paginate/paginate_key/paginate_wrapped.
|
|
232
|
+
# Eagerly fetches page 1 (so ListMeta#total_count is available
|
|
233
|
+
# immediately), then returns a ListEnumerator that yields the captured
|
|
234
|
+
# first page and follows Link headers lazily. When key is nil each page
|
|
235
|
+
# body is a bare item array; otherwise items live under key. Yields the
|
|
236
|
+
# parsed first-page body when a block is given (paginate_wrapped uses it
|
|
237
|
+
# to capture the wrapper fields).
|
|
238
|
+
#
|
|
239
|
+
# meta.truncated is finalized during enumeration: set when max_items
|
|
240
|
+
# drops items or leaves a next Link unfetched, or when the max_pages
|
|
241
|
+
# safety cap stops with a next Link still present. A cap landing exactly
|
|
242
|
+
# on the final item of the last page is not truncation.
|
|
243
|
+
#
|
|
244
|
+
# Re-enumerating restarts pagination from the base URL: the eagerly
|
|
245
|
+
# fetched first page is served from memory on the first pass only, and
|
|
246
|
+
# later passes refetch every page, so each pass is a consistent
|
|
247
|
+
# snapshot rather than a hybrid of captured and current data.
|
|
248
|
+
def paginated_enumerator(path, params:, operation:, max_items:, key: nil)
|
|
249
|
+
max_items = nil if max_items && max_items <= 0
|
|
250
|
+
single_page = single_page_selected?(params)
|
|
273
251
|
base_url = build_url(path)
|
|
274
252
|
|
|
275
253
|
@hooks.on_paginate(base_url, 1)
|
|
276
254
|
first_response = get(base_url, params: params, operation: operation)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
255
|
+
first_data = parse_page(first_response, page: 1)
|
|
256
|
+
yield first_data if block_given?
|
|
257
|
+
first_items = extract_page_items(first_data, key: key, page: 1)
|
|
258
|
+
|
|
259
|
+
meta = ListMeta.new(total_count: parse_total_count(first_response.headers))
|
|
260
|
+
first_pass = true
|
|
261
|
+
|
|
262
|
+
ListEnumerator.new(meta) do |yielder|
|
|
263
|
+
if first_pass
|
|
264
|
+
first_pass = false
|
|
265
|
+
response = first_response
|
|
266
|
+
items = first_items
|
|
267
|
+
else
|
|
268
|
+
@hooks.on_paginate(base_url, 1)
|
|
269
|
+
response = get(base_url, params: params, operation: operation)
|
|
270
|
+
items = extract_page_items(parse_page(response, page: 1), key: key, page: 1)
|
|
271
|
+
meta.restart!(total_count: parse_total_count(response.headers))
|
|
272
|
+
end
|
|
287
273
|
|
|
288
|
-
|
|
289
|
-
|
|
274
|
+
yielded = 0
|
|
275
|
+
page = 1
|
|
276
|
+
url = base_url
|
|
290
277
|
|
|
291
|
-
|
|
292
|
-
|
|
278
|
+
loop do
|
|
279
|
+
next_link = parse_next_link(response.headers["Link"])
|
|
293
280
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
281
|
+
# A pinned page is the whole answer (SPEC §8): this pass yields that
|
|
282
|
+
# page and stops, so a next link we deliberately do not follow is
|
|
283
|
+
# what makes the result truncated. Recorded before the yields for the
|
|
284
|
+
# same reason as the capping case below.
|
|
285
|
+
meta.mark_truncated! if single_page && next_link
|
|
286
|
+
|
|
287
|
+
capped = false
|
|
288
|
+
items.each_with_index do |item, index|
|
|
289
|
+
yielded += 1
|
|
290
|
+
capped = max_items && yielded >= max_items
|
|
291
|
+
# Truncation is recorded before the capping yield: consumers like
|
|
292
|
+
# first/take cancel the producer at that yield, and the metadata
|
|
293
|
+
# must already be accurate once the capped item is delivered.
|
|
294
|
+
meta.mark_truncated! if capped && (index < items.size - 1 || next_link)
|
|
295
|
+
yielder << item
|
|
296
|
+
break if capped
|
|
297
|
+
end
|
|
298
|
+
break if capped
|
|
299
|
+
break if single_page
|
|
300
|
+
break if next_link.nil?
|
|
297
301
|
|
|
298
|
-
while next_link && page < @config.max_pages
|
|
299
|
-
page += 1
|
|
300
302
|
next_url = Security.resolve_url(url, next_link)
|
|
301
|
-
|
|
302
303
|
unless Security.same_origin?(next_url, base_url)
|
|
303
304
|
raise Basecamp::ApiError.new(
|
|
304
|
-
"Pagination Link header points to different origin: "
|
|
305
|
-
"#{Security.truncate(next_url)}"
|
|
305
|
+
"Pagination Link header points to different origin: #{Security.truncate(next_url)}"
|
|
306
306
|
)
|
|
307
307
|
end
|
|
308
308
|
|
|
309
|
-
@
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
begin
|
|
314
|
-
data = JSON.parse(response.body)
|
|
315
|
-
Http.normalize_person_ids(data)
|
|
316
|
-
rescue JSON::ParserError => e
|
|
317
|
-
raise Basecamp::ApiError.new(
|
|
318
|
-
"Failed to parse paginated response (page #{page}): " \
|
|
319
|
-
"#{Security.truncate(e.message)}"
|
|
320
|
-
)
|
|
309
|
+
if page >= @config.max_pages
|
|
310
|
+
meta.mark_truncated!
|
|
311
|
+
break
|
|
321
312
|
end
|
|
322
313
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
314
|
+
page += 1
|
|
315
|
+
@hooks.on_paginate(next_url, page)
|
|
316
|
+
response = get(next_url, operation: operation)
|
|
317
|
+
items = extract_page_items(parse_page(response, page: page), key: key, page: page)
|
|
327
318
|
url = next_url
|
|
328
319
|
end
|
|
329
320
|
end
|
|
321
|
+
end
|
|
330
322
|
|
|
331
|
-
|
|
323
|
+
# Reports whether the outgoing query pins a single page (SPEC §8).
|
|
324
|
+
#
|
|
325
|
+
# The query parameters are the authority: `page` reaches the wire only when
|
|
326
|
+
# the caller passed it, so reading it back here needs no extra plumbing
|
|
327
|
+
# through every generated service method. `to_i` rather than an Integer
|
|
328
|
+
# check so a string-typed "3" selects page 3 instead of silently walking
|
|
329
|
+
# the collection from there.
|
|
330
|
+
# @param params [Hash, nil] the outgoing query parameters
|
|
331
|
+
# @return [Boolean]
|
|
332
|
+
def single_page_selected?(params)
|
|
333
|
+
page = params && (params[:page] || params["page"])
|
|
334
|
+
page.respond_to?(:to_i) && page.to_i.positive?
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Parses a pagination page body: size check, JSON parse, and person-ID
|
|
338
|
+
# normalization, with page-numbered error context.
|
|
339
|
+
def parse_page(response, page:)
|
|
340
|
+
Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
|
|
341
|
+
data = JSON.parse(response.body)
|
|
342
|
+
Http.normalize_person_ids(data)
|
|
343
|
+
data
|
|
344
|
+
rescue JSON::ParserError => e
|
|
345
|
+
raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Extracts the item array from a parsed page body: the body itself for
|
|
349
|
+
# bare-array pagination, or the named key's array otherwise.
|
|
350
|
+
def extract_page_items(data, key:, page:)
|
|
351
|
+
if key.nil?
|
|
352
|
+
data
|
|
353
|
+
else
|
|
354
|
+
unless data.key?(key)
|
|
355
|
+
warn "[Basecamp SDK] paginate: expected key '#{key}' not found in response (page #{page})"
|
|
356
|
+
end
|
|
357
|
+
data[key] || []
|
|
358
|
+
end
|
|
332
359
|
end
|
|
333
360
|
|
|
334
|
-
|
|
361
|
+
# Parses the X-Total-Count header, returning 0 when absent or malformed.
|
|
362
|
+
def parse_total_count(headers)
|
|
363
|
+
Integer(headers["X-Total-Count"] || headers["x-total-count"], 10)
|
|
364
|
+
rescue ArgumentError, TypeError
|
|
365
|
+
0
|
|
366
|
+
end
|
|
335
367
|
|
|
336
368
|
def build_faraday_client
|
|
337
369
|
Faraday.new(url: @config.base_url) do |f|
|
|
@@ -354,30 +386,86 @@ module Basecamp
|
|
|
354
386
|
end
|
|
355
387
|
end
|
|
356
388
|
|
|
357
|
-
def request_with_retry(method, url, params: {}, allow_cross_origin: false, operation: nil
|
|
389
|
+
def request_with_retry(method, url, params: {}, allow_cross_origin: false, operation: nil, retry_on: nil,
|
|
390
|
+
accept: "application/json")
|
|
358
391
|
op_retry = operation && Http.operation_retry(operation)
|
|
392
|
+
# The cap is floored at one attempt on every path: whether a request
|
|
393
|
+
# reaches the wire at all must not depend on whether the operation
|
|
394
|
+
# carries a declared retry block (#532). A declared operation ceiling
|
|
395
|
+
# still clamps the floored cap downward.
|
|
359
396
|
caller_cap = [ @config.max_retries, 1 ].max
|
|
360
|
-
max_attempts = op_retry ? [ caller_cap, op_retry.fetch("maxAttempts") ].min :
|
|
397
|
+
max_attempts = op_retry ? [ caller_cap, op_retry.fetch("maxAttempts") ].min : caller_cap
|
|
361
398
|
attempt = 0
|
|
399
|
+
refreshed_once = false
|
|
362
400
|
last_error = nil
|
|
363
401
|
|
|
364
402
|
loop do
|
|
365
403
|
attempt += 1
|
|
366
404
|
break if attempt > max_attempts
|
|
367
405
|
|
|
406
|
+
# Each rescue only CLASSIFIES the attempt; the retry side effects run
|
|
407
|
+
# below, outside every handler. An exception raised inside a rescue
|
|
408
|
+
# clause bypasses that begin's sibling rescues, so refreshing there
|
|
409
|
+
# would let a NetworkError from the token endpoint escape the loop
|
|
410
|
+
# with budget still on the table.
|
|
411
|
+
error = nil
|
|
412
|
+
stale_auth = nil
|
|
413
|
+
|
|
368
414
|
begin
|
|
369
|
-
return single_request(method, url, params: params, body: nil, attempt: attempt,
|
|
415
|
+
return single_request(method, url, params: params, body: nil, attempt: attempt,
|
|
416
|
+
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: false)
|
|
417
|
+
rescue Basecamp::AuthError => e
|
|
418
|
+
# SPEC §4: the refresh replay is a request on the wire, so it spends
|
|
419
|
+
# an attempt from THIS budget rather than an uncounted one inside
|
|
420
|
+
# single_request. max_retries is a total attempt count (#461), and a
|
|
421
|
+
# cap of one means one request whatever would have caused the second.
|
|
422
|
+
#
|
|
423
|
+
# The budget is checked BEFORE refresh so a rotation is never burned
|
|
424
|
+
# on an attempt the loop has no room to make.
|
|
425
|
+
raise e if refreshed_once || e.http_status != 401 || attempt >= max_attempts
|
|
426
|
+
|
|
427
|
+
stale_auth = e
|
|
370
428
|
rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::ApiError => e
|
|
371
|
-
|
|
429
|
+
error = e
|
|
430
|
+
end
|
|
372
431
|
|
|
373
|
-
|
|
432
|
+
if stale_auth
|
|
433
|
+
# SPEC §4 tracks refresh with an "attempted" boolean, not a
|
|
434
|
+
# "succeeded" one, so mark it BEFORE invoking the provider: a refresh
|
|
435
|
+
# that raises still counts as the one attempt this request gets.
|
|
436
|
+
# Otherwise a transient token-endpoint failure lets the NEXT 401 in
|
|
437
|
+
# the same request call refresh again — and if the first call reached
|
|
438
|
+
# the server and rotated the token before its response was lost, the
|
|
439
|
+
# second spends a refresh token that is already dead.
|
|
440
|
+
refreshed_once = true
|
|
441
|
+
|
|
442
|
+
begin
|
|
443
|
+
refreshed = @token_provider&.refreshable? && @token_provider.refresh
|
|
444
|
+
rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::ApiError => e
|
|
445
|
+
# A token endpoint that times out is a transient failure of this
|
|
446
|
+
# attempt, not a terminal auth fault: it retries under the same
|
|
447
|
+
# budget, exactly as it did when the replay lived in single_request.
|
|
448
|
+
error = e
|
|
449
|
+
else
|
|
450
|
+
raise stale_auth unless refreshed
|
|
451
|
+
|
|
452
|
+
# No backoff: the token is fresh, and the server never asked us to
|
|
453
|
+
# wait. The replay still costs the attempt counted above.
|
|
454
|
+
next
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
if error
|
|
459
|
+
raise error unless retry_eligible?(error, op_retry, retry_on)
|
|
460
|
+
|
|
461
|
+
last_error = error
|
|
374
462
|
|
|
375
463
|
# Don't sleep if this was the last attempt
|
|
376
464
|
break if attempt >= max_attempts
|
|
377
465
|
|
|
378
|
-
delay = calculate_delay(attempt,
|
|
466
|
+
delay = calculate_delay(attempt, error.retry_after)
|
|
379
467
|
|
|
380
|
-
@hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt), attempt + 1,
|
|
468
|
+
@hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt), attempt + 1, error,
|
|
381
469
|
delay)
|
|
382
470
|
sleep(delay)
|
|
383
471
|
end
|
|
@@ -387,14 +475,16 @@ module Basecamp
|
|
|
387
475
|
raise last_error || Basecamp::ApiError.new("Request failed after #{max_attempts} #{noun}")
|
|
388
476
|
end
|
|
389
477
|
|
|
390
|
-
# For a governed request
|
|
391
|
-
#
|
|
478
|
+
# For a governed request — an operation's declared retry block, or an
|
|
479
|
+
# explicit declared set such as the download flow's — a status-bearing
|
|
480
|
+
# error retries exactly when the declared retryOn set says so: the error
|
|
392
481
|
# taxonomy's retryable flag neither widens the set (500 is retryable in
|
|
393
482
|
# errors.rb but not declared) nor vetoes it. Status-less errors (network
|
|
394
483
|
# failures) and all ungoverned traffic keep the taxonomy's judgment.
|
|
395
|
-
def retry_eligible?(error, op_retry)
|
|
396
|
-
|
|
397
|
-
|
|
484
|
+
def retry_eligible?(error, op_retry, retry_on)
|
|
485
|
+
declared = retry_on || op_retry&.fetch("retryOn")
|
|
486
|
+
if declared && error.http_status
|
|
487
|
+
declared.include?(error.http_status)
|
|
398
488
|
else
|
|
399
489
|
error.retryable?
|
|
400
490
|
end
|
|
@@ -409,7 +499,8 @@ module Basecamp
|
|
|
409
499
|
@operation_metadata.dig(operation, "retry")
|
|
410
500
|
end
|
|
411
501
|
|
|
412
|
-
def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false
|
|
502
|
+
def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false,
|
|
503
|
+
accept: "application/json", refresh_replay: true)
|
|
413
504
|
assert_credential_origin!(url, allow_cross_origin)
|
|
414
505
|
info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
|
|
415
506
|
@hooks.on_request_start(info)
|
|
@@ -417,7 +508,7 @@ module Basecamp
|
|
|
417
508
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
418
509
|
|
|
419
510
|
begin
|
|
420
|
-
response = @faraday.run_request(method, url, body, request_headers) do |req|
|
|
511
|
+
response = @faraday.run_request(method, url, body, request_headers(accept: accept)) do |req|
|
|
421
512
|
req.params.merge!(params) if params.any?
|
|
422
513
|
end
|
|
423
514
|
|
|
@@ -432,7 +523,7 @@ module Basecamp
|
|
|
432
523
|
)
|
|
433
524
|
rescue Faraday::ServerError, Faraday::ClientError => e
|
|
434
525
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
435
|
-
error = handle_error(e)
|
|
526
|
+
error = handle_error(e, refresh_on_401: refresh_replay)
|
|
436
527
|
result = RequestResult.new(
|
|
437
528
|
status_code: e.response&.dig(:status),
|
|
438
529
|
duration: duration,
|
|
@@ -441,10 +532,15 @@ module Basecamp
|
|
|
441
532
|
)
|
|
442
533
|
@hooks.on_request_end(info, result)
|
|
443
534
|
|
|
444
|
-
#
|
|
445
|
-
|
|
535
|
+
# 401 replay for callers that come here directly (mutations), which
|
|
536
|
+
# have no retry loop to own it. request_with_retry passes
|
|
537
|
+
# refresh_replay: false and replays from the loop so the extra request
|
|
538
|
+
# draws from the attempt budget (SPEC §4).
|
|
539
|
+
if refresh_replay && error.is_a?(Basecamp::AuthError) && error.http_status == 401 && retry_count < 1 \
|
|
540
|
+
&& @token_refreshed
|
|
446
541
|
@token_refreshed = false
|
|
447
|
-
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1,
|
|
542
|
+
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1,
|
|
543
|
+
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: refresh_replay)
|
|
448
544
|
end
|
|
449
545
|
|
|
450
546
|
raise error
|
|
@@ -457,11 +553,12 @@ module Basecamp
|
|
|
457
553
|
end
|
|
458
554
|
end
|
|
459
555
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
556
|
+
# accept: nil is the binary-download carve-out (SPEC §14): hop 1 sends
|
|
557
|
+
# Authorization and User-Agent only, because it is not a JSON API call.
|
|
558
|
+
# Every other caller keeps the JSON Accept.
|
|
559
|
+
def request_headers(accept: "application/json")
|
|
560
|
+
headers = { "User-Agent" => USER_AGENT }
|
|
561
|
+
headers["Accept"] = accept if accept
|
|
465
562
|
@auth_strategy.authenticate(headers)
|
|
466
563
|
headers
|
|
467
564
|
end
|
|
@@ -506,7 +603,10 @@ module Basecamp
|
|
|
506
603
|
end
|
|
507
604
|
end
|
|
508
605
|
|
|
509
|
-
|
|
606
|
+
# refresh_on_401: false leaves the token alone — the caller (request_with_retry)
|
|
607
|
+
# owns the refresh so it can gate it on the attempt budget before spending a
|
|
608
|
+
# rotation. Classifying an error should not rotate credentials as a side effect.
|
|
609
|
+
def handle_error(error, refresh_on_401: true)
|
|
510
610
|
status = error.response&.dig(:status)
|
|
511
611
|
body = error.response&.dig(:body)
|
|
512
612
|
headers = error.response&.dig(:headers) || {}
|
|
@@ -517,7 +617,7 @@ module Basecamp
|
|
|
517
617
|
err = case status
|
|
518
618
|
when 401
|
|
519
619
|
# Try token refresh; flag for caller to retry
|
|
520
|
-
@token_refreshed = @token_provider&.refreshable? && @token_provider.refresh
|
|
620
|
+
@token_refreshed = refresh_on_401 && @token_provider&.refreshable? && @token_provider.refresh
|
|
521
621
|
Basecamp::AuthError.new("Authentication failed")
|
|
522
622
|
when 403
|
|
523
623
|
Basecamp::ForbiddenError.new("Access denied")
|
|
@@ -527,8 +627,17 @@ module Basecamp
|
|
|
527
627
|
when 429
|
|
528
628
|
Basecamp::RateLimitError.new(retry_after: retry_after)
|
|
529
629
|
when 400, 422
|
|
530
|
-
|
|
531
|
-
|
|
630
|
+
field_errors = Basecamp.parse_field_errors(body)
|
|
631
|
+
message = Security.truncate(
|
|
632
|
+
Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed"
|
|
633
|
+
)
|
|
634
|
+
Basecamp::ValidationError.new(message, http_status: status, field_errors: field_errors)
|
|
635
|
+
when 507
|
|
636
|
+
# A 5xx status carrying a client fact: the account is out of storage, or
|
|
637
|
+
# at its webhook ceiling. Retrying cannot satisfy it, so this is decided
|
|
638
|
+
# before the 5xx arms below.
|
|
639
|
+
message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached")
|
|
640
|
+
Basecamp::LimitExceededError.new(message)
|
|
532
641
|
when 500
|
|
533
642
|
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true)
|
|
534
643
|
when 502, 503, 504
|
|
@@ -579,10 +688,12 @@ module Basecamp
|
|
|
579
688
|
end
|
|
580
689
|
|
|
581
690
|
def calculate_delay(attempt, server_retry_after)
|
|
691
|
+
# Retry-After is server-directed and exempt from the ceiling (SPEC §7);
|
|
692
|
+
# only the locally-computed term saturates.
|
|
582
693
|
return server_retry_after if server_retry_after&.positive?
|
|
583
694
|
|
|
584
|
-
# Exponential backoff: base_delay * 2^(attempt-1) + jitter
|
|
585
|
-
base = @config.base_delay
|
|
695
|
+
# Exponential backoff: min(base_delay * 2^(attempt-1), ceiling) + jitter
|
|
696
|
+
base = Config.saturating_backoff(@config.base_delay, attempt)
|
|
586
697
|
jitter = rand * @config.max_jitter
|
|
587
698
|
base + jitter
|
|
588
699
|
end
|
|
@@ -606,18 +717,96 @@ module Basecamp
|
|
|
606
717
|
nil
|
|
607
718
|
end
|
|
608
719
|
|
|
609
|
-
|
|
610
|
-
|
|
720
|
+
# Returns the contents of the first non-empty <...> pair.
|
|
721
|
+
#
|
|
722
|
+
# This is the leftmost-match semantics of /<([^>]+)>/ without the regex.
|
|
723
|
+
# CodeQL flags that pattern as polynomial-redos (alert 48): every "<" is
|
|
724
|
+
# retried as a start position, and each scan runs to the end of the string.
|
|
725
|
+
# Onigmo does not actually realize the blowup — measured linear to 3.2M
|
|
726
|
+
# characters — so this is a consistency and correctness change here rather
|
|
727
|
+
# than a remediation. Searching for ">" from *after* the "<" is linear by
|
|
728
|
+
# construction, and it is what the other five SDKs now do.
|
|
729
|
+
#
|
|
730
|
+
# The scan runs over a binary view, and that is the whole point in Ruby.
|
|
731
|
+
# Neither of the obvious spellings is both fast and total:
|
|
732
|
+
#
|
|
733
|
+
# String#index(str, offset) takes a CHARACTER offset, and on a string
|
|
734
|
+
# whose coderange is not CR_7BIT Ruby walks from the start to convert it —
|
|
735
|
+
# O(cursor) per call. The skip loop below advances the cursor once per
|
|
736
|
+
# empty <>, so character indexing is quadratic on any header carrying a
|
|
737
|
+
# non-ASCII byte — seconds, against 5ms for the regex it replaces, on the
|
|
738
|
+
# input measured below.
|
|
739
|
+
#
|
|
740
|
+
# String#byteindex(str, offset) is O(1), but it RAISES IndexError when the
|
|
741
|
+
# offset does not land on a character boundary — and on malformed UTF-8 it
|
|
742
|
+
# does. In "\xC2<\x80>" the "\xC2" is a two-byte lead, so byte 2, the
|
|
743
|
+
# offset just past the "<", is mid-character by Ruby's reckoning and
|
|
744
|
+
# byteindex(">", 2) blows up. The header is attacker-influenced, so that
|
|
745
|
+
# is a reachable crash, not a curiosity.
|
|
746
|
+
#
|
|
747
|
+
# ASCII-8BIT has no multi-byte characters and therefore no boundaries to
|
|
748
|
+
# violate: every offset into part.b is O(1) AND legal, whatever the bytes
|
|
749
|
+
# say. force_encoding hands the caller's encoding back on the way out, so
|
|
750
|
+
# UTF-8 in gives UTF-8 out and binary in gives binary out. Measured on
|
|
751
|
+
# "é" + "<>" * n: 2.7ms / 8.2ms / 31.7ms at n = 10k / 40k / 160k, against
|
|
752
|
+
# 64.7ms / 988.1ms / 15,459.7ms for the character version — still linear.
|
|
753
|
+
# Parity with the character version is exact: 0 mismatches and 0 raises over
|
|
754
|
+
# 400,000 random byte strings drawn from <, >, a, /, \xC2, \x80, \xFF, \xC3,
|
|
755
|
+
# \xA9 and force-encoded to UTF-8. It has to be, because "<" and ">" are
|
|
756
|
+
# ASCII and UTF-8 is self-synchronising, so neither can match inside a
|
|
757
|
+
# multi-byte sequence.
|
|
758
|
+
#
|
|
759
|
+
# The other five SDKs index by byte (Go), UTF-16 code unit
|
|
760
|
+
# (TypeScript/Kotlin), flat code point (Python) or an O(1) native
|
|
761
|
+
# String.Index (Swift) — all already linear; only Ruby had to ask for it.
|
|
762
|
+
#
|
|
763
|
+
# An empty <> is skipped rather than returned, because [^>]+ requires at
|
|
764
|
+
# least one character: the regex would move on to the next "<", and so does
|
|
765
|
+
# this.
|
|
766
|
+
def extract_angle_bracketed(part)
|
|
767
|
+
bytes = part.b
|
|
768
|
+
result = nil
|
|
769
|
+
cursor = 0
|
|
770
|
+
|
|
771
|
+
while cursor && result.nil?
|
|
772
|
+
start = bytes.index("<", cursor)
|
|
773
|
+
finish = start && bytes.index(">", start + 1)
|
|
774
|
+
|
|
775
|
+
if finish.nil?
|
|
776
|
+
cursor = nil
|
|
777
|
+
elsif finish > start + 1
|
|
778
|
+
result = bytes[(start + 1)...finish]
|
|
779
|
+
else
|
|
780
|
+
cursor = start + 1
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
result && result.force_encoding(part.encoding)
|
|
785
|
+
end
|
|
611
786
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
787
|
+
def parse_next_link(link_header)
|
|
788
|
+
next_url = nil
|
|
789
|
+
|
|
790
|
+
unless link_header.nil? || link_header.empty?
|
|
791
|
+
# Split a binary view for the same reason extract_angle_bracketed scans
|
|
792
|
+
# one: String#split and String#strip raise ArgumentError on a broken
|
|
793
|
+
# coderange, so a header carrying malformed UTF-8 crashed here, one
|
|
794
|
+
# frame above the extractor — fixing only the extractor would leave the
|
|
795
|
+
# header just as fatal. ASCII-8BIT has no invalid sequences, so the
|
|
796
|
+
# split is total, and strip and include? behave identically against the
|
|
797
|
+
# ASCII-only literals used below. The extracted URL is retagged with the
|
|
798
|
+
# caller's encoding so the .b round trip is invisible to well-formed
|
|
799
|
+
# input.
|
|
800
|
+
link_header.b.split(",").each do |part|
|
|
801
|
+
part = part.strip
|
|
802
|
+
next_url = extract_angle_bracketed(part) if part.include?('rel="next"')
|
|
803
|
+
break unless next_url.nil?
|
|
804
|
+
end
|
|
615
805
|
|
|
616
|
-
|
|
617
|
-
return match[1] if match
|
|
806
|
+
next_url = next_url&.force_encoding(link_header.encoding)
|
|
618
807
|
end
|
|
619
808
|
|
|
620
|
-
|
|
809
|
+
next_url
|
|
621
810
|
end
|
|
622
811
|
end
|
|
623
812
|
|