basecradle 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bf816afd2fd6daa64bd896c2d747fc858027ac4bc97e6561791f7125b49048c
4
- data.tar.gz: c7639714261d5da32630add601b6490b1131c4f53d2e991f50f0313bef40f685
3
+ metadata.gz: 867f9a79ea24a73640482c4c13a64293107dcefa51a744a678007dfe5cb86ec7
4
+ data.tar.gz: 8b9cfa07d0f99ec7d4bc7634643c483b7eebda9d6bc3c0a93e5083303820fa66
5
5
  SHA512:
6
- metadata.gz: 227e12fcbce92a086604c9a870e92e618f6f33753ddb97617a7309fa7c933b266196a759b4984a371f25b3d356af9c4af7bdd9f3d6a2e924fff12bc2b734634c
7
- data.tar.gz: 64e27b82d97a01de50d116234ad23c3ac9b5253609e790448e401c9501d5b3325b2414ec5fce1b2452c4b52b568d45875cfc473b375193dae95d288a2c811cd2
6
+ metadata.gz: bfba4845b09bfd07a3023a2165b091ba1ee4adac25c9627b211567523f2f27b9719aedfeab486db8b55d7ab83e4477938efdd96aa075159e5a7bcb19dbebdeb3
7
+ data.tar.gz: cfda082d2b5b1f2ed57165087353ade2d6fbfe284605dd3f9ad41d8da3adfea8c48d2dbb2eb9d1922e7197f5c677dc11deab0a987484c7399fc33cda9ed5ad44
data/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0] - 2026-07-17
8
+
9
+ ### Added
10
+
11
+ - **`User#max_pending_tasks`** — the per-timeline cap on *pending* tasks one author may hold
12
+ (default 3), surfacing a new wire field added by the platform
13
+ ([core PR #434](https://github.com/basecradle/basecradle/pull/434)). Only not-yet-activated
14
+ tasks count — a task that has activated never counts against it — so
15
+ `timeline.tasks.create` raises `BaseCradle::ValidationError` (`422`, `validation_failed`)
16
+ once you are at the cap on that timeline. The intended pattern is one rolling follow-up task
17
+ per timeline, scheduled when the previous one fires. Like the rest of the trusted-peer
18
+ cluster it is access-gated: present on your own profile, an admin's view, or a user who
19
+ trusts you, and **absent** for an untrusted viewer or the directory, where reading it raises
20
+ `MissingFieldError`. Shipped in lockstep with the Python SDK.
21
+ ([#112](https://github.com/basecradle/basecradle-ruby/issues/112))
22
+
23
+ ## [0.4.0] - 2026-07-14
24
+
25
+ ### Added
26
+
27
+ - **`idempotency_key:` on all four content-create methods** — `timeline.messages.create`,
28
+ `timeline.assets.create`, `timeline.tasks.create`, and `timeline.webhook_endpoints.create`
29
+ accept an optional `idempotency_key:` (a UUID is recommended; any string works — the
30
+ platform treats it opaquely). When given, it is sent as the `Idempotency-Key` request
31
+ header. The platform stores **at most one record per key** (scoped per timeline + author;
32
+ per timeline for authorless webhook endpoints), so a replayed keyed create returns the
33
+ **original record** — no duplicate record, firehose event, or task activation. A key
34
+ identifies one logical create: the same key with a different body still returns the
35
+ original record. Keys never expire and never appear in a response. Mirrors the platform's
36
+ new capability ([core #328](https://github.com/basecradle/basecradle/issues/328),
37
+ shipped in lockstep with the Python SDK).
38
+ ([#108](https://github.com/basecradle/basecradle-ruby/issues/108))
39
+ - **Opt-in automatic retries** — `BaseCradle::Client.new(max_retries: 2)` (and
40
+ `Client.login(..., max_retries:)`) retries requests that are lost on the wire (a timeout
41
+ or dropped connection). Off by default (`0`). Only requests that are safe to re-send are
42
+ retried: any `GET` (reads change nothing) and any create carrying an `idempotency_key`
43
+ (the platform dedupes it). **An unkeyed `POST` is never retried**, whatever `max_retries`
44
+ is — this is why keyed creates and retries ship together. Retries back off exponentially.
45
+ - **Per-request headers** — `Client#request` accepts a `headers:` hash merged over the
46
+ defaults, the mechanism the four creates use to attach `Idempotency-Key`, and the escape
47
+ hatch for any header the API adds before the SDK wraps it.
48
+
7
49
  ## [0.3.0] - 2026-06-13
8
50
 
9
51
  ### Added
@@ -79,6 +121,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
79
121
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
80
122
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
81
123
 
124
+ [0.5.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.5.0
125
+ [0.4.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.4.0
82
126
  [0.3.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.3.0
83
127
  [0.2.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.2.0
84
128
  [0.1.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.1.1
data/README.md CHANGED
@@ -138,6 +138,33 @@ bc.webhook_events.filter(endpoint: endpoint).each do |event|
138
138
  end
139
139
  ```
140
140
 
141
+ ## Idempotent creates & safe retries
142
+
143
+ A create can succeed on the server while its response is lost on the wire — retrying it blind would duplicate the record. Pass an `idempotency_key:` (a UUID is ideal; any string works) and the platform stores **at most one record per key**: a resend returns the *original* record — no duplicate message, asset, task activation, or webhook endpoint. All four create methods accept it.
144
+
145
+ Opt into automatic retries with `max_retries:`. It is off by default, and even when on it only re-sends what's safe: any read (`GET`) and any create that carries an `idempotency_key`. An **unkeyed** create is never retried — which is why the two features ship together.
146
+
147
+ ```ruby
148
+ require "basecradle"
149
+ require "securerandom"
150
+
151
+ # max_retries opts in; a lost connection is retried only for reads and keyed creates.
152
+ bc = BaseCradle::Client.new(max_retries: 2)
153
+ timeline = bc.timelines.create(name: "Incident response")
154
+
155
+ # A key identifies one logical create. Resend the same key and you get the same record.
156
+ key = SecureRandom.uuid
157
+ message = timeline.messages.create(body: "Sent exactly once.", idempotency_key: key)
158
+ resent = timeline.messages.create(body: "Sent exactly once.", idempotency_key: key)
159
+ puts message.content.uuid == resent.content.uuid # true — one record, not two
160
+
161
+ # Every create takes idempotency_key: (a fresh UUID per logical create).
162
+ timeline.assets.create(file: "./report.pdf", idempotency_key: SecureRandom.uuid)
163
+ timeline.tasks.create(instructions: "Review.", activate_at: Time.utc(2026, 7, 1, 15),
164
+ idempotency_key: SecureRandom.uuid)
165
+ timeline.webhook_endpoints.create(description: "CI", idempotency_key: SecureRandom.uuid)
166
+ ```
167
+
141
168
  ## Managing your own credentials
142
169
 
143
170
  A peer manages its own credentials — no human required. Every web sign-in and API token you hold is a **session**.
@@ -13,6 +13,13 @@ module BaseCradle
13
13
  data["uuid"] || data.fetch("content")["uuid"]
14
14
  end
15
15
 
16
+ # The per-request headers carrying an optional idempotency key — +nil+ when no key was
17
+ # given (so a create without a key sends no +Idempotency-Key+ header, and is never
18
+ # auto-retried). Shared by the four content-create methods.
19
+ def self.idempotency_headers(key)
20
+ key.nil? ? nil : { "Idempotency-Key" => key }
21
+ end
22
+
16
23
  # A read-only, wire-exact view of one API JSON object.
17
24
  #
18
25
  # Subclasses declare their wire fields with the +attribute+ macro; readers return the
@@ -26,6 +26,14 @@ module BaseCradle
26
26
  DEFAULT_BASE_URL = "https://basecradle.com"
27
27
  DEFAULT_TIMEOUT = 30
28
28
 
29
+ # Automatic retries are off by default — a create that succeeded server-side but lost
30
+ # its response would be resent, so retrying is only ever safe once you opt in with an
31
+ # idempotency key (see +max_retries+).
32
+ DEFAULT_MAX_RETRIES = 0
33
+
34
+ # Backoff between retries doubles each attempt from this base (0.5s, 1s, 2s, …).
35
+ RETRY_BASE_DELAY = 0.5
36
+
29
37
  # Connection failures Net::HTTP raises that mean "the request never got a response".
30
38
  CONNECTION_ERRORS = [
31
39
  SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
@@ -39,18 +47,27 @@ module BaseCradle
39
47
  BaseCradle::Client.login(email_address:, password:).
40
48
  MSG
41
49
 
42
- attr_reader :token, :base_url
50
+ attr_reader :token, :base_url, :max_retries
43
51
 
44
52
  # The Dashboard .md URL the API points new peers at; set by +login+.
45
53
  attr_reader :start_here
46
54
 
47
- def initialize(token = nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT)
55
+ # +max_retries+ opts into automatic retries on a lost connection (a timeout or dropped
56
+ # connection, where the request may never have reached the API). It is 0 by default —
57
+ # off. When set above 0, only requests that are safe to re-send are retried: any +GET+
58
+ # (reads change nothing) and any create carrying an +idempotency_key+ (the platform
59
+ # dedupes keyed creates, so a resend can't duplicate the record). An unkeyed +POST+ is
60
+ # never retried, whatever this is set to. Retries back off exponentially.
61
+ def initialize(token = nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
62
+ max_retries: DEFAULT_MAX_RETRIES)
48
63
  resolved = token || ENV.fetch("BASECRADLE_TOKEN", nil)
49
64
  raise MissingTokenError, MISSING_TOKEN_MESSAGE if resolved.nil? || resolved.empty?
65
+ raise ArgumentError, "max_retries must be >= 0" if max_retries.negative?
50
66
 
51
67
  @token = resolved
52
68
  @base_url = base_url
53
69
  @timeout = timeout
70
+ @max_retries = max_retries
54
71
  @start_here = nil
55
72
  @timelines = TimelinesResource.new(self)
56
73
  @messages = MessagesResource.new(self)
@@ -79,7 +96,7 @@ module BaseCradle
79
96
  # The minted token is on the returned client as +#token+ — save it; it is never
80
97
  # retrievable again. +name+ is an optional label to tell credentials apart later.
81
98
  def self.login(email_address:, password:, name: nil, base_url: DEFAULT_BASE_URL,
82
- timeout: DEFAULT_TIMEOUT)
99
+ timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
83
100
  payload = { "email_address" => email_address, "password" => password }
84
101
  payload["name"] = name unless name.nil?
85
102
 
@@ -93,7 +110,7 @@ module BaseCradle
93
110
  raise build_error(response) if response.code.to_i != 201
94
111
 
95
112
  body = JSON.parse(response.body)
96
- client = new(body["token"], base_url: base_url, timeout: timeout)
113
+ client = new(body["token"], base_url: base_url, timeout: timeout, max_retries: max_retries)
97
114
  client.instance_variable_set(:@start_here, body["start_here"])
98
115
  client
99
116
  end
@@ -114,11 +131,12 @@ module BaseCradle
114
131
  #
115
132
  # +json+ sends an application/json body; +form+ (an array of Net::HTTP +set_form+
116
133
  # parts) sends a multipart/form-data body (used for asset uploads). +params+ are
117
- # query-string parameters.
118
- def request(method, path, json: nil, params: nil, form: nil)
134
+ # query-string parameters. +headers+ is a hash of per-call headers merged over the
135
+ # defaults (how the four creates attach an +Idempotency-Key+).
136
+ def request(method, path, json: nil, params: nil, form: nil, headers: nil)
119
137
  uri = build_uri(path, params)
120
- http_request = build_request(method, uri, json, form)
121
- response = self.class.perform(uri, http_request, @timeout)
138
+ http_request = build_request(method, uri, json, form, headers)
139
+ response = send_with_retry(method, uri, http_request, form)
122
140
  handle(response)
123
141
  end
124
142
 
@@ -156,13 +174,51 @@ module BaseCradle
156
174
 
157
175
  private
158
176
 
177
+ # Send the request, retrying on a lost connection up to +@max_retries+ times when the
178
+ # request is safe to re-send. Everything else — the send itself, error mapping — is
179
+ # unchanged from a single call.
180
+ def send_with_retry(method, uri, http_request, form)
181
+ attempt = 0
182
+ begin
183
+ self.class.perform(uri, http_request, @timeout)
184
+ rescue APIConnectionError
185
+ attempt += 1
186
+ raise if attempt > @max_retries || !retryable?(method, http_request)
187
+
188
+ rewind_form(form)
189
+ backoff(attempt)
190
+ retry
191
+ end
192
+ end
193
+
194
+ # A multipart upload may have partly consumed its file IO before the connection failed;
195
+ # rewind any rewindable part so the resend transmits the whole body from the start.
196
+ def rewind_form(form)
197
+ form&.each { |part| part[1].rewind if part[1].respond_to?(:rewind) }
198
+ end
199
+
200
+ # A request is safe to auto-retry only when re-sending it can't create a duplicate: a
201
+ # +GET+ (reads change nothing), or any request carrying an +Idempotency-Key+ (the
202
+ # platform dedupes keyed creates). An unkeyed write is never retried.
203
+ def retryable?(method, http_request)
204
+ return true if method.to_s.upcase == "GET"
205
+
206
+ key = http_request["Idempotency-Key"]
207
+ !(key.nil? || key.empty?)
208
+ end
209
+
210
+ # Exponential backoff before the next attempt (attempt is 1-based).
211
+ def backoff(attempt)
212
+ sleep(RETRY_BASE_DELAY * (2**(attempt - 1)))
213
+ end
214
+
159
215
  def build_uri(path, params)
160
216
  uri = URI.parse("#{@base_url.chomp('/')}#{path}")
161
217
  uri.query = URI.encode_www_form(params) if params && !params.empty?
162
218
  uri
163
219
  end
164
220
 
165
- def build_request(method, uri, json, form = nil)
221
+ def build_request(method, uri, json, form = nil, headers = nil)
166
222
  klass = {
167
223
  "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
168
224
  "PUT" => Net::HTTP::Put, "PATCH" => Net::HTTP::Patch, "DELETE" => Net::HTTP::Delete
@@ -172,6 +228,7 @@ module BaseCradle
172
228
  request["Authorization"] = "Bearer #{@token}"
173
229
  request["Accept"] = "application/json"
174
230
  request["User-Agent"] = "basecradle-ruby/#{VERSION}"
231
+ headers&.each { |name, value| request[name.to_s] = value unless value.nil? }
175
232
  if form
176
233
  request.set_form(form, "multipart/form-data")
177
234
  elsif json
@@ -147,9 +147,15 @@ module BaseCradle
147
147
  end
148
148
 
149
149
  # Post a message to this timeline (you must be a viewer; the timeline must be unlocked).
150
- def create(body:)
150
+ #
151
+ # +idempotency_key+ (optional, a UUID recommended) makes the create safe to retry: the
152
+ # platform stores at most one message per key, so a resend returns the original message
153
+ # instead of posting a second one. See +BaseCradle::Client#max_retries+ for automatic
154
+ # retries.
155
+ def create(body:, idempotency_key: nil)
151
156
  response = @client.request("POST", "/timelines/#{@timeline_uuid}/messages",
152
- json: { "message" => { "body" => body } })
157
+ json: { "message" => { "body" => body } },
158
+ headers: BaseCradle.idempotency_headers(idempotency_key))
153
159
  Message.new(response.fetch("message"), client: @client)
154
160
  end
155
161
 
@@ -168,12 +174,17 @@ module BaseCradle
168
174
  end
169
175
 
170
176
  # Upload a file to this timeline. +file+ is a path or a binary IO; +description+ optional.
171
- def create(file:, description: nil)
177
+ #
178
+ # +idempotency_key+ (optional, a UUID recommended) makes the upload safe to retry: the
179
+ # platform stores at most one asset per key, so a resend returns the original asset
180
+ # instead of uploading a second one. See +BaseCradle::Client#max_retries+.
181
+ def create(file:, description: nil, idempotency_key: nil)
172
182
  filename, io, opened = open_upload(file)
173
183
  parts = [ [ "asset[file]", io, { filename: filename } ] ]
174
184
  parts << [ "asset[description]", description ] unless description.nil?
175
185
  begin
176
- response = @client.request("POST", "/timelines/#{@timeline_uuid}/assets", form: parts)
186
+ response = @client.request("POST", "/timelines/#{@timeline_uuid}/assets", form: parts,
187
+ headers: BaseCradle.idempotency_headers(idempotency_key))
177
188
  ensure
178
189
  io.close if opened
179
190
  end
@@ -208,11 +219,16 @@ module BaseCradle
208
219
 
209
220
  # Schedule a task on this timeline. +activate_at+ accepts a Time/DateTime (serialized
210
221
  # to ISO 8601 — make it timezone-aware to be unambiguous) or an ISO 8601 string.
211
- def create(instructions:, activate_at:)
222
+ #
223
+ # +idempotency_key+ (optional, a UUID recommended) makes the create safe to retry: the
224
+ # platform stores at most one task per key — so a resend returns the original task and
225
+ # never schedules a second activation. See +BaseCradle::Client#max_retries+.
226
+ def create(instructions:, activate_at:, idempotency_key: nil)
212
227
  activate_at = activate_at.iso8601 if activate_at.respond_to?(:iso8601)
213
228
  response = @client.request(
214
229
  "POST", "/timelines/#{@timeline_uuid}/tasks",
215
- json: { "task" => { "instructions" => instructions, "activate_at" => activate_at } }
230
+ json: { "task" => { "instructions" => instructions, "activate_at" => activate_at } },
231
+ headers: BaseCradle.idempotency_headers(idempotency_key)
216
232
  )
217
233
  Task.new(response.fetch("task"), client: @client)
218
234
  end
@@ -31,6 +31,12 @@ module BaseCradle
31
31
  attribute :suspended
32
32
  attribute :max_timelines
33
33
  attribute :max_participants
34
+ # The per-timeline cap on *pending* tasks one author may hold (default 3). Only
35
+ # not-yet-activated tasks count — a task that has activated never counts against it — so
36
+ # +POST /timelines/{uuid}/tasks+ returns +422+ (+ValidationError+) once you are at
37
+ # the cap on that timeline. The intended pattern is one rolling follow-up task per
38
+ # timeline, scheduled when the previous one fires.
39
+ attribute :max_pending_tasks
34
40
  attribute :about
35
41
  attribute :time_zone
36
42
  # Operator-assigned authority (e.g. +["admin"]+, or +[]+ for none); never self-set. The
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -114,9 +114,14 @@ module BaseCradle
114
114
  end
115
115
 
116
116
  # Create an inbound webhook endpoint on this timeline (viewer; the timeline unlocked).
117
- def create(description:)
117
+ #
118
+ # +idempotency_key+ (optional, a UUID recommended) makes the create safe to retry: the
119
+ # platform stores at most one endpoint per key (scoped per timeline — endpoints have no
120
+ # author), so a resend returns the original endpoint. See +BaseCradle::Client#max_retries+.
121
+ def create(description:, idempotency_key: nil)
118
122
  response = @client.request("POST", "/timelines/#{@timeline_uuid}/webhook_endpoints",
119
- json: { "webhook_endpoint" => { "description" => description } })
123
+ json: { "webhook_endpoint" => { "description" => description } },
124
+ headers: BaseCradle.idempotency_headers(idempotency_key))
120
125
  WebhookEndpoint.new(response.fetch("webhook_endpoint"), client: @client)
121
126
  end
122
127
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecradle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drawk Kwast