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.
- checksums.yaml +4 -4
- data/README.md +127 -8
- data/lib/basecamp/client.rb +35 -11
- data/lib/basecamp/config.rb +69 -0
- data/lib/basecamp/generated/metadata.json +280 -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 +10 -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 +527 -139
- data/lib/basecamp/http.rb +346 -163
- data/lib/basecamp/list_enumerator.rb +29 -0
- data/lib/basecamp/list_meta.rb +44 -0
- 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 +94 -4
- data/scripts/generate-services.rb +74 -25
- data/scripts/generate-types.rb +2 -1
- data/scripts/go_type_spellings.rb +26 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d41385ab7c36a096b4b54f56a0b60af4c6ab42e561e4bf147bdd850b77c8e230
|
|
4
|
+
data.tar.gz: e93de2347867e1f40afcee749d1e49a7873fffd4f656200bf5305e0fef813b29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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**:
|
|
358
|
-
- **`max_retries`**: Total request attempts for GET requests, including the initial request — the default `3` means one initial attempt plus two retries.
|
|
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**:
|
|
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
|
data/lib/basecamp/client.rb
CHANGED
|
@@ -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 [
|
|
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 [
|
|
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
|
-
# @
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
271
|
-
|
|
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) }
|
data/lib/basecamp/config.rb
CHANGED
|
@@ -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
|