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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/basecamp/api_error.rb +5 -3
  4. data/lib/basecamp/bucket_mismatch_error.rb +41 -0
  5. data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
  6. data/lib/basecamp/campfire_index.rb +398 -0
  7. data/lib/basecamp/client.rb +47 -4
  8. data/lib/basecamp/generated/metadata.json +728 -1
  9. data/lib/basecamp/generated/services/account_service.rb +2 -2
  10. data/lib/basecamp/generated/services/attachments_service.rb +1 -1
  11. data/lib/basecamp/generated/services/automation_service.rb +1 -1
  12. data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
  13. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  14. data/lib/basecamp/generated/services/calendars_service.rb +2 -2
  15. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  16. data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
  17. data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
  18. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  19. data/lib/basecamp/generated/services/cards_service.rb +3 -3
  20. data/lib/basecamp/generated/services/checkins_service.rb +9 -9
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  23. data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
  25. data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
  26. data/lib/basecamp/generated/services/comments_service.rb +3 -3
  27. data/lib/basecamp/generated/services/documents_service.rb +3 -3
  28. data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
  29. data/lib/basecamp/generated/services/everything_service.rb +2 -2
  30. data/lib/basecamp/generated/services/folders_service.rb +4 -4
  31. data/lib/basecamp/generated/services/forwards_service.rb +3 -3
  32. data/lib/basecamp/generated/services/gauges_service.rb +10 -6
  33. data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
  34. data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
  35. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  36. data/lib/basecamp/generated/services/message_types_service.rb +3 -3
  37. data/lib/basecamp/generated/services/messages_service.rb +3 -3
  38. data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
  39. data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
  40. data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
  41. data/lib/basecamp/generated/services/people_service.rb +11 -11
  42. data/lib/basecamp/generated/services/projects_service.rb +4 -4
  43. data/lib/basecamp/generated/services/recordings_service.rb +1 -1
  44. data/lib/basecamp/generated/services/reports_service.rb +3 -3
  45. data/lib/basecamp/generated/services/schedules_service.rb +6 -6
  46. data/lib/basecamp/generated/services/search_service.rb +1 -1
  47. data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
  48. data/lib/basecamp/generated/services/templates_service.rb +8 -8
  49. data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
  50. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  51. data/lib/basecamp/generated/services/todolists_service.rb +3 -3
  52. data/lib/basecamp/generated/services/todos_service.rb +4 -4
  53. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  54. data/lib/basecamp/generated/services/tools_service.rb +3 -3
  55. data/lib/basecamp/generated/services/uploads_service.rb +4 -4
  56. data/lib/basecamp/generated/services/vaults_service.rb +3 -3
  57. data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
  58. data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
  59. data/lib/basecamp/generated/types.rb +111 -7
  60. data/lib/basecamp/http.rb +383 -60
  61. data/lib/basecamp/ids.rb +277 -0
  62. data/lib/basecamp/mentions.rb +1119 -0
  63. data/lib/basecamp/person_id_sites.rb +184 -0
  64. data/lib/basecamp/recording_routing_error.rb +50 -0
  65. data/lib/basecamp/recording_summary_error.rb +33 -0
  66. data/lib/basecamp/services/comments_extensions.rb +121 -0
  67. data/lib/basecamp/services/merge_safe.rb +37 -13
  68. data/lib/basecamp/services/recordings_extensions.rb +1305 -0
  69. data/lib/basecamp/services/todolists_extensions.rb +4 -3
  70. data/lib/basecamp/text.rb +94 -0
  71. data/lib/basecamp/unresolved_recording_error.rb +68 -0
  72. data/lib/basecamp/version.rb +1 -1
  73. data/lib/basecamp/webhooks/event.rb +4 -1
  74. data/lib/basecamp.rb +22 -4
  75. data/scripts/generate-metadata.rb +75 -2
  76. data/scripts/generate-services.rb +35 -8
  77. data/scripts/generate-types.rb +19 -0
  78. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6831e79b8c78f4ba54ee64287457227c5553fbd2837e71b09d022601084ac2af
4
- data.tar.gz: 3dd5ac9aefd6f03e78fa9fa4c9833ac98d217a09a05ea129b8b2b6f3d7049939
3
+ metadata.gz: c53198686c7bf2b7f9fc25f60c14dec77dada2f3bbf7b6070f9a3e0639f7b529
4
+ data.tar.gz: f25233709c5a99476ed38728cf9ed005eb55e39457cc6dd4ea3250ac72583e60
5
5
  SHA512:
6
- metadata.gz: 007f77e92a111a5687b26740caedc75e71e640ddfc10a32057b39b1627ab7cedd2412faebc75647b26402551e7f235b4bf77743236e8ab9da5fc26e8b8286c0b
7
- data.tar.gz: dff2430d3c5225c2ad563c0848d61d09a1da0620c58d93438947318f567b2f551548b5a899dfbadb55d14f84b495efb8982f786515c8fd934a885757b5f9db5e
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
- - **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.
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.
@@ -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
@@ -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
- raise Basecamp.error_from_response(response.status, response.body)
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("redirect #{response.code} on the signed download hop is not followed", http_status: response.code.to_i)
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