basecamp-sdk 0.17.0 → 0.19.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 +1 -1
- data/lib/basecamp/api_error.rb +5 -3
- data/lib/basecamp/bucket_mismatch_error.rb +41 -0
- data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
- data/lib/basecamp/campfire_index.rb +398 -0
- data/lib/basecamp/client.rb +47 -4
- data/lib/basecamp/generated/metadata.json +728 -1
- data/lib/basecamp/generated/services/account_service.rb +2 -2
- data/lib/basecamp/generated/services/attachments_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +1 -1
- data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
- data/lib/basecamp/generated/services/boosts_service.rb +3 -3
- data/lib/basecamp/generated/services/calendars_service.rb +2 -2
- data/lib/basecamp/generated/services/campfires_service.rb +7 -7
- data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
- data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
- data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
- data/lib/basecamp/generated/services/cards_service.rb +3 -3
- data/lib/basecamp/generated/services/checkins_service.rb +9 -9
- data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
- data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
- data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
- data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
- data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
- data/lib/basecamp/generated/services/comments_service.rb +3 -3
- data/lib/basecamp/generated/services/documents_service.rb +3 -3
- data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
- data/lib/basecamp/generated/services/everything_service.rb +2 -2
- data/lib/basecamp/generated/services/folders_service.rb +4 -4
- data/lib/basecamp/generated/services/forwards_service.rb +3 -3
- data/lib/basecamp/generated/services/gauges_service.rb +10 -6
- data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
- data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
- data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
- data/lib/basecamp/generated/services/message_types_service.rb +3 -3
- data/lib/basecamp/generated/services/messages_service.rb +3 -3
- data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
- data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
- data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
- data/lib/basecamp/generated/services/people_service.rb +11 -11
- data/lib/basecamp/generated/services/projects_service.rb +4 -4
- data/lib/basecamp/generated/services/recordings_service.rb +1 -1
- data/lib/basecamp/generated/services/reports_service.rb +3 -3
- data/lib/basecamp/generated/services/schedules_service.rb +6 -6
- data/lib/basecamp/generated/services/search_service.rb +1 -1
- data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
- data/lib/basecamp/generated/services/templates_service.rb +8 -8
- data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
- data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
- data/lib/basecamp/generated/services/todolists_service.rb +3 -3
- data/lib/basecamp/generated/services/todos_service.rb +4 -4
- data/lib/basecamp/generated/services/todosets_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +3 -3
- data/lib/basecamp/generated/services/uploads_service.rb +4 -4
- data/lib/basecamp/generated/services/vaults_service.rb +3 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
- data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
- data/lib/basecamp/generated/types.rb +111 -7
- data/lib/basecamp/http.rb +383 -60
- data/lib/basecamp/ids.rb +277 -0
- data/lib/basecamp/mentions.rb +1119 -0
- data/lib/basecamp/person_id_sites.rb +184 -0
- data/lib/basecamp/recording_routing_error.rb +50 -0
- data/lib/basecamp/recording_summary_error.rb +33 -0
- data/lib/basecamp/services/comments_extensions.rb +121 -0
- data/lib/basecamp/services/merge_safe.rb +37 -13
- data/lib/basecamp/services/recordings_extensions.rb +1305 -0
- data/lib/basecamp/services/todolists_extensions.rb +4 -3
- data/lib/basecamp/text.rb +94 -0
- data/lib/basecamp/unresolved_recording_error.rb +68 -0
- data/lib/basecamp/version.rb +1 -1
- data/lib/basecamp/webhooks/event.rb +4 -1
- data/lib/basecamp.rb +22 -4
- data/scripts/generate-metadata.rb +75 -2
- data/scripts/generate-services.rb +35 -8
- data/scripts/generate-types.rb +19 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c53198686c7bf2b7f9fc25f60c14dec77dada2f3bbf7b6070f9a3e0639f7b529
|
|
4
|
+
data.tar.gz: f25233709c5a99476ed38728cf9ed005eb55e39457cc6dd4ea3250ac72583e60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d17039b8cddadbbc8ee270f46f0f3e4665651091c0cd0371728693398002dfdc9def64bc081316c496ca477e43a1e2b4e55b65bc53a2d47124694989d5a0437
|
|
7
|
+
data.tar.gz: 9bf77edeae08a13c60d1f25f3e9d16ad9d39298dffa7cc430fa319aee4ecfa208d62c1c3ccec629473058ac9b48569faea8190b4373313923caa48556a1ab83a
|
data/README.md
CHANGED
|
@@ -448,7 +448,7 @@ Only plain GET requests retry — automatically, with exponential backoff. Mutat
|
|
|
448
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
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.
|
|
450
450
|
- **Backoff**: Exponential with jitter — `base_delay * 2^(attempt - 1) + rand * max_jitter` — uncapped, bounded in practice by the attempt budget.
|
|
451
|
-
-
|
|
451
|
+
- **`Retry-After`**: A `Retry-After` header overrides the calculated backoff at every status the loop is already retrying — a 429, or a retryable 5xx — and `retry_after` is carried on the error at every status, not only 429. Network errors have no header and always use the exponential backoff.
|
|
452
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.
|
|
453
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.)
|
|
454
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.
|
data/lib/basecamp/api_error.rb
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
module Basecamp
|
|
4
4
|
# Raised for generic API errors.
|
|
5
5
|
class ApiError < Error
|
|
6
|
-
def initialize(message, http_status: nil, hint: nil, retryable: false, cause: nil)
|
|
6
|
+
def initialize(message, http_status: nil, hint: nil, retryable: false, retry_after: nil, cause: nil)
|
|
7
7
|
super(
|
|
8
8
|
code: ErrorCode::API,
|
|
9
9
|
message: message,
|
|
10
10
|
hint: hint,
|
|
11
11
|
http_status: http_status,
|
|
12
12
|
retryable: retryable,
|
|
13
|
+
retry_after: retry_after,
|
|
13
14
|
cause: cause
|
|
14
15
|
)
|
|
15
16
|
end
|
|
@@ -18,11 +19,12 @@ module Basecamp
|
|
|
18
19
|
# @param status [Integer] HTTP status code
|
|
19
20
|
# @param message [String, nil] optional error message
|
|
20
21
|
# @param hint [String, nil] optional hint (SPEC section 6 step 3)
|
|
22
|
+
# @param retry_after [Integer, nil] seconds from a parsed Retry-After header
|
|
21
23
|
# @return [ApiError]
|
|
22
|
-
def self.from_status(status, message = nil, hint: nil)
|
|
24
|
+
def self.from_status(status, message = nil, hint: nil, retry_after: nil)
|
|
23
25
|
message ||= "Request failed (HTTP #{status})"
|
|
24
26
|
retryable = status >= 500 && status < 600
|
|
25
|
-
new(message, http_status: status, hint: hint, retryable: retryable)
|
|
27
|
+
new(message, http_status: status, hint: hint, retryable: retryable, retry_after: retry_after)
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised when the recording a typed read returned lives in a different bucket
|
|
5
|
+
# from the one the pointer named, so a pointer from one project can never
|
|
6
|
+
# resolve to a recording in another.
|
|
7
|
+
#
|
|
8
|
+
# +code+ is +usage+ with +retryable+ false, settled across every port on
|
|
9
|
+
# card 41[https://app.basecamp.com/2914079/buckets/48699913/card_tables/cards/10308966794] after
|
|
10
|
+
# Rust shipped +not_found+ where this port and three others shipped +usage+.
|
|
11
|
+
# The read succeeded and the API answered honestly — it FOUND the recording,
|
|
12
|
+
# in another bucket, and returned it, so nothing is absent and +not_found+
|
|
13
|
+
# would be a false claim. It is the caller's pointer that disagreed with it.
|
|
14
|
+
class BucketMismatchError < RecordingSummaryError
|
|
15
|
+
KIND = "bucket_mismatch"
|
|
16
|
+
|
|
17
|
+
# @return [Integer] the bucket the pointer named
|
|
18
|
+
attr_reader :bucket_id
|
|
19
|
+
|
|
20
|
+
# @return [Integer] the bucket the read returned
|
|
21
|
+
attr_reader :actual_bucket_id
|
|
22
|
+
|
|
23
|
+
# @return [Integer] the recording's id
|
|
24
|
+
attr_reader :recording_id
|
|
25
|
+
|
|
26
|
+
# @param bucket_id [Integer] the bucket the pointer named
|
|
27
|
+
# @param actual_bucket_id [Integer] the bucket the read returned
|
|
28
|
+
# @param recording_id [Integer]
|
|
29
|
+
def initialize(bucket_id:, actual_bucket_id:, recording_id:)
|
|
30
|
+
super(
|
|
31
|
+
kind: KIND,
|
|
32
|
+
code: ErrorCode::USAGE,
|
|
33
|
+
message: "recording is not in the requested bucket: recording #{recording_id} is in bucket " \
|
|
34
|
+
"#{actual_bucket_id}, not #{bucket_id}"
|
|
35
|
+
)
|
|
36
|
+
@bucket_id = bucket_id
|
|
37
|
+
@actual_bucket_id = actual_bucket_id
|
|
38
|
+
@recording_id = recording_id
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised when a chat line's Campfire discovery could not be carried to a
|
|
5
|
+
# conclusion — the account-wide Campfire listing overflowed
|
|
6
|
+
# +CampfireIndex::MAX_LISTING+, or the bucket has more visible Campfires than
|
|
7
|
+
# +RecordingsExtensions::MAX_CAMPFIRE_CANDIDATES+.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately distinct from {UnresolvedRecordingError}: candidates were left
|
|
10
|
+
# unsearched, so nothing can be reported absent. Nothing left unsearched is
|
|
11
|
+
# ever called missing.
|
|
12
|
+
#
|
|
13
|
+
# +code+ is +usage+ with +retryable+ false, settled across every port on
|
|
14
|
+
# card 40[https://app.basecamp.com/2914079/buckets/48699913/card_tables/cards/10308122086]
|
|
15
|
+
# after the merged ports shipped two different answers.
|
|
16
|
+
#
|
|
17
|
+
# +usage+ is one of only three coarse codes no HTTP response can produce —
|
|
18
|
+
# the status mapping yields +auth_required+, +forbidden+, +not_found+,
|
|
19
|
+
# +rate_limit+, +validation+, +limit_exceeded+ and +api_error+, and +network+
|
|
20
|
+
# and +ambiguous+ are equally unreachable from a status. +usage+ is the one of
|
|
21
|
+
# those three that also describes a call the SDK declined to complete, which
|
|
22
|
+
# is why it and not the other two. A verdict the composite reached on its own
|
|
23
|
+
# therefore can never be read back as a constituent read's own answer. This
|
|
24
|
+
# port previously said +api_error+, which a caller could not tell from a 500
|
|
25
|
+
# one of those reads returned.
|
|
26
|
+
#
|
|
27
|
+
# Retryability is a separate field and is unchanged: false, because the call
|
|
28
|
+
# reached no verdict and no argument the caller can change would produce one
|
|
29
|
+
# — the bounds are the SDK's, both reasons are deterministic for the same
|
|
30
|
+
# account state, and a retry loop would re-run the identical search forever.
|
|
31
|
+
class CampfireDiscoveryIncompleteError < RecordingSummaryError
|
|
32
|
+
KIND = "campfire_discovery_incomplete"
|
|
33
|
+
|
|
34
|
+
# @return [Integer] the bucket the pointer named
|
|
35
|
+
attr_reader :bucket_id
|
|
36
|
+
|
|
37
|
+
# @return [Integer] the chat line's id
|
|
38
|
+
attr_reader :recording_id
|
|
39
|
+
|
|
40
|
+
# @return [String] why discovery stopped short
|
|
41
|
+
attr_reader :reason
|
|
42
|
+
|
|
43
|
+
# @param bucket_id [Integer]
|
|
44
|
+
# @param recording_id [Integer]
|
|
45
|
+
# @param reason [String]
|
|
46
|
+
def initialize(bucket_id:, recording_id:, reason:)
|
|
47
|
+
super(
|
|
48
|
+
kind: KIND,
|
|
49
|
+
code: ErrorCode::USAGE,
|
|
50
|
+
message: "campfire discovery incomplete: line #{recording_id} in bucket #{bucket_id}: #{reason}"
|
|
51
|
+
)
|
|
52
|
+
@bucket_id = bucket_id
|
|
53
|
+
@recording_id = recording_id
|
|
54
|
+
@reason = reason
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# The two Campfire discovery sources a chat line's read needs, each cached.
|
|
5
|
+
#
|
|
6
|
+
# A +chat.line.created+ row carries the line's id and bucket, not its
|
|
7
|
+
# Campfire, and the line read is +/chats/{campfireId}/lines/{lineId}+.
|
|
8
|
+
# Candidates come from two sources, tried in order and each cached {TTL}
|
|
9
|
+
# seconds:
|
|
10
|
+
#
|
|
11
|
+
# 1. The bucket's project dock, whose "chat" tool is the project's Campfire:
|
|
12
|
+
# one project read per bucket, and the answer for every line posted in a
|
|
13
|
+
# project. Cached per bucket.
|
|
14
|
+
# 2. The account-wide Campfire listing (BC3 has no per-bucket one), filtered
|
|
15
|
+
# to the bucket, for buckets that are not projects or whose dock did not
|
|
16
|
+
# hold the line. Cached per account, so a burst of lines costs one listing.
|
|
17
|
+
#
|
|
18
|
+
# This class holds the caching only; the reads themselves are passed in as
|
|
19
|
+
# blocks by +RecordingsExtensions+, so every request still goes through a
|
|
20
|
+
# generated service method (SPEC section 18 rule 1) and this file touches no
|
|
21
|
+
# wire.
|
|
22
|
+
#
|
|
23
|
+
# It lives on {Client}, shared by every {AccountClient} that client hands out.
|
|
24
|
+
# A Client is bound to one credential, so entries are never shared across
|
|
25
|
+
# authorization contexts, and every key carries the account id anyway. Expired
|
|
26
|
+
# snapshots are swept at each publication and each cache is bounded
|
|
27
|
+
# ({MAX_ITEMS}, oldest fetched out first), so the index holds at most the
|
|
28
|
+
# buckets and accounts consulted within the last TTL, and never more than the
|
|
29
|
+
# bound, whatever the Client has seen.
|
|
30
|
+
class CampfireIndex
|
|
31
|
+
# How long a cached discovery source — a bucket's project dock, the
|
|
32
|
+
# account's Campfire listing — is reused before it is read again.
|
|
33
|
+
TTL = 600.0
|
|
34
|
+
|
|
35
|
+
# Bounds the refresh-on-miss: a line found under no candidate re-reads the
|
|
36
|
+
# cached sources, but not more often than this per source, so a run of
|
|
37
|
+
# unresolvable lines cannot turn into a listing per line.
|
|
38
|
+
# +UnresolvedRecordingError#refreshed?+ says whether the floor applied.
|
|
39
|
+
MIN_REFRESH = 30.0
|
|
40
|
+
|
|
41
|
+
# Caps the account-wide Campfire listing the fallback source reads. A
|
|
42
|
+
# listing that overflows it is not cached and the call reports
|
|
43
|
+
# {CampfireDiscoveryIncompleteError}: the dock covers every project, so the
|
|
44
|
+
# listing only ever serves the leftover, and an account with more Campfires
|
|
45
|
+
# than this should not pay a full walk per TTL for it.
|
|
46
|
+
MAX_LISTING = 1000
|
|
47
|
+
|
|
48
|
+
# Bounds each cache's entry count. The dock cache holds one snapshot per
|
|
49
|
+
# bucket consulted within the TTL: a connector listening across every
|
|
50
|
+
# project an agent can see touches hundreds of buckets, not thousands, and a
|
|
51
|
+
# snapshot is a handful of ids, so this is generous headroom at a few
|
|
52
|
+
# hundred KB. The bound exists so that a process alive for weeks can never
|
|
53
|
+
# grow past it whatever it sees. When the bound is reached the
|
|
54
|
+
# oldest-fetched entries go first, deterministically.
|
|
55
|
+
MAX_ITEMS = 1024
|
|
56
|
+
|
|
57
|
+
# Raised by the listing loader when the account-wide listing overflows
|
|
58
|
+
# {MAX_LISTING}. +RecordingsExtensions+ turns it into the typed
|
|
59
|
+
# {CampfireDiscoveryIncompleteError}; it is never cached, so the next call
|
|
60
|
+
# re-reads rather than remembering a verdict it never reached.
|
|
61
|
+
class ListingOverflow < StandardError; end
|
|
62
|
+
|
|
63
|
+
# One consultation of a discovery source: the candidate ids it holds for the
|
|
64
|
+
# bucket, when that snapshot was fetched, and whether it predated the call.
|
|
65
|
+
SourceRead = Struct.new(:ids, :fetched, :cached, keyword_init: true) do
|
|
66
|
+
# @return [Boolean] whether the snapshot predated this call
|
|
67
|
+
def cached?
|
|
68
|
+
cached
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param clock [#call, nil] monotonic seconds, injectable for tests
|
|
73
|
+
def initialize(clock: nil)
|
|
74
|
+
@docks = TTLCache.new(ttl: TTL, floor: MIN_REFRESH, max_items: MAX_ITEMS, clock: clock)
|
|
75
|
+
@listings = TTLCache.new(ttl: TTL, floor: MIN_REFRESH, max_items: MAX_ITEMS, clock: clock)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The Campfire ids a bucket's project dock names.
|
|
79
|
+
#
|
|
80
|
+
# @param account_id [String]
|
|
81
|
+
# @param bucket_id [Integer]
|
|
82
|
+
# @param refresh [Boolean] re-read a cached snapshot older than {MIN_REFRESH}
|
|
83
|
+
# @yieldreturn [Array<Integer>] the dock's Campfire ids
|
|
84
|
+
# @return [SourceRead]
|
|
85
|
+
def dock_campfires(account_id:, bucket_id:, refresh: false, &load)
|
|
86
|
+
hit = @docks.get([ account_id, bucket_id ], refresh: refresh, &load)
|
|
87
|
+
SourceRead.new(ids: Array(hit.value), fetched: hit.fetched, cached: hit.cached)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The Campfire ids the CACHED account-wide listing shows in a bucket,
|
|
91
|
+
# without fetching. nil when the listing is not cached or has expired.
|
|
92
|
+
#
|
|
93
|
+
# It exists so a caller can consult what the source already holds before
|
|
94
|
+
# deciding whether to pay for the listing fetch — the expensive, slow
|
|
95
|
+
# request, which the dock (including its refresh) should get to pre-empt.
|
|
96
|
+
#
|
|
97
|
+
# @param account_id [String]
|
|
98
|
+
# @param bucket_id [Integer]
|
|
99
|
+
# @return [SourceRead, nil]
|
|
100
|
+
def cached_listed_campfires(account_id:, bucket_id:)
|
|
101
|
+
hit = @listings.peek(account_id)
|
|
102
|
+
return nil if hit.nil?
|
|
103
|
+
|
|
104
|
+
SourceRead.new(ids: Array(hit.value[bucket_id]).dup, fetched: hit.fetched, cached: true)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The Campfire ids the account-wide listing shows in a bucket.
|
|
108
|
+
#
|
|
109
|
+
# @param account_id [String]
|
|
110
|
+
# @param bucket_id [Integer]
|
|
111
|
+
# @param refresh [Boolean] re-read a cached snapshot older than {MIN_REFRESH}
|
|
112
|
+
# @yieldreturn [Hash{Integer => Array<Integer>}] campfire ids by bucket id
|
|
113
|
+
# @return [SourceRead]
|
|
114
|
+
# @raise [ListingOverflow] when the loader reports the listing was truncated
|
|
115
|
+
def listed_campfires(account_id:, bucket_id:, refresh: false, &load)
|
|
116
|
+
hit = @listings.get(account_id, refresh: refresh, &load)
|
|
117
|
+
SourceRead.new(ids: Array(hit.value[bucket_id]).dup, fetched: hit.fetched, cached: hit.cached)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A per-key cache with single-flight loading: concurrent callers for one key
|
|
121
|
+
# wait on the one load in progress rather than loading again, and a refresh
|
|
122
|
+
# is honoured only once the value is older than a floor.
|
|
123
|
+
#
|
|
124
|
+
# A load that FAILS leaves the previous value in place and its error is
|
|
125
|
+
# shared with the waiters, so N callers never re-run one failed load N
|
|
126
|
+
# times. A load that was ABANDONED — the loading thread killed, or unwound
|
|
127
|
+
# by something that is not a StandardError — is the one exception: a waiter
|
|
128
|
+
# goes round once and loads for itself, because that outcome says nothing
|
|
129
|
+
# about whether the waiter's own call can succeed. See {LoaderAbandoned},
|
|
130
|
+
# which explains why the two can be told apart here.
|
|
131
|
+
class TTLCache
|
|
132
|
+
# Raised when a load left without publishing an outcome of its own — the
|
|
133
|
+
# loading thread was killed, or unwound by something that is not a
|
|
134
|
+
# StandardError, +Timeout.timeout+ included (it raises
|
|
135
|
+
# +Timeout::ExitException+ INSIDE the block and only becomes a
|
|
136
|
+
# +Timeout::Error+ at its own frame, so no rescue here ever sees it).
|
|
137
|
+
#
|
|
138
|
+
# It is a {Basecamp::Error} so that +rescue Basecamp::Error+ around a
|
|
139
|
+
# composite catches it like everything else, with +api_error+ and
|
|
140
|
+
# +retryable+ — the load reached no verdict and the key is free, so the
|
|
141
|
+
# next call loads for itself.
|
|
142
|
+
#
|
|
143
|
+
# A waiter does not normally re-run another caller's failed load, but this
|
|
144
|
+
# one it does, once: the classification is unambiguous here in a way Go's
|
|
145
|
+
# two-part +callerDone+ had to work for. A transport failure is a
|
|
146
|
+
# StandardError and is shared as the load's own; only a genuinely
|
|
147
|
+
# non-local unwind of the LOADING thread reaches this, and that says
|
|
148
|
+
# nothing about whether the waiter's own call can succeed. Without the
|
|
149
|
+
# re-run, one caller's +Timeout.timeout+ fails every concurrent waiter on
|
|
150
|
+
# a key the whole account shares.
|
|
151
|
+
class LoaderAbandoned < Error
|
|
152
|
+
def initialize(message = "cache loader did not complete")
|
|
153
|
+
super(code: ErrorCode::API, message: message, retryable: true)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# What a cache read hands back: the value, when it was fetched, and
|
|
158
|
+
# whether it predated the call (as opposed to being loaded during it, by
|
|
159
|
+
# this caller or by one it waited on). The fetch time is what lets a
|
|
160
|
+
# caller tell a snapshot it already consulted from a newer one, whoever
|
|
161
|
+
# loaded it.
|
|
162
|
+
Hit = Struct.new(:value, :fetched, :cached, keyword_init: true)
|
|
163
|
+
|
|
164
|
+
Entry = Struct.new(:value, :fetched, :seq, keyword_init: true)
|
|
165
|
+
|
|
166
|
+
# How many times one {#get} may try. Two: the call itself, plus the single
|
|
167
|
+
# re-run a waiter is allowed when the load it woke on was abandoned.
|
|
168
|
+
MAX_ATTEMPTS = 2
|
|
169
|
+
|
|
170
|
+
# The clock is MONOTONIC, never wall time. A backwards step in wall time —
|
|
171
|
+
# an NTP correction, a VM resume — would make an entry outlive its TTL,
|
|
172
|
+
# decline a refresh that is genuinely due, and take the `refreshed` and
|
|
173
|
+
# stale-candidate signals down with it, none of which surfaces as an
|
|
174
|
+
# error. The injected clock in tests is monotonic in the same sense: it
|
|
175
|
+
# only ever moves forward.
|
|
176
|
+
#
|
|
177
|
+
# @param ttl [Float] seconds a value is reused for
|
|
178
|
+
# @param floor [Float] seconds a refresh must wait before it is honoured
|
|
179
|
+
# @param max_items [Integer] entry bound; 0 or less disables eviction
|
|
180
|
+
# @param clock [#call, nil] monotonic seconds, injectable for tests
|
|
181
|
+
# @param on_wait [#call, nil] a test seam, called while the cache's lock
|
|
182
|
+
# is held and immediately before a waiter releases it to wait. It is how
|
|
183
|
+
# a test knows a waiter has REACHED the wait rather than guessing from
|
|
184
|
+
# Thread#status, which reports a thread merely blocked on the lock as
|
|
185
|
+
# sleeping too. The reference implementation carries a seam for the same
|
|
186
|
+
# reason, though it calls its own after releasing the lock, which leaves
|
|
187
|
+
# the waiter able to be descheduled before it waits; under the lock is
|
|
188
|
+
# the stronger barrier and the reason this one is placed there. It also
|
|
189
|
+
# means a seam that BLOCKS would wedge the cache — never set in
|
|
190
|
+
# production, and never anything but a signal in a test.
|
|
191
|
+
def initialize(ttl:, floor:, max_items:, clock: nil, on_wait: nil)
|
|
192
|
+
@ttl = ttl
|
|
193
|
+
@floor = floor
|
|
194
|
+
@max_items = max_items
|
|
195
|
+
@on_wait = on_wait
|
|
196
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
197
|
+
@mutex = Mutex.new
|
|
198
|
+
@condition = ConditionVariable.new
|
|
199
|
+
@entries = {}
|
|
200
|
+
@inflight = {}
|
|
201
|
+
@seq = 0
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Returns the value for key, loading it when absent or older than the TTL
|
|
205
|
+
# — or, with +refresh+ set, older than the floor.
|
|
206
|
+
#
|
|
207
|
+
# @param key [Object]
|
|
208
|
+
# @param refresh [Boolean]
|
|
209
|
+
# @yieldreturn [Object] the loaded value
|
|
210
|
+
# @return [Hit]
|
|
211
|
+
def get(key, refresh: false)
|
|
212
|
+
# Two attempts, not a loop with a counter, and deliberately not
|
|
213
|
+
# Kernel#loop. The bound IS the retry rule — a waiter may load for
|
|
214
|
+
# itself once when the load it woke on was abandoned — so spelling it as
|
|
215
|
+
# the iteration count leaves nothing to drift. Kernel#loop would also
|
|
216
|
+
# rescue StopIteration out of the caller's loader and return its result
|
|
217
|
+
# in place of a Hit, turning a raise into a silently wrong answer.
|
|
218
|
+
MAX_ATTEMPTS.times do |attempt|
|
|
219
|
+
owner = false
|
|
220
|
+
pending = nil
|
|
221
|
+
published = false
|
|
222
|
+
|
|
223
|
+
# The ensure encloses the REGISTRATION, not just the loader. A loader
|
|
224
|
+
# that leaves by anything the rescue below does not catch — Interrupt,
|
|
225
|
+
# a signal, NoMemoryError, a Thread#kill that raises nothing at all —
|
|
226
|
+
# would otherwise leave the key in flight forever, and since {#await}
|
|
227
|
+
# waits with no timeout, every later caller for that key would park on
|
|
228
|
+
# it permanently rather than erroring. The listing's key is the bare
|
|
229
|
+
# account id, so that is one killed thread wedging chat-line discovery
|
|
230
|
+
# for a whole account for the life of the process. Go publishes from a
|
|
231
|
+
# deferred recover for exactly this reason — and an ensure that began
|
|
232
|
+
# after the key was registered would leave the same hole a few
|
|
233
|
+
# instructions wide.
|
|
234
|
+
begin
|
|
235
|
+
@mutex.synchronize do
|
|
236
|
+
entry = @entries[key]
|
|
237
|
+
if entry
|
|
238
|
+
age = @clock.call - entry.fetched
|
|
239
|
+
if age < @ttl && (!refresh || age < @floor)
|
|
240
|
+
return Hit.new(value: entry.value, fetched: entry.fetched, cached: true)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
pending = @inflight[key]
|
|
245
|
+
if pending.nil?
|
|
246
|
+
pending = { done: false, error: nil, value: nil, fetched: nil }
|
|
247
|
+
# Ownership is claimed BEFORE the key is registered, so an
|
|
248
|
+
# interrupt between the two leaves the ensure publishing a
|
|
249
|
+
# record that was never registered — which {#publish} ignores,
|
|
250
|
+
# because it only ever evicts its own.
|
|
251
|
+
owner = true
|
|
252
|
+
@inflight[key] = pending
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
unless owner
|
|
257
|
+
begin
|
|
258
|
+
return await(pending)
|
|
259
|
+
rescue LoaderAbandoned
|
|
260
|
+
# DELIBERATE, and the one place a waiter does not simply take
|
|
261
|
+
# the outcome it woke on. The load did not fail, it was
|
|
262
|
+
# abandoned — see {LoaderAbandoned} for why Ruby can tell those
|
|
263
|
+
# apart where Go needed a two-part test — so this caller goes
|
|
264
|
+
# round and loads for itself. On the last attempt it is raised
|
|
265
|
+
# rather than chased, so a run of abandoned owners cannot become
|
|
266
|
+
# a queue of sequential loads behind one waiter.
|
|
267
|
+
raise if attempt == MAX_ATTEMPTS - 1
|
|
268
|
+
end
|
|
269
|
+
next
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
value = yield
|
|
273
|
+
publish(key, pending, value, nil)
|
|
274
|
+
published = true
|
|
275
|
+
return Hit.new(value: value, fetched: pending[:fetched], cached: false)
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
publish(key, pending, nil, e) if owner
|
|
278
|
+
published = true
|
|
279
|
+
raise
|
|
280
|
+
ensure
|
|
281
|
+
publish(key, pending, nil, LoaderAbandoned.new) if owner && !published
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Unreachable: the last attempt either returns or raises. Stated so a
|
|
286
|
+
# future edit that breaks that cannot fall out of here with a value
|
|
287
|
+
# nobody meant.
|
|
288
|
+
raise LoaderAbandoned.new("cache load did not settle in #{MAX_ATTEMPTS} attempts")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Returns the cached value for key when one is within the TTL, without
|
|
292
|
+
# loading.
|
|
293
|
+
#
|
|
294
|
+
# @param key [Object]
|
|
295
|
+
# @return [Hit, nil]
|
|
296
|
+
def peek(key)
|
|
297
|
+
@mutex.synchronize do
|
|
298
|
+
entry = @entries[key]
|
|
299
|
+
return nil if entry.nil? || (@clock.call - entry.fetched) >= @ttl
|
|
300
|
+
|
|
301
|
+
Hit.new(value: entry.value, fetched: entry.fetched, cached: true)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
# Waits on another caller's load and hands its outcome over.
|
|
308
|
+
#
|
|
309
|
+
# The value is read off the load record, not out of the cache, so a sweep
|
|
310
|
+
# or the entry bound evicting the entry in the meantime cannot take it
|
|
311
|
+
# from this waiter. It is reported as loaded during this call rather than
|
|
312
|
+
# as something that predated it, because it was.
|
|
313
|
+
def await(pending)
|
|
314
|
+
@mutex.synchronize do
|
|
315
|
+
until pending[:done]
|
|
316
|
+
# Signalled under the lock, so a test that has seen it knows this
|
|
317
|
+
# thread reaches the wait before any other thread can take the lock
|
|
318
|
+
# and publish.
|
|
319
|
+
@on_wait&.call
|
|
320
|
+
@condition.wait(@mutex)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
raise pending[:error] if pending[:error]
|
|
324
|
+
|
|
325
|
+
Hit.new(value: pending[:value], fetched: pending[:fetched], cached: false)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Releases the key and wakes the waiters. A failed load publishes no entry,
|
|
329
|
+
# so the previous value — if any — stays in place and keeps serving until
|
|
330
|
+
# its own TTL runs out.
|
|
331
|
+
def publish(key, pending, value, error)
|
|
332
|
+
@mutex.synchronize do
|
|
333
|
+
# Idempotent, so publication survives being interrupted. An async
|
|
334
|
+
# exception landing between the load returning and this call
|
|
335
|
+
# completing would otherwise let {#get}'s ensure publish a second time
|
|
336
|
+
# and overwrite a good value with an abandonment error. Whichever
|
|
337
|
+
# publication lands first is the outcome; the rest only wake the
|
|
338
|
+
# waiters again.
|
|
339
|
+
#
|
|
340
|
+
# The repeat still broadcasts, and that is the point of doing it here
|
|
341
|
+
# rather than returning bare: an interrupt landing between the flag
|
|
342
|
+
# below and its broadcast would otherwise leave a record marked done
|
|
343
|
+
# with everybody still parked on it.
|
|
344
|
+
if pending[:done]
|
|
345
|
+
@condition.broadcast
|
|
346
|
+
return
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Only this call's own registration. An abandonment publish for a
|
|
350
|
+
# record that never made it into the map — the interrupt window the
|
|
351
|
+
# ensure covers — would otherwise delete whatever ANOTHER thread has
|
|
352
|
+
# since registered under the key, dropping its single-flight guarantee
|
|
353
|
+
# while it is still loading. The listing's key is the bare account id,
|
|
354
|
+
# so that is process-wide.
|
|
355
|
+
@inflight.delete(key) if @inflight[key].equal?(pending)
|
|
356
|
+
pending[:error] = error
|
|
357
|
+
if error.nil?
|
|
358
|
+
pending[:value] = value
|
|
359
|
+
pending[:fetched] = @clock.call
|
|
360
|
+
sweep_locked
|
|
361
|
+
make_room_locked(key)
|
|
362
|
+
@seq += 1
|
|
363
|
+
@entries[key] = Entry.new(value: value, fetched: pending[:fetched], seq: @seq)
|
|
364
|
+
end
|
|
365
|
+
pending[:done] = true
|
|
366
|
+
@condition.broadcast
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Drops every entry past its TTL. Runs at each publication — the one
|
|
371
|
+
# moment the cache does work proportional to a miss anyway — so a
|
|
372
|
+
# long-lived Client that has seen many buckets keeps a snapshot for at most
|
|
373
|
+
# a TTL past its last use plus the interval to the next load on any key,
|
|
374
|
+
# rather than for its lifetime.
|
|
375
|
+
def sweep_locked
|
|
376
|
+
now = @clock.call
|
|
377
|
+
@entries.delete_if { |_key, entry| (now - entry.fetched) >= @ttl }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Evicts the oldest-fetched entries until the one about to be stored for
|
|
381
|
+
# +key+ fits under the bound — oldest by fetch time, and by publication
|
|
382
|
+
# order among equals, so the choice is total rather than whatever hash
|
|
383
|
+
# order happens to come first. Oldest-first is also the right order: the
|
|
384
|
+
# entry nearest its TTL is the one least worth keeping.
|
|
385
|
+
def make_room_locked(key)
|
|
386
|
+
return if @max_items <= 0
|
|
387
|
+
return if @entries.key?(key) # an overwrite takes no new room
|
|
388
|
+
|
|
389
|
+
while @entries.size >= @max_items
|
|
390
|
+
oldest = @entries.min_by { |_k, entry| [ entry.fetched, entry.seq ] }
|
|
391
|
+
break if oldest.nil?
|
|
392
|
+
|
|
393
|
+
@entries.delete(oldest.first)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
data/lib/basecamp/client.rb
CHANGED
|
@@ -96,6 +96,23 @@ module Basecamp
|
|
|
96
96
|
def account_id
|
|
97
97
|
nil
|
|
98
98
|
end
|
|
99
|
+
|
|
100
|
+
# @api private
|
|
101
|
+
# The Campfire discovery caches used by +recordings.summarize+ when it
|
|
102
|
+
# resolves a chat line.
|
|
103
|
+
#
|
|
104
|
+
# It lives here rather than on {AccountClient} so a burst of lines costs one
|
|
105
|
+
# project read per bucket and one account-wide listing, however many
|
|
106
|
+
# AccountClients this Client has handed out. A Client is bound to one
|
|
107
|
+
# credential, so entries are never shared across authorization contexts, and
|
|
108
|
+
# every key carries the account id anyway.
|
|
109
|
+
#
|
|
110
|
+
# @return [CampfireIndex]
|
|
111
|
+
def campfire_index
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
@campfire_index ||= CampfireIndex.new
|
|
114
|
+
end
|
|
115
|
+
end
|
|
99
116
|
end
|
|
100
117
|
|
|
101
118
|
# HTTP client bound to a specific Basecamp account.
|
|
@@ -142,6 +159,14 @@ module Basecamp
|
|
|
142
159
|
@parent.http
|
|
143
160
|
end
|
|
144
161
|
|
|
162
|
+
# @api private
|
|
163
|
+
# The parent Client's Campfire discovery caches, shared by every
|
|
164
|
+
# AccountClient it handed out. See {Client#campfire_index}.
|
|
165
|
+
# @return [CampfireIndex]
|
|
166
|
+
def campfire_index
|
|
167
|
+
@parent.campfire_index
|
|
168
|
+
end
|
|
169
|
+
|
|
145
170
|
# @api private
|
|
146
171
|
# @return [Hooks] the observability hooks
|
|
147
172
|
def hooks
|
|
@@ -311,8 +336,12 @@ module Basecamp
|
|
|
311
336
|
|
|
312
337
|
else
|
|
313
338
|
# This shouldn't happen because Faraday's raise_error middleware
|
|
314
|
-
# handles 4xx/5xx, but handle it defensively
|
|
315
|
-
|
|
339
|
+
# handles 4xx/5xx, but handle it defensively — carrying the parsed
|
|
340
|
+
# Retry-After as every other mapping does (SPEC §6).
|
|
341
|
+
raise Basecamp.error_from_response(
|
|
342
|
+
response.status, response.body,
|
|
343
|
+
retry_after: http.parse_retry_after_header(response.headers["Retry-After"] || response.headers["retry-after"])
|
|
344
|
+
)
|
|
316
345
|
end
|
|
317
346
|
rescue => e
|
|
318
347
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
@@ -507,6 +536,12 @@ module Basecamp
|
|
|
507
536
|
service(:bookmarks) { Services::BookmarksService.new(self) }
|
|
508
537
|
end
|
|
509
538
|
|
|
539
|
+
# The account event feed, the agent inbox, and stream tickets (SPEC §23 wire layer).
|
|
540
|
+
# @return [Services::EventFeedService]
|
|
541
|
+
def event_feed
|
|
542
|
+
service(:event_feed) { Services::EventFeedService.new(self) }
|
|
543
|
+
end
|
|
544
|
+
|
|
510
545
|
# @return [Services::BubbleUpsService]
|
|
511
546
|
def bubble_ups
|
|
512
547
|
service(:bubble_ups) { Services::BubbleUpsService.new(self) }
|
|
@@ -674,14 +709,22 @@ module Basecamp
|
|
|
674
709
|
raise NetworkError.new("Download failed"), cause: nil
|
|
675
710
|
end
|
|
676
711
|
|
|
712
|
+
# Hop 2 is never retried, but its error still carries the parsed
|
|
713
|
+
# Retry-After like every other mapped status (SPEC §6): a caller
|
|
714
|
+
# rescheduling the download themselves reads it off whatever came back.
|
|
715
|
+
retry_after = http.parse_retry_after_header(response["Retry-After"])
|
|
716
|
+
|
|
677
717
|
# The exact set hop 1 dispatches on, not Net::HTTPRedirection — that
|
|
678
718
|
# class also covers 304, which is a cache answer, not a redirect.
|
|
679
719
|
if [ 301, 302, 303, 307, 308 ].include?(response.code.to_i)
|
|
680
|
-
raise ApiError.new(
|
|
720
|
+
raise ApiError.new(
|
|
721
|
+
"redirect #{response.code} on the signed download hop is not followed",
|
|
722
|
+
http_status: response.code.to_i, retry_after: retry_after
|
|
723
|
+
)
|
|
681
724
|
end
|
|
682
725
|
|
|
683
726
|
unless response.is_a?(Net::HTTPSuccess)
|
|
684
|
-
raise ApiError.new("download failed with status #{response.code}", http_status: response.code.to_i)
|
|
727
|
+
raise ApiError.new("download failed with status #{response.code}", http_status: response.code.to_i, retry_after: retry_after)
|
|
685
728
|
end
|
|
686
729
|
|
|
687
730
|
response
|