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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -8
  3. data/lib/basecamp/client.rb +35 -11
  4. data/lib/basecamp/config.rb +69 -0
  5. data/lib/basecamp/error.rb +1 -0
  6. data/lib/basecamp/error_code.rb +1 -0
  7. data/lib/basecamp/exit_code.rb +1 -0
  8. data/lib/basecamp/generated/metadata.json +291 -141
  9. data/lib/basecamp/generated/services/base_service.rb +37 -16
  10. data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
  11. data/lib/basecamp/generated/services/boosts_service.rb +12 -6
  12. data/lib/basecamp/generated/services/campfires_service.rb +54 -41
  13. data/lib/basecamp/generated/services/cards_service.rb +6 -3
  14. data/lib/basecamp/generated/services/checkins_service.rb +28 -15
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
  17. data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
  18. data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
  19. data/lib/basecamp/generated/services/comments_service.rb +6 -3
  20. data/lib/basecamp/generated/services/documents_service.rb +9 -6
  21. data/lib/basecamp/generated/services/drafts_service.rb +5 -4
  22. data/lib/basecamp/generated/services/events_service.rb +6 -3
  23. data/lib/basecamp/generated/services/everything_service.rb +70 -56
  24. data/lib/basecamp/generated/services/folders_service.rb +62 -0
  25. data/lib/basecamp/generated/services/forwards_service.rb +12 -17
  26. data/lib/basecamp/generated/services/gauges_service.rb +12 -7
  27. data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
  28. data/lib/basecamp/generated/services/message_types_service.rb +4 -3
  29. data/lib/basecamp/generated/services/messages_service.rb +6 -4
  30. data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
  31. data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
  32. data/lib/basecamp/generated/services/people_service.rb +17 -10
  33. data/lib/basecamp/generated/services/projects_service.rb +26 -4
  34. data/lib/basecamp/generated/services/recordings_service.rb +6 -13
  35. data/lib/basecamp/generated/services/reports_service.rb +15 -9
  36. data/lib/basecamp/generated/services/schedules_service.rb +90 -16
  37. data/lib/basecamp/generated/services/search_service.rb +6 -4
  38. data/lib/basecamp/generated/services/templates_service.rb +6 -4
  39. data/lib/basecamp/generated/services/timeline_service.rb +6 -3
  40. data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
  41. data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
  42. data/lib/basecamp/generated/services/todolists_service.rb +11 -9
  43. data/lib/basecamp/generated/services/todos_service.rb +6 -14
  44. data/lib/basecamp/generated/services/uploads_service.rb +28 -6
  45. data/lib/basecamp/generated/services/vaults_service.rb +6 -3
  46. data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
  47. data/lib/basecamp/generated/types.rb +603 -139
  48. data/lib/basecamp/http.rb +352 -163
  49. data/lib/basecamp/limit_exceeded_error.rb +22 -0
  50. data/lib/basecamp/list_enumerator.rb +29 -0
  51. data/lib/basecamp/list_meta.rb +44 -0
  52. data/lib/basecamp/services/authorization_service.rb +11 -2
  53. data/lib/basecamp/services/cards_extensions.rb +35 -27
  54. data/lib/basecamp/services/documents_extensions.rb +136 -0
  55. data/lib/basecamp/services/merge_safe.rb +255 -0
  56. data/lib/basecamp/services/schedules_extensions.rb +354 -0
  57. data/lib/basecamp/services/todolists_extensions.rb +274 -0
  58. data/lib/basecamp/services/todos_extensions.rb +22 -6
  59. data/lib/basecamp/validation_error.rb +11 -1
  60. data/lib/basecamp/version.rb +2 -2
  61. data/lib/basecamp.rb +98 -4
  62. data/scripts/generate-metadata.rb +3 -1
  63. data/scripts/generate-services.rb +78 -27
  64. data/scripts/generate-types.rb +4 -2
  65. data/scripts/go_type_spellings.rb +26 -0
  66. 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
- # Performs a GET request without retry logic.
165
- # Used for the download flow where retry is not appropriate.
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 get_no_retry(url)
169
- single_request(:get, url, params: {}, body: nil, attempt: 1)
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 [Enumerator] if no block given
177
- def paginate(path, params: {}, operation: nil, &block)
178
- return to_enum(:paginate, path, params: params, operation: operation) unless block
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 [Enumerator] if no block given
223
- def paginate_key(path, key:, params: {}, operation: nil, &block)
224
- return to_enum(:paginate_key, path, key: key, params: params, operation: operation) unless block
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
- # @return [Hash] wrapper fields merged with key => Enumerator of all items
272
- def paginate_wrapped(path, key:, params: {}, operation: nil)
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
- Security.check_body_size!(first_response.body, Security::MAX_RESPONSE_BODY_BYTES)
278
-
279
- begin
280
- first_data = JSON.parse(first_response.body)
281
- Http.normalize_person_ids(first_data)
282
- rescue JSON::ParserError => e
283
- raise Basecamp::ApiError.new(
284
- "Failed to parse paginated response (page 1): #{Security.truncate(e.message)}"
285
- )
286
- end
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
- wrapper = first_data.reject { |k, _| k == key }
289
- first_items = first_data[key] || []
274
+ yielded = 0
275
+ page = 1
276
+ url = base_url
290
277
 
291
- events = Enumerator.new do |yielder|
292
- first_items.each { |item| yielder << item }
278
+ loop do
279
+ next_link = parse_next_link(response.headers["Link"])
293
280
 
294
- next_link = parse_next_link(first_response.headers["Link"])
295
- url = base_url
296
- page = 1
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
- @hooks.on_paginate(next_url, page)
310
- response = get(next_url, operation: operation)
311
- Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
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
- items = data[key] || []
324
- items.each { |item| yielder << item }
325
-
326
- next_link = parse_next_link(response.headers["Link"])
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
- wrapper.merge(key => events)
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
- private
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 : @config.max_retries
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, allow_cross_origin: allow_cross_origin)
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
- raise e unless retry_eligible?(e, op_retry)
429
+ error = e
430
+ end
372
431
 
373
- last_error = e
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, e.retry_after)
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, e,
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 (operation given), a status-bearing error retries
391
- # exactly when the operation's declared retryOn set says so the error
478
+ # For a governed request — an operation's declared retry block, or an
479
+ # explicit declared set such as the download flow'sa 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
- if op_retry && error.http_status
397
- op_retry.fetch("retryOn").include?(error.http_status)
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
- # After a successful token refresh on 401, retry the request once
445
- if error.is_a?(Basecamp::AuthError) && error.http_status == 401 && retry_count < 1 && @token_refreshed
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, allow_cross_origin: allow_cross_origin)
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
- def request_headers
461
- headers = {
462
- "User-Agent" => USER_AGENT,
463
- "Accept" => "application/json"
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
- def handle_error(error)
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
- message = Security.truncate(Basecamp.parse_error_message(body) || "Validation failed")
531
- Basecamp::ValidationError.new(message, http_status: status)
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 * (2**(attempt - 1))
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
- def parse_next_link(link_header)
610
- return nil if link_header.nil? || link_header.empty?
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
- link_header.split(",").each do |part|
613
- part = part.strip
614
- next unless part.include?('rel="next"')
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
- match = part.match(/<([^>]+)>/)
617
- return match[1] if match
806
+ next_url = next_url&.force_encoding(link_header.encoding)
618
807
  end
619
808
 
620
- nil
809
+ next_url
621
810
  end
622
811
  end
623
812