basecamp-sdk 0.12.0 → 0.13.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +127 -8
  3. data/lib/basecamp/client.rb +35 -11
  4. data/lib/basecamp/config.rb +69 -0
  5. data/lib/basecamp/generated/metadata.json +280 -141
  6. data/lib/basecamp/generated/services/base_service.rb +37 -16
  7. data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
  8. data/lib/basecamp/generated/services/boosts_service.rb +12 -6
  9. data/lib/basecamp/generated/services/campfires_service.rb +54 -41
  10. data/lib/basecamp/generated/services/cards_service.rb +6 -3
  11. data/lib/basecamp/generated/services/checkins_service.rb +28 -15
  12. data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
  13. data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
  14. data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
  15. data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
  16. data/lib/basecamp/generated/services/comments_service.rb +6 -3
  17. data/lib/basecamp/generated/services/documents_service.rb +9 -6
  18. data/lib/basecamp/generated/services/drafts_service.rb +5 -4
  19. data/lib/basecamp/generated/services/events_service.rb +6 -3
  20. data/lib/basecamp/generated/services/everything_service.rb +70 -56
  21. data/lib/basecamp/generated/services/folders_service.rb +62 -0
  22. data/lib/basecamp/generated/services/forwards_service.rb +12 -17
  23. data/lib/basecamp/generated/services/gauges_service.rb +12 -7
  24. data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
  25. data/lib/basecamp/generated/services/message_types_service.rb +4 -3
  26. data/lib/basecamp/generated/services/messages_service.rb +6 -4
  27. data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
  28. data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
  29. data/lib/basecamp/generated/services/people_service.rb +17 -10
  30. data/lib/basecamp/generated/services/projects_service.rb +26 -4
  31. data/lib/basecamp/generated/services/recordings_service.rb +6 -13
  32. data/lib/basecamp/generated/services/reports_service.rb +15 -9
  33. data/lib/basecamp/generated/services/schedules_service.rb +90 -16
  34. data/lib/basecamp/generated/services/search_service.rb +6 -4
  35. data/lib/basecamp/generated/services/templates_service.rb +6 -4
  36. data/lib/basecamp/generated/services/timeline_service.rb +6 -3
  37. data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
  38. data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
  39. data/lib/basecamp/generated/services/todolists_service.rb +11 -9
  40. data/lib/basecamp/generated/services/todos_service.rb +6 -14
  41. data/lib/basecamp/generated/services/uploads_service.rb +10 -6
  42. data/lib/basecamp/generated/services/vaults_service.rb +6 -3
  43. data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
  44. data/lib/basecamp/generated/types.rb +527 -139
  45. data/lib/basecamp/http.rb +346 -163
  46. data/lib/basecamp/list_enumerator.rb +29 -0
  47. data/lib/basecamp/list_meta.rb +44 -0
  48. data/lib/basecamp/services/cards_extensions.rb +35 -27
  49. data/lib/basecamp/services/documents_extensions.rb +136 -0
  50. data/lib/basecamp/services/merge_safe.rb +255 -0
  51. data/lib/basecamp/services/schedules_extensions.rb +354 -0
  52. data/lib/basecamp/services/todolists_extensions.rb +274 -0
  53. data/lib/basecamp/services/todos_extensions.rb +22 -6
  54. data/lib/basecamp/validation_error.rb +11 -1
  55. data/lib/basecamp/version.rb +2 -2
  56. data/lib/basecamp.rb +94 -4
  57. data/scripts/generate-services.rb +74 -25
  58. data/scripts/generate-types.rb +2 -1
  59. data/scripts/go_type_spellings.rb +26 -0
  60. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99e64617bcf9bcf031f03ea1ae0311f37f46c5da4ac4fba9caeca9a768e44a22
4
- data.tar.gz: 83dd4b5a6d15ffc7909121dc742e2ccbaf9d1159d2b743a8482bb5a1cd17241f
3
+ metadata.gz: d41385ab7c36a096b4b54f56a0b60af4c6ab42e561e4bf147bdd850b77c8e230
4
+ data.tar.gz: e93de2347867e1f40afcee749d1e49a7873fffd4f656200bf5305e0fef813b29
5
5
  SHA512:
6
- metadata.gz: 1e5ce39f93f07dad1c1b79b2da9ac39db5ac70e8871eba73af86a92eb986d56f5b4002af55c40eb16a8f4bed14d2407ead7c1be3b5c817664dcf5033397db7eb
7
- data.tar.gz: 5263d6241a9de95303c8a546efe7f412db59def57207a0e0f8c22c45ab06d0b40b4d2c05e7b4d7ab05e02d7cb451246a5ccb5c4992afb52127541b464dbeeb3e
6
+ metadata.gz: 8f33657f9328f7a786aa33925a96ab89786f655dd0f83db71f80b9fb9472c06bd2400a15b5637e964a00fc146a1dbae0f86b49157939f1df983f52838ec42380
7
+ data.tar.gz: 2db82e6a78234d1f12f6e54a139d490df8f0f5205135a23b8946435f75f7120e863659d3c3fdeb6ae3e57a7cb94b55a32a7ba4914aa1ad0c7ebfba08f701d59e
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Official Ruby SDK for the [Basecamp API](https://github.com/basecamp/bc3-api).
4
4
 
5
+ **Upgrading to v0.13.0?** Read [MIGRATING.md](../MIGRATING.md#ruby) before you bump the version — Ruby carries eleven breaks nothing catches at load time — ten with no signal at all, and one that raises only on a record where the field is populated.
6
+
5
7
  ## Requirements
6
8
 
7
9
  - Ruby 3.2+
@@ -21,6 +23,49 @@ Or install directly:
21
23
  gem install basecamp-sdk
22
24
  ```
23
25
 
26
+ ## Getting a token
27
+
28
+ Every Basecamp API request carries an OAuth 2.0 access token. There is no API key and no personal access token, so even a throwaway script starts here:
29
+
30
+ 1. Choose the grant that matches how your code runs:
31
+
32
+ | Your integration | Grant | Who refreshes the token |
33
+ |---|---|---|
34
+ | already holds a token you obtained elsewhere | **static token** — [`Token Providers`](#token-providers) | you do |
35
+ | can receive a browser redirect (web app, or a local callback server) | **authorization code + PKCE** — [`OAuth Flow Helpers`](#oauth-flow-helpers) | `OauthTokenProvider` |
36
+ | has no browser, but a person can approve on another device (CLI, headless server, TV) | **device flow** — [`Device Authorization Grant`](#device-authorization-grant-rfc-8628) | `Basecamp::Oauth.refresh_token`, echoing the token's `resource` — **not** `OauthTokenProvider` |
37
+
38
+ The one-line rule: **a redirect URI you control → authorization code; no browser but someone to approve → device flow; a token already in hand → static token.** An unattended daemon or CI job fits none of the three on its own — the device flow needs a person to enter the user code at the verification URI — so provision a token out of band and hand it to the process as a static or refresh token.
39
+
40
+ 2. Get the client credentials that grant needs:
41
+
42
+ - **Authorization code + PKCE** — register your own integration at **<https://launchpad.37signals.com/integrations>**. You get a client ID, a client secret, and whatever redirect URI you nominated.
43
+ - **Device flow** — nothing to register. It runs as the pre-registered public `basecamp-cli` client, which sends no secret, against the device endpoint that discovery returns. Launchpad advertises no device endpoint, so a client you register there is not the one this flow uses.
44
+ - **Static token** — nothing to register; you already hold the token.
45
+
46
+ `StaticTokenProvider` hands back the string you gave it and nothing more — it never refreshes, so once the token expires every call fails with `401` until you supply a new one. Use it to get a first successful call, then move to a refreshing path before you ship — `OauthTokenProvider` for an authorization-code token from Launchpad, or, for a device-flow token, `Basecamp::Oauth.refresh_token` echoing the stored `resource`. Do not hand a device-flow token to `OauthTokenProvider`: it refreshes only against Launchpad and sends no `resource`, so it fails at the first expiry.
47
+
48
+ ## Finding your account ID
49
+
50
+ Every API path is scoped to an account — `https://3.basecampapi.com/{accountId}/…` — so `for_account` needs that number before your first call. One token can reach several accounts, so ask the token which. `authorization` hangs off the *top-level* client because it takes no account context. Unlike the other SDKs that ship this service (Go, Python, TypeScript all hardcode Launchpad), Ruby does not: `Http#get_authorization_document` runs resource-first discovery (SPEC.md §16) against your configured base URL and fetches `/authorization.json` from the *selected* issuer, reaching Launchpad only on a soft fallback. Point egress rules and HTTP stubs at the issuer discovery selects, not at Launchpad; a hard selection failure raises `Basecamp::Oauth::DiscoverySelectionError` before any credentialed request goes out.
51
+
52
+ ```ruby
53
+ client = Basecamp.client(access_token: ENV["BASECAMP_TOKEN"])
54
+
55
+ info = client.authorization.get
56
+ # "bc3" is Basecamp; the same response also carries "hey" and other products,
57
+ # and they are not ordered — filter before you pick, or you may scope the
58
+ # client to a HEY account.
59
+ basecamp_accounts = info["accounts"].select { |a| a["product"] == "bc3" }
60
+ basecamp_accounts.each do |a|
61
+ puts "#{a["id"]}: #{a["name"]}"
62
+ end
63
+
64
+ account = client.for_account(basecamp_accounts.first["id"])
65
+ ```
66
+
67
+ The response is parsed JSON with **string** keys, not symbols. `info["expires_at"]` tells you how long the token has left, which is the quickest way to confirm a static token has not lapsed.
68
+
24
69
  ## Quick Start
25
70
 
26
71
  ```ruby
@@ -316,7 +361,10 @@ The SDK provides 46 account-scoped services. The table below covers the common o
316
361
 
317
362
  ## Pagination
318
363
 
319
- All list methods return lazy Enumerators that automatically handle pagination:
364
+ All list methods return a lazy `ListEnumerator` an `Enumerator` subclass
365
+ that automatically handles pagination and carries metadata. The first page is
366
+ fetched when the method is called; later pages are fetched only as iteration
367
+ demands them:
320
368
 
321
369
  ```ruby
322
370
  # Automatically fetches all pages
@@ -324,13 +372,56 @@ account.projects.list.each do |project|
324
372
  puts project["name"]
325
373
  end
326
374
 
327
- # Take only what you need
375
+ # Take only what you need — no extra pages are fetched
328
376
  first_10 = account.todos.list(todolist_id: 456).take(10)
329
377
 
330
378
  # Convert to array (fetches all pages)
331
379
  all_projects = account.projects.list.to_a
332
380
  ```
333
381
 
382
+ Pagination is automatic: the SDK follows Link headers up to `config.max_pages`
383
+ (default: 10,000). The enumerator's `meta` exposes pagination metadata:
384
+
385
+ ```ruby
386
+ projects = account.projects.list
387
+ projects.meta.total_count # X-Total-Count from the first page (0 if absent),
388
+ # available immediately — page 1 is fetched eagerly
389
+ projects.to_a
390
+ projects.meta.truncated # true if items beyond those yielded were available
391
+ ```
392
+
393
+ Every list method also accepts a `max_items` keyword to cap how many items are
394
+ yielded. Enumeration stops as soon as the cap is met, without fetching further
395
+ pages. Zero or negative values disable the cap, as in the other SDKs:
396
+
397
+ ```ruby
398
+ recent = account.projects.list(max_items: 50)
399
+ recent.to_a
400
+ recent.meta.truncated # true only if more items were available
401
+ ```
402
+
403
+ `meta.truncated` is final once enumeration completes, and is `true` only when
404
+ items beyond those yielded were available — items were dropped by `max_items`,
405
+ or the last-fetched page still advertised a next page when enumeration stopped
406
+ (at `max_items` or the `max_pages` safety cap). Landing exactly on the final
407
+ item is not truncation: when `truncated` is `false` after full enumeration,
408
+ the result is definitely complete.
409
+
410
+ ### The `page` keyword
411
+
412
+ A positive `page` selects exactly that page: one request, that page's items,
413
+ no link-following.
414
+
415
+ ```ruby
416
+ page_3 = account.projects.list(page: 3).to_a
417
+ ```
418
+
419
+ Omit `page` (or pass `0`) to auto-paginate the whole collection. `max_items`
420
+ still trims a pinned page.
421
+
422
+ All six SDKs share these semantics — one request, that page only, no
423
+ link-following. See SPEC section 8.
424
+
334
425
  ## Downloading Files
335
426
 
336
427
  Fetch an upload's file content in one call. The SDK fetches the upload
@@ -352,14 +443,14 @@ result = account.download_url(url)
352
443
 
353
444
  ## Retry Behavior
354
445
 
355
- Only plain GET requests retry — automatically, with exponential backoff. Mutation operations (POST, PUT, DELETE) do **not** retry to prevent data duplication, and the raw upload and download paths skip the retry loop entirely (the upload path is strictly one request; the download hop keeps only the one-shot 401 replay below).
446
+ Only plain GET requests retry — automatically, with exponential backoff. Mutation operations (POST, PUT, DELETE) do **not** retry to prevent data duplication, and the raw upload path skips the retry loop entirely it is strictly one request. The download flow's authenticated first hop does retry, but under its own declared status set (`429`, `502`, `503`, `504` — never `500`); its second hop, the fetch from the signed URL, is a single unauthenticated request.
356
447
 
357
- - **Which errors**: Retry keys off the error's `retryable?` classification, not a declared status list 429 (rate limit), 500, 502, 503, 504, and any other 5xx all retry, as does `NetworkError` (connection failures, including DNS and connect-phase timeouts). Read timeouts are the exception: Faraday surfaces them as a status-less `ApiError` with `retryable? == false`, so a GET that times out mid-response fails on the first attempt. 400, 401, 403, 404, and 422 never retry.
358
- - **`max_retries`**: Total request attempts for GET requests, including the initial request — the default `3` means one initial attempt plus two retries. **`max_retries: 0` sends zero requests** and raises `Basecamp::ApiError` (`"Request failed after 0 attempts"`).
448
+ - **Which errors**: A GET issued through a generated service carries its operation ID and is **governed** — status retries are gated on the statuses that operation declares, which is `[429, 503]` for every operation in the current metadata. A governed GET does **not** retry 500. Only the handful of GETs that carry no operation ID (`get_absolute`, OAuth discovery) fall back to the error taxonomy, where 429, 500, 502, 503, 504, and any other 5xx all retry. `NetworkError` (connection failures, including DNS and connect-phase timeouts) retries on both paths, since it has no status to gate on. Read timeouts are the exception: Faraday surfaces them as a status-less `ApiError` with `retryable? == false`, so a GET that times out mid-response fails on the first attempt. 400, 401, 403, 404, and 422 never retry.
449
+ - **`max_retries`**: Total request attempts for GET requests, including the initial request — the default `3` means one initial attempt plus two retries. `max_retries: 0` is floored to a single attempt rather than sending zero requests.
359
450
  - **Backoff**: Exponential with jitter — `base_delay * 2^(attempt - 1) + rand * max_jitter` — uncapped, bounded in practice by the attempt budget.
360
451
  - **Rate limits**: A 429's `Retry-After` header overrides the calculated backoff. Only 429 carries it: 5xx and network errors always use the exponential backoff.
361
452
  - **401 responses**: With a refresh-capable token provider, the SDK refreshes the token and replays the request **once** — for all methods, including mutations — outside the `max_retries` budget. A second 401 is surfaced. The raw upload path has no 401 replay.
362
- - **Per-operation metadata**: The retry policy operations declare (`retry_on` statuses, per-operation `max`) is inert in Ruby every API GET issued through the client, including the Launchpad authorization fetch, rides the same classification-based loop bounded by `config.max_retries` alone. (The download flow's redirect hop and OAuth discovery use their own single-attempt transports.)
453
+ - **Per-operation metadata**: Every GET a generated service issues passes its canonical operation ID, so essentially all SDK reads are **governed**: attempts are bounded by `min(config.max_retries, operation max)` and status retries are gated on the operation's declared `retry_on`. The GETs that carry no operation ID — `get_absolute` and the Launchpad authorization fetch it backs — are **ungoverned** and ride the classification-based loop bounded by `config.max_retries` alone. The declared `base_delay_ms` and `backoff` are inert in Ruby either way: the backoff is always the client's. (OAuth discovery uses its own single-attempt transport.)
363
454
  - **`retryable?`**: Unlike SDKs where the error classification is only a hint for your own code, in Ruby an error's `retryable?` (and `retry_after`) is exactly what the transport acts on for GET requests.
364
455
 
365
456
  ## Error Handling
@@ -390,6 +481,27 @@ end
390
481
  | `RateLimitError` | Rate limit exceeded (429) |
391
482
  | `NetworkError` | Connection failures |
392
483
 
484
+ ### Validation Errors
485
+
486
+ Basecamp rejects invalid writes with a body keyed by field. The SDK folds those
487
+ messages into the message and keeps the raw map in `field_errors`, so you can
488
+ drive a form without re-parsing the message:
489
+
490
+ ```ruby
491
+ begin
492
+ account.calendars.update_calendar(calendar_id: calendar_id, calendar: { "color" => "chartreuse" })
493
+ rescue Basecamp::ValidationError => e
494
+ puts e.message # => "color: is not a valid color"
495
+
496
+ e.field_errors&.each do |field, messages|
497
+ messages.each { |message| puts " #{field} #{message}" }
498
+ end
499
+ end
500
+ ```
501
+
502
+ `field_errors` is `nil` for every other error shape, and its messages are the
503
+ raw ones — the message is capped at 500 bytes, the map is not.
504
+
393
505
  ## Observability Hooks
394
506
 
395
507
  Monitor SDK behavior with hooks:
@@ -424,14 +536,21 @@ client = Basecamp::Client.new(
424
536
 
425
537
  ## Environment Variables
426
538
 
539
+ `Basecamp::Config.from_env` (and `#load_from_env` on an existing config) reads these three. They are the only `BASECAMP_*` variables the SDK reads anywhere; the sole other environment read is `XDG_CONFIG_HOME`, for `Config.global_config_dir`.
540
+
427
541
  | Variable | Description |
428
542
  |----------|-------------|
429
- | `BASECAMP_TOKEN` | OAuth access token |
430
- | `BASECAMP_ACCOUNT_ID` | Account ID |
431
543
  | `BASECAMP_BASE_URL` | API base URL (default: `https://3.basecampapi.com`) |
432
544
  | `BASECAMP_TIMEOUT` | Request timeout in seconds (default: `30`) |
433
545
  | `BASECAMP_MAX_RETRIES` | Total request attempts for GET requests, including the initial request (default: `3`) |
434
546
 
547
+ ```ruby
548
+ config = Basecamp::Config.from_env
549
+ client = Basecamp::Client.new(config: config, token_provider: token_provider)
550
+ ```
551
+
552
+ Credentials are **not** among them. `BASECAMP_TOKEN` and `BASECAMP_ACCOUNT_ID` appear in the examples above only because the caller reads them and passes the values in; the SDK never looks them up. Pass the token to `Basecamp.client(access_token:)` or `StaticTokenProvider`, and the account ID to `#for_account`.
553
+
435
554
  ## Development
436
555
 
437
556
  ```bash
@@ -202,10 +202,12 @@ module Basecamp
202
202
  # Fetches all pages of a paginated resource.
203
203
  # @param path [String] URL path (without account prefix)
204
204
  # @param params [Hash] query parameters
205
+ # @param max_items [Integer, nil] cap on items yielded across pages;
206
+ # nil or non-positive means no cap
205
207
  # @yield [Hash] each item from the response
206
- # @return [Enumerator] if no block given
207
- def paginate(path, params: {}, operation: nil, &)
208
- @parent.http.paginate(account_path(path), params: params, operation: operation, &)
208
+ # @return [ListEnumerator] metadata-carrying lazy enumerator
209
+ def paginate(path, params: {}, operation: nil, max_items: nil, &)
210
+ @parent.http.paginate(account_path(path), params: params, operation: operation, max_items: max_items, &)
209
211
  end
210
212
 
211
213
  # Fetches all pages of a paginated resource, extracting items from a key.
@@ -213,19 +215,25 @@ module Basecamp
213
215
  # @param path [String] URL path (without account prefix)
214
216
  # @param key [String] the key containing the array of items
215
217
  # @param params [Hash] query parameters
218
+ # @param max_items [Integer, nil] cap on items yielded across pages;
219
+ # nil or non-positive means no cap
216
220
  # @yield [Hash] each item from the response
217
- # @return [Enumerator] if no block given
218
- def paginate_key(path, key:, params: {}, operation: nil, &)
219
- @parent.http.paginate_key(account_path(path), key: key, params: params, operation: operation, &)
221
+ # @return [ListEnumerator] metadata-carrying lazy enumerator
222
+ def paginate_key(path, key:, params: {}, operation: nil, max_items: nil, &)
223
+ @parent.http.paginate_key(account_path(path), key: key, params: params, operation: operation, \
224
+ max_items: max_items, &)
220
225
  end
221
226
 
222
227
  # Fetches a wrapped paginated resource, returning wrapper fields + lazy paginated items.
223
228
  # @param path [String] URL path (without account prefix)
224
229
  # @param key [String] the key containing the array of paginated items
225
230
  # @param params [Hash] query parameters
226
- # @return [Hash] wrapper fields merged with key => Enumerator of all items
227
- def paginate_wrapped(path, key:, params: {}, operation: nil)
228
- @parent.http.paginate_wrapped(account_path(path), key: key, params: params, operation: operation)
231
+ # @param max_items [Integer, nil] cap on items yielded across pages;
232
+ # nil or non-positive means no cap
233
+ # @return [Hash] wrapper fields merged with key => ListEnumerator of all items
234
+ def paginate_wrapped(path, key:, params: {}, operation: nil, max_items: nil)
235
+ @parent.http.paginate_wrapped(account_path(path), key: key, params: params, operation: operation, \
236
+ max_items: max_items)
229
237
  end
230
238
 
231
239
  # Downloads file content from any API-routable download URL.
@@ -267,8 +275,9 @@ module Basecamp
267
275
  rewritten.port = base.port
268
276
  rewritten_url = rewritten.to_s
269
277
 
270
- # Hop 1: Authenticated API request (no retry, captures redirect)
271
- response = http.get_no_retry(rewritten_url)
278
+ # Hop 1: Authenticated API request under the SPEC §14 hop-1 retry
279
+ # policy (captures redirect; every attempt is authenticated)
280
+ response = http.get_download(rewritten_url)
272
281
 
273
282
  result = case response.status
274
283
  when 301, 302, 303, 307, 308
@@ -391,6 +400,16 @@ module Basecamp
391
400
  service(:documents) { Services::DocumentsService.new(self) }
392
401
  end
393
402
 
403
+ # @return [Services::CloudFilesService]
404
+ def cloud_files
405
+ service(:cloud_files) { Services::CloudFilesService.new(self) }
406
+ end
407
+
408
+ # @return [Services::GoogleDocumentsService]
409
+ def google_documents
410
+ service(:google_documents) { Services::GoogleDocumentsService.new(self) }
411
+ end
412
+
394
413
  # @return [Services::UploadsService]
395
414
  def uploads
396
415
  service(:uploads) { Services::UploadsService.new(self) }
@@ -486,6 +505,11 @@ module Basecamp
486
505
  service(:bookmarks) { Services::BookmarksService.new(self) }
487
506
  end
488
507
 
508
+ # @return [Services::FoldersService]
509
+ def folders
510
+ service(:folders) { Services::FoldersService.new(self) }
511
+ end
512
+
489
513
  # @return [Services::DraftsService]
490
514
  def drafts
491
515
  service(:drafts) { Services::DraftsService.new(self) }
@@ -45,6 +45,75 @@ module Basecamp
45
45
  DEFAULT_MAX_JITTER = 0.1
46
46
  DEFAULT_MAX_PAGES = 10_000
47
47
 
48
+ # Ceiling on the backoff term (SPEC §7, "Backoff Ceiling"), in seconds.
49
+ # Jitter is added after the clamp, so the longest single backoff sleep is
50
+ # this plus +max_jitter+.
51
+ MAX_BACKOFF_DELAY = 30.0
52
+
53
+ # Smallest exponent +e+ with <tt>base_delay * 2**e >= MAX_BACKOFF_DELAY</tt>.
54
+ #
55
+ # Derived from the *configured* base rather than assumed. A fixed exponent
56
+ # cap plus a trailing +min(..., MAX_BACKOFF_DELAY)+ looks equivalent and is
57
+ # not: for a small enough base the capped product never reaches the ceiling,
58
+ # so the delay plateaus below it forever. At +base_delay = 1e-30+ a cap of
59
+ # 64 pins every attempt from 65 on at ~1.84e-11s — a tight retry loop, which
60
+ # is the failure SPEC §7's ceiling exists to prevent, not an instance of it.
61
+ #
62
+ # Computed in the LOG domain rather than as
63
+ # <tt>MAX_BACKOFF_DELAY / base_delay</tt>. That ratio coerces to
64
+ # +Float::INFINITY+ for any base below ~1.67e-307, and falling back to a
65
+ # fixed 1023 then saturates *early*: +base_delay = 1e-307+ reaches only
66
+ # ~8.99s at exponent 1023, so returning the 30s ceiling there overstates the
67
+ # specified term instead of tracking it. The log form has no such cliff, so
68
+ # the numeric backstop is gone entirely rather than merely made rarer.
69
+ #
70
+ # @param base_delay [Float] initial backoff delay in seconds, strictly positive
71
+ # @return [Integer] the exponent at which the term reaches the ceiling
72
+ def self.saturating_exponent(base_delay)
73
+ # log2 is correctly rounded but the subtraction is not, so the estimate can
74
+ # land one either side of the true boundary. Both corrections are bounded
75
+ # and evaluate the term with Math.ldexp, which scales directly and never
76
+ # forms 2**e.
77
+ exponent = [ (Math.log2(MAX_BACKOFF_DELAY) - Math.log2(base_delay)).ceil, 0 ].max
78
+ exponent -= 1 while exponent > 0 && Math.ldexp(base_delay, exponent - 1) >= MAX_BACKOFF_DELAY
79
+ exponent += 1 while Math.ldexp(base_delay, exponent) < MAX_BACKOFF_DELAY
80
+ exponent
81
+ end
82
+
83
+ # Exponential backoff for a 1-based attempt, saturating at MAX_BACKOFF_DELAY.
84
+ #
85
+ # The clamp is load-bearing rather than defensive. Ruby's +**+ promotes
86
+ # instead of overflowing, so +base_delay * (2**(attempt - 1))+ on a long
87
+ # failure streak coerces to +Float::INFINITY+ — and +sleep(Float::INFINITY)+
88
+ # never returns. A retry that never happens is not backoff.
89
+ #
90
+ # The exponent is compared against the point where the term reaches the
91
+ # ceiling *before* the power is evaluated, so no intermediate leaves the
92
+ # Float range and the term saturates AT the ceiling for every positive base
93
+ # — the same contract Go, Kotlin and Swift get from comparing their
94
+ # multiplier against <tt>MAX_BACKOFF_DELAY / base</tt> before multiplying.
95
+ #
96
+ # Below that point the term is scaled with +Math.ldexp+, which computes
97
+ # <tt>base * 2**e</tt> directly. +2**e+ would be an unbounded Integer that
98
+ # coerces to +Float::INFINITY+ long before the *product* leaves the Float
99
+ # range, which is what forced the fixed cap this replaces.
100
+ #
101
+ # @param base_delay [Float] initial backoff delay in seconds
102
+ # @param attempt [Integer] 1-based attempt number
103
+ # @return [Float] the backoff term in seconds
104
+ def self.saturating_backoff(base_delay, attempt)
105
+ if base_delay <= 0
106
+ 0.0
107
+ else
108
+ exponent = [ attempt - 1, 0 ].max
109
+ if exponent >= saturating_exponent(base_delay)
110
+ MAX_BACKOFF_DELAY
111
+ else
112
+ [ Math.ldexp(base_delay, exponent), MAX_BACKOFF_DELAY ].min.to_f
113
+ end
114
+ end
115
+ end
116
+
48
117
  # Creates a new configuration with the given options.
49
118
  #
50
119
  # @param base_url [String] API base URL