strongmind-platform-sdk 3.33.4 → 3.33.5

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: 80e27a2c045cd72bc8633ded3fc6505f5c2a029158bb67b120ed6b39b31c3438
4
- data.tar.gz: 9941c3cde4236922d326beb90cfef6847568fb1b996eb55bc999f86217672385
3
+ metadata.gz: fe3ec6a94953018e636a9fc5bab7a7f66791db9d15aeed14035c1a444980f9a6
4
+ data.tar.gz: 678c3fc1a62d418838ab25d1fd8bafeea342a58a986bcd1a1ec3e5d6d087e6dc
5
5
  SHA512:
6
- metadata.gz: d991813ce02b2207fc72c523930d4dbe827d05e644be07d6868caa562be89936ec71a305bf275c1c378571005775cbcf584784bb2fbc57a806ccc236e438caac
7
- data.tar.gz: c29637d88a1e720243e39a556195e89836c1346d1fc2a0e0de2d5c88a0d286c1a5bdc8096fea2be727dd2b0777f4e4fb6d32c37a3130c8ccf3b0063fbaef5588
6
+ metadata.gz: 83d9ffbf4ee13769ae6ffb5d1346e8a37cbd6d1cfc511be1ef0d87a63863191bccc12b9a51cfdce4a4732ec70e9c35580018677e15aca638d3e9629cfc89cb37
7
+ data.tar.gz: 67bb2e2e837402cb15fdedab566a343111f17204795eafc1e149fa19976d53f277ab924f0d247877c76570996e18db6f125e444df27f162b05928342ce537570
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Bound `Client::DEFAULT_RETRY_OPTIONS` with `max_interval: 10`. faraday-retry defaults `max_interval` to `Float::MAX`, so a `Retry-After` header (Canvas doesn't send one today, but could) would otherwise block a single request for as long as that header said. Retries also now log a warning before each sleep via a default `retry_block`, so a sustained throttle shows up in logs rather than only as request latency; callers passing their own `retry_options: { retry_block: ... }` still override it.
4
+ - Fix `Client#page_links` returning `{}` -- read as "no more pages" -- when the `Link` header is present but fails to parse, which silently truncated a listing to its first page instead of raising. Only a genuinely absent header is now treated as unpaginated; a present-but-malformed header raises `CanvasClientError`.
5
+ - Fix the page-1 error branch of every listing/read/write method assuming a non-2xx Canvas response is JSON. Canvas's own throttle (`RequestThrottle`, Rack middleware ahead of the Rails app) returns a plain-text `429` body, which raised `MultiJson::ParseError` once retries were exhausted instead of the descriptive error each method is meant to raise. Extracted into `error_messages_from`, which falls back to the raw body when it isn't JSON, collapsing 27 duplicated copies of the same parsing block in the process. Nine of those methods (`put_relock_module`, `put_module_prerequesites`, `put_recompute_module_prerequisites`, `create_module`, `update_module`, `post_module_item`, `put_publish_module_item`, `delete_module_item`, `retrieve_discussion_topic`) additionally parsed the body *before* checking `success?`, so the `ParseError` fired ahead of the error branch entirely; they now parse only on success. The now-unused `parse_response_errors` helper is removed.
6
+ - Fix `client.rb` raising `NameError: uninitialized constant Faraday::RetriableResponse` when required directly (e.g. `require "platform_sdk/canvas_api/client"`) rather than through `canvas_api.rb`. `faraday/retry`, which defines the constant `DEFAULT_RETRY_OPTIONS` references at load time, is now required alongside `faraday` in the file that uses it.
7
+ - Fix `handle_rate_limiting` treating Canvas's `X-Rate-Limit-Remaining` as an integer via `to_i` truncation -- a fractional value like `"0.75"` (quota still available) was read as `0` and slept a second anyway. The header is now parsed as a float, so only a value at or below zero throttles.
8
+ - Add the pre-emptive `handle_rate_limiting` check to `update_submission_excused_with_comment`, the one write endpoint that had been skipping it.
9
+ - Add wire-level specs asserting `post_module_item` sends exactly one request under a `429` (POST is excluded from retry), that a form-encoded PUT body lands the way Canvas expects, and that a rate-limited later page actually retries (rather than merely raising) before giving up.
10
+ - Fix `CanvasApiWrapper::Client` still raising `NoMethodError: undefined method 'split' for nil:NilClass` from `page_links` when Canvas throttles a page *after* the first. The `success?` fix above guards only the initial response of each request; `paginated_items` handed every subsequent page straight to `page_links`, which needs the `Link` header a `429` does not carry. Pages are now fetched through `fetch_page`, which status-checks each one and raises `CanvasClientError` with the response body instead.
11
+ - Retry throttled Canvas requests instead of only failing more legibly. The connection installs `faraday-retry` with `retry_statuses: [429]`, 3 attempts and exponential backoff (1s, 2s, 4s, jittered), so a rate-limited call now recovers on its own rather than surfacing to the caller. Retries are scoped to Faraday's idempotent methods — POST is excluded, so a throttled `post_module_item` can never be replayed into a duplicate item. Defaults live in `Client::DEFAULT_RETRY_OPTIONS` and can be overridden per client with `Client.new(domain:, token:, retry_options: {...})`.
12
+ - Fix `handle_rate_limiting` sleeping a second on every response that carries no `X-Rate-Limit-Remaining` header. `nil.to_i` is `0`, which the "bucket is spent" check could not tell apart from a genuinely exhausted quota, so the client throttled itself against responses Canvas had said nothing about. An absent header is now distinguished from a zero quota. (This was not merely theoretical: the Canvas client's own spec file spent 39s of its 40s runtime asleep, and now runs in 0.25s.)
13
+ - The Canvas connection now declares `url_encoded` explicitly. Faraday installs it by default *only* when no block is given, and adding the retry middleware requires a block — several endpoints here assign a Hash to `req.body` for form-encoded Canvas APIs and depend on that middleware to serialize it.
14
+
3
15
  - Fix `CanvasApiWrapper::Client#success?` treating some non-2xx responses as successful. The check used an unanchored `/2[0-9]/` match, so `429` matched on its `"29"` substring and a rate-limited response took the success branch, then raised `NoMethodError: undefined method 'split' for nil:NilClass` in `page_links` — `429` responses carry no `Link` header. Now anchored to `(200..299)`, so these fall through to the error branch and raise a descriptive message. Also corrects `422` and the other `3xx`/`4xx`/`5xx` codes matching the same pattern.
4
16
 
5
17
  ## [3.33.0] - 2026-07-28
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "faraday"
4
+ require "faraday/retry"
4
5
  require "platform_sdk/version"
5
6
 
6
7
  module PlatformSdk
@@ -11,18 +12,63 @@ module PlatformSdk
11
12
 
12
13
  PAGE_SIZE = 10
13
14
 
15
+ # Retries are scoped to Canvas's 429 throttle and nothing else.
16
+ #
17
+ # `retry_statuses` is implemented by raising the synthetic
18
+ # `Faraday::RetriableResponse`, so that class has to stay in
19
+ # `exceptions` for a 429 to be retried at all -- an empty list would
20
+ # let it escape to the caller instead. Naming it as the only entry is
21
+ # what drops faraday-retry's default `Errno::ETIMEDOUT`,
22
+ # `Timeout::Error` and `Faraday::TimeoutError`: a timed-out request may
23
+ # already have been applied by Canvas, and replaying it is not safe for
24
+ # every endpoint here. `update_submission_excused_with_comment` PUTs a
25
+ # `submission_comments` entry, which Canvas appends rather than
26
+ # replaces, so a replayed timeout would leave the student duplicate
27
+ # comments.
28
+ #
29
+ # `methods` restates faraday-retry's own IDEMPOTENT_METHODS so the
30
+ # exclusion is visible at the call site: POST is left out because a
31
+ # retry could re-create a module item. PUT is safe on the remaining 429
32
+ # path because Canvas rejects a throttled request before applying it,
33
+ # so the replay is the first time the write lands.
34
+ #
35
+ # The backoff is deliberately short: Canvas refills the rate-limit
36
+ # bucket continuously, so max: 3 with interval: 1 and
37
+ # backoff_factor: 2 spends about 1.0-1.5s, 2.0-2.5s and 4.0-4.5s (the
38
+ # randomness is additive, not a +/- percentage of the interval) before
39
+ # giving up -- that's 3 retries, 4 attempts total. Callers that need a
40
+ # different budget pass `retry_options:` to the constructor.
41
+ #
42
+ # `retry_statuses: [429]` assumes the hosted Canvas instance has
43
+ # `request_throttle.send_429_response` enabled; without it Canvas's
44
+ # `RequestThrottle` returns 403 instead, which won't retry and surfaces
45
+ # as `LockedModuleItemError` on the write endpoints below.
46
+ #
47
+ # `max_interval` bounds both the exponential backoff and a `Retry-After`
48
+ # header Canvas might one day send -- without it, a single throttled
49
+ # request could block for as long as that header says (faraday-retry
50
+ # defaults `max_interval` to `Float::MAX`).
51
+ DEFAULT_RETRY_OPTIONS = {
52
+ max: 3,
53
+ interval: 1,
54
+ interval_randomness: 0.5,
55
+ backoff_factor: 2,
56
+ max_interval: 10,
57
+ retry_statuses: [429],
58
+ exceptions: [Faraday::RetriableResponse],
59
+ methods: %i[delete get head options put]
60
+ }.freeze
61
+
14
62
  # @param domain [String]
15
63
  # @param token [String]
16
- def initialize(domain:, token:)
64
+ # @param retry_options [Hash] overrides merged onto DEFAULT_RETRY_OPTIONS
65
+ def initialize(domain:, token:, retry_options: {})
17
66
  @host = "https://#{domain}"
18
67
  @token = token
19
- @connection = Faraday.new(
20
- url: host,
21
- headers: {
22
- 'Authorization' => "Bearer #{token}",
23
- 'User-Agent' => "StrongMind-PlatformSDK/#{PlatformSdk::VERSION}"
24
- }
25
- )
68
+ @retry_options = DEFAULT_RETRY_OPTIONS
69
+ .merge(retry_block: method(:log_rate_limit_retry))
70
+ .merge(retry_options)
71
+ @connection = build_connection
26
72
  end
27
73
 
28
74
  # @param course_id [Integer]
@@ -33,12 +79,7 @@ module PlatformSdk
33
79
  if success?(response.status)
34
80
  paginated_items headers: response.headers, initial_response: response.body
35
81
  else
36
- result = MultiJson.load response.body, symbolize_keys: true
37
- error_messages = String.new
38
- result[:errors].each do |error|
39
- error_messages << "#{error[:message]} "
40
- end
41
- raise "Error getting course modules: #{error_messages}"
82
+ raise "Error getting course modules: #{error_messages_from(response.body)}"
42
83
  end
43
84
  end
44
85
 
@@ -48,12 +89,7 @@ module PlatformSdk
48
89
  if success?(response.status)
49
90
  paginated_items headers: response.headers, initial_response: response.body
50
91
  else
51
- result = MultiJson.load response.body, symbolize_keys: true
52
- error_messages = String.new
53
- result[:errors].each do |error|
54
- error_messages << "#{error[:message]} "
55
- end
56
- raise "Error getting course modules: #{error_messages}"
92
+ raise "Error getting course modules: #{error_messages_from(response.body)}"
57
93
  end
58
94
  end
59
95
 
@@ -66,12 +102,7 @@ module PlatformSdk
66
102
  if success?(response.status)
67
103
  paginated_items headers: response.headers, initial_response: response.body
68
104
  else
69
- result = MultiJson.load response.body, symbolize_keys: true
70
- error_messages = String.new
71
- result[:errors].each do |error|
72
- error_messages << "#{error[:message]} "
73
- end
74
- raise "Error getting module items: #{error_messages}"
105
+ raise "Error getting module items: #{error_messages_from(response.body)}"
75
106
  end
76
107
  end
77
108
 
@@ -86,12 +117,7 @@ module PlatformSdk
86
117
  if success?(response.status)
87
118
  paginated_items headers: response.headers, initial_response: response.body
88
119
  else
89
- result = MultiJson.load response.body, symbolize_keys: true
90
- error_messages = String.new
91
- result[:errors].each do |error|
92
- error_messages << "#{error[:message]} "
93
- end
94
- raise "Error getting module items: #{error_messages}"
120
+ raise "Error getting module items: #{error_messages_from(response.body)}"
95
121
  end
96
122
  end
97
123
 
@@ -105,12 +131,7 @@ module PlatformSdk
105
131
  assignments = paginated_items headers: response.headers, initial_response: response.body
106
132
  assignments.select { |item| item[:type] == 'Assignment' }
107
133
  else
108
- result = MultiJson.load response.body, symbolize_keys: true
109
- error_messages = String.new
110
- result[:errors].each do |error|
111
- error_messages << "#{error[:message]} "
112
- end
113
- raise "Error getting assignments: #{error_messages}"
134
+ raise "Error getting assignments: #{error_messages_from(response.body)}"
114
135
  end
115
136
  end
116
137
 
@@ -122,12 +143,7 @@ module PlatformSdk
122
143
  if success?(response.status)
123
144
  MultiJson.load response.body, symbolize_keys: true
124
145
  else
125
- result = MultiJson.load response.body, symbolize_keys: true
126
- error_messages = String.new
127
- result[:errors].each do |error|
128
- error_messages << "#{error[:message]} "
129
- end
130
- raise "Error getting quiz submissions#{error_messages}"
146
+ raise "Error getting quiz submissions#{error_messages_from(response.body)}"
131
147
  end
132
148
  end
133
149
 
@@ -140,12 +156,7 @@ module PlatformSdk
140
156
  if success?(response.status)
141
157
  MultiJson.load response.body, symbolize_keys: true
142
158
  else
143
- result = MultiJson.load response.body, symbolize_keys: true
144
- error_messages = String.new
145
- result[:errors].each do |error|
146
- error_messages << "#{error[:message]} "
147
- end
148
- raise "Error getting module item: #{error_messages}"
159
+ raise "Error getting module item: #{error_messages_from(response.body)}"
149
160
  end
150
161
  end
151
162
 
@@ -155,12 +166,7 @@ module PlatformSdk
155
166
  if success?(response.status)
156
167
  MultiJson.load response.body, symbolize_keys: true
157
168
  else
158
- result = MultiJson.load response.body, symbolize_keys: true
159
- error_messages = String.new
160
- result[:errors].each do |error|
161
- error_messages << "#{error[:message]} "
162
- end
163
- raise "Error getting module item: #{error_messages}"
169
+ raise "Error getting module item: #{error_messages_from(response.body)}"
164
170
  end
165
171
  end
166
172
 
@@ -170,12 +176,7 @@ module PlatformSdk
170
176
  if success?(response.status)
171
177
  MultiJson.load response.body, symbolize_keys: true
172
178
  else
173
- result = MultiJson.load response.body, symbolize_keys: true
174
- error_messages = String.new
175
- result[:errors].each do |error|
176
- error_messages << "#{error[:message]} "
177
- end
178
- raise "Error getting module item: #{error_messages}"
179
+ raise "Error getting module item: #{error_messages_from(response.body)}"
179
180
  end
180
181
  end
181
182
 
@@ -187,12 +188,7 @@ module PlatformSdk
187
188
  if success?(response.status)
188
189
  paginated_items headers: response.headers, initial_response: response.body
189
190
  else
190
- result = MultiJson.load response.body, symbolize_keys: true
191
- error_messages = String.new
192
- result[:errors].each do |error|
193
- error_messages << "#{error[:message]} "
194
- end
195
- raise "Error getting submissions: #{error_messages}"
191
+ raise "Error getting submissions: #{error_messages_from(response.body)}"
196
192
  end
197
193
  end
198
194
 
@@ -210,12 +206,7 @@ module PlatformSdk
210
206
  if success?(response.status)
211
207
  MultiJson.load response.body, symbolize_keys: true
212
208
  else
213
- result = MultiJson.load response.body, symbolize_keys: true
214
- error_messages = String.new
215
- result[:errors].each do |error|
216
- error_messages << "#{error[:message]} "
217
- end
218
- raise "Error update submissions to excused: #{error_messages}"
209
+ raise "Error update submissions to excused: #{error_messages_from(response.body)}"
219
210
  end
220
211
  end
221
212
 
@@ -232,15 +223,11 @@ module PlatformSdk
232
223
  }
233
224
  }.to_json
234
225
  end
226
+ handle_rate_limiting response
235
227
  if success?(response.status)
236
228
  MultiJson.load response.body, symbolize_keys: true
237
229
  else
238
- result = MultiJson.load response.body, symbolize_keys: true
239
- error_messages = String.new
240
- result[:errors].each do |error|
241
- error_messages << "#{error[:message]} "
242
- end
243
- raise "Error update submissions to excused: #{error_messages}"
230
+ raise "Error update submissions to excused: #{error_messages_from(response.body)}"
244
231
  end
245
232
  end
246
233
 
@@ -253,12 +240,7 @@ module PlatformSdk
253
240
  if success?(response.status)
254
241
  paginated_items headers: response.headers, initial_response: response.body
255
242
  else
256
- result = MultiJson.load response.body, symbolize_keys: true
257
- error_messages = String.new
258
- result[:errors].each do |error|
259
- error_messages << "#{error[:message]} "
260
- end
261
- raise "Error getting course students: #{error_messages}"
243
+ raise "Error getting course students: #{error_messages_from(response.body)}"
262
244
  end
263
245
  end
264
246
 
@@ -271,12 +253,7 @@ module PlatformSdk
271
253
  if success?(response.status)
272
254
  MultiJson.load response.body, symbolize_keys: true
273
255
  else
274
- result = MultiJson.load response.body, symbolize_keys: true
275
- error_messages = String.new
276
- result[:errors].each do |error|
277
- error_messages << "#{error[:message]} "
278
- end
279
- raise "Error getting test students: #{error_messages}"
256
+ raise "Error getting test students: #{error_messages_from(response.body)}"
280
257
  end
281
258
  end
282
259
 
@@ -288,12 +265,7 @@ module PlatformSdk
288
265
  if success?(response.status)
289
266
  paginated_items headers: response.headers, initial_response: response.body
290
267
  else
291
- result = MultiJson.load response.body, symbolize_keys: true
292
- error_messages = String.new
293
- result[:errors].each do |error|
294
- error_messages << "#{error[:message]} "
295
- end
296
- raise "Error getting course users: #{error_messages}"
268
+ raise "Error getting course users: #{error_messages_from(response.body)}"
297
269
  end
298
270
  end
299
271
 
@@ -306,12 +278,7 @@ module PlatformSdk
306
278
  if success?(response.status)
307
279
  MultiJson.load response.body, symbolize_keys: true
308
280
  else
309
- result = MultiJson.load response.body, symbolize_keys: true
310
- error_messages = String.new
311
- result[:errors].each do |error|
312
- error_messages << "#{error[:message]} "
313
- end
314
- raise "Error getting quiz: #{error_messages}"
281
+ raise "Error getting quiz: #{error_messages_from(response.body)}"
315
282
  end
316
283
  end
317
284
 
@@ -326,12 +293,7 @@ module PlatformSdk
326
293
  if success?(response.status)
327
294
  paginated_items headers: response.headers, initial_response: response.body
328
295
  else
329
- result = MultiJson.load response.body, symbolize_keys: true
330
- error_messages = String.new
331
- result[:errors].each do |error|
332
- error_messages << "#{error[:message]} "
333
- end
334
- raise "Error getting quiz submissions#{error_messages}"
296
+ raise "Error getting quiz submissions#{error_messages_from(response.body)}"
335
297
  end
336
298
  end
337
299
 
@@ -411,12 +373,7 @@ module PlatformSdk
411
373
  if success?(response.status)
412
374
  MultiJson.load response.body, symbolize_keys: true
413
375
  else
414
- result = MultiJson.load response.body, symbolize_keys: true
415
- error_messages = String.new
416
- result[:errors]&.each do |error|
417
- error_messages << "#{error[:message]} "
418
- end
419
- raise "Error posting a reply: #{error_messages}"
376
+ raise "Error posting a reply: #{error_messages_from(response.body)}"
420
377
  end
421
378
  end
422
379
 
@@ -425,13 +382,11 @@ module PlatformSdk
425
382
  def put_relock_module(course_id:, module_id:)
426
383
  uri = "api/v1/courses/#{course_id}/modules/#{module_id}/relock"
427
384
  response = @connection.put(uri)
428
- result = MultiJson.load response.body, symbolize_keys: true
429
385
  handle_rate_limiting response
430
386
  if success?(response.status)
431
- result
387
+ MultiJson.load response.body, symbolize_keys: true
432
388
  else
433
- error_message = parse_response_errors(result:, message: 'Error re-locking module')
434
- raise CanvasClientError, error_message
389
+ raise CanvasClientError, "Error re-locking module: #{error_messages_from(response.body)}"
435
390
  end
436
391
  end
437
392
 
@@ -446,15 +401,10 @@ module PlatformSdk
446
401
  req.body = { 'module[prerequisite_module_ids]' => prerequisite_ids }
447
402
  end
448
403
  handle_rate_limiting response
449
- result = MultiJson.load response.body, symbolize_keys: true
450
404
  if success?(response.status)
451
- result
405
+ MultiJson.load response.body, symbolize_keys: true
452
406
  else
453
- error_messages = String.new
454
- result[:errors]&.each do |error|
455
- error_messages << "#{error[:message]} "
456
- end
457
- raise "Error updating module prerequisite: #{error_messages}"
407
+ raise "Error updating module prerequisite: #{error_messages_from(response.body)}"
458
408
  end
459
409
  end
460
410
 
@@ -466,15 +416,10 @@ module PlatformSdk
466
416
  req.body = { 'module[name]' => original_name }
467
417
  end
468
418
  handle_rate_limiting response
469
- result = MultiJson.load response.body, symbolize_keys: true
470
419
  if success?(response.status)
471
- result
420
+ MultiJson.load response.body, symbolize_keys: true
472
421
  else
473
- error_messages = String.new
474
- result[:errors]&.each do |error|
475
- error_messages << "#{error[:message]} "
476
- end
477
- raise "Error updating refreshing module prerequisite: #{error_messages}"
422
+ raise "Error updating refreshing module prerequisite: #{error_messages_from(response.body)}"
478
423
  end
479
424
  end
480
425
 
@@ -490,15 +435,10 @@ module PlatformSdk
490
435
  req.body = body
491
436
  end
492
437
  handle_rate_limiting response
493
- result = MultiJson.load response.body, symbolize_keys: true
494
438
  if success?(response.status)
495
- result
439
+ MultiJson.load response.body, symbolize_keys: true
496
440
  else
497
- error_messages = String.new
498
- result[:errors]&.each do |error|
499
- error_messages << "#{error[:message]} "
500
- end
501
- raise "Error creating module: #{error_messages}"
441
+ raise "Error creating module: #{error_messages_from(response.body)}"
502
442
  end
503
443
  end
504
444
 
@@ -518,15 +458,10 @@ module PlatformSdk
518
458
  req.body = body
519
459
  end
520
460
  handle_rate_limiting response
521
- result = MultiJson.load response.body, symbolize_keys: true
522
461
  if success?(response.status)
523
- result
462
+ MultiJson.load response.body, symbolize_keys: true
524
463
  else
525
- error_messages = String.new
526
- result[:errors]&.each do |error|
527
- error_messages << "#{error[:message]} "
528
- end
529
- raise "Error updating module: #{error_messages}"
464
+ raise "Error updating module: #{error_messages_from(response.body)}"
530
465
  end
531
466
  end
532
467
 
@@ -546,15 +481,10 @@ module PlatformSdk
546
481
  req.body = body
547
482
  end
548
483
  handle_rate_limiting response
549
- result = MultiJson.load response.body, symbolize_keys: true
550
484
  if success?(response.status)
551
- result
485
+ MultiJson.load response.body, symbolize_keys: true
552
486
  else
553
- error_messages = String.new
554
- result[:errors]&.each do |error|
555
- error_messages << "#{error[:message]} "
556
- end
557
- raise "Error creating module item: #{error_messages}"
487
+ raise "Error creating module item: #{error_messages_from(response.body)}"
558
488
  end
559
489
  end
560
490
 
@@ -568,15 +498,10 @@ module PlatformSdk
568
498
  req.body = { 'module_item[published]' => true }
569
499
  end
570
500
  handle_rate_limiting response
571
- result = MultiJson.load response.body, symbolize_keys: true
572
501
  if success?(response.status)
573
- result
502
+ MultiJson.load response.body, symbolize_keys: true
574
503
  else
575
- error_messages = String.new
576
- result[:errors]&.each do |error|
577
- error_messages << "#{error[:message]} "
578
- end
579
- raise "Error publishing module item: #{error_messages}"
504
+ raise "Error publishing module item: #{error_messages_from(response.body)}"
580
505
  end
581
506
  end
582
507
 
@@ -586,15 +511,10 @@ module PlatformSdk
586
511
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
587
512
  end
588
513
  handle_rate_limiting response
589
- result = MultiJson.load response.body, symbolize_keys: true
590
514
  if success?(response.status)
591
- result
515
+ MultiJson.load response.body, symbolize_keys: true
592
516
  else
593
- error_messages = String.new
594
- result[:errors]&.each do |error|
595
- error_messages << "#{error[:message]} "
596
- end
597
- raise "Error destroying module item: #{error_messages}"
517
+ raise "Error destroying module item: #{error_messages_from(response.body)}"
598
518
  end
599
519
  end
600
520
 
@@ -604,44 +524,90 @@ module PlatformSdk
604
524
  req.headers['Content-Type'] = 'application/json'
605
525
  end
606
526
  handle_rate_limiting response
607
- result = MultiJson.load response.body, symbolize_keys: true
608
527
  if success?(response.status)
609
- result
528
+ MultiJson.load response.body, symbolize_keys: true
610
529
  else
611
- error_messages = String.new
612
- result[:errors]&.each do |error|
613
- error_messages << "#{error[:message]} "
614
- end
615
- raise "Error retrieving discussion topic: #{error_messages}"
530
+ raise "Error retrieving discussion topic: #{error_messages_from(response.body)}"
616
531
  end
617
532
  end
618
533
 
619
534
  private
620
535
 
536
+ def default_headers
537
+ {
538
+ 'Authorization' => "Bearer #{token}",
539
+ 'User-Agent' => "StrongMind-PlatformSDK/#{PlatformSdk::VERSION}"
540
+ }
541
+ end
542
+
543
+ def build_connection
544
+ Faraday.new(url: host, headers: default_headers) do |f|
545
+ # Faraday installs UrlEncoded by default, but only when no block is
546
+ # given -- several endpoints here post a Hash body as form data and
547
+ # depend on it, so it has to be re-declared alongside the retry.
548
+ f.request :url_encoded
549
+ f.request :retry, @retry_options
550
+ f.adapter Faraday.default_adapter
551
+ end
552
+ end
553
+
554
+ # Walks `rel="next"` rather than comparing `rel="current"` to
555
+ # `rel="last"`. Canvas omits `last` when the total count is too
556
+ # expensive to compute, which left the old condition permanently true
557
+ # and crashed on the final page, where there is no `next` to follow.
558
+ # https://canvas.instructure.com/doc/api/file.pagination.html
559
+ #
621
560
  # @param headers [Faraday::Utils::Headers]
622
561
  # @return [Array]
623
562
  def paginated_items(headers:, initial_response:)
624
563
  pages = page_links(headers:)
625
564
  items = []
626
565
  items.append MultiJson.load(initial_response, symbolize_keys: true)
627
- while pages[:current] != pages[:last]
628
- response = @connection.get(pages[:next][:url])
629
- handle_rate_limiting response
566
+ while pages[:next]
567
+ response = fetch_page(pages[:next][:url])
630
568
  pages = page_links(headers: response.headers)
631
569
  items.append MultiJson.load(response.body, symbolize_keys: true)
632
570
  end
633
571
  items.flatten
634
572
  end
635
573
 
574
+ # A failed page carries no `Link` header for `page_links` to read, so
575
+ # this has to raise rather than return. It raises `CanvasClientError`
576
+ # directly instead of going through `handle_post_response_errors`,
577
+ # whose status mapping is specific to module-item writes -- a 403 on
578
+ # page 2 of a listing is not a `LockedModuleItemError`.
579
+ #
580
+ # @param url [String]
581
+ # @return [Faraday::Response]
582
+ def fetch_page(url)
583
+ response = @connection.get(url)
584
+ handle_rate_limiting response
585
+ return response if success?(response.status)
586
+
587
+ raise CanvasClientError,
588
+ "Error paginating Canvas results,\nhost: #{host},\nurl: #{url},\n" \
589
+ "status: #{response.status},\nresponse: #{response.body}"
590
+ end
591
+
592
+ # Tolerates only a missing `Link` header -- an unpaginated 2xx has none
593
+ # at all, and `{}` reads as "no next page" to `paginated_items`. A
594
+ # header that is present but does not parse raises instead of also
595
+ # returning `{}`, which would read as a complete listing when records
596
+ # are actually missing. Canvas quotes `rel` per RFC 8288, so a present
597
+ # header that fails this parse is not a shape production sends.
598
+ #
636
599
  # @param headers [Faraday::Utils::Headers]
637
600
  # @return [Hash]
638
601
  def page_links(headers:)
639
- parts = headers[:link].split(',')
640
- parts.map do |part, _|
641
- section = part.split(';')
642
- name = section[1][/rel="(.*)"/, 1].to_sym
643
- url = section[0][/<(.*)>/, 1]
644
- [name, { url: }]
602
+ raw = headers[:link]
603
+ return {} if raw.nil?
604
+
605
+ raw.split(',').map do |part|
606
+ name = part[/rel="(.*?)"/, 1]
607
+ url = part[/<(.*?)>/, 1]
608
+ raise CanvasClientError, "Malformed Canvas Link header: #{raw}" if name.nil? || url.nil?
609
+
610
+ [name.to_sym, { url: }]
645
611
  end.to_h
646
612
  end
647
613
 
@@ -651,25 +617,53 @@ module PlatformSdk
651
617
  (200..299).cover?(response_code.to_i)
652
618
  end
653
619
 
654
- # @param message [String]
655
- # @param result [Hash]
620
+ # Canvas's own 429 body (raised by `RequestThrottle`, not the Rails app)
621
+ # is plain text, not the JSON error shape every other failure returns.
622
+ # Without this, an exhausted retry surfaced as `MultiJson::ParseError`
623
+ # instead of the descriptive error each caller raises.
624
+ #
625
+ # @param body [String]
656
626
  # @return [String]
657
- def parse_response_errors(message:, result:)
658
- error_messages = String.new
659
- unless result[:errors].nil?
660
- result[:errors].each do |error|
661
- error_messages << "#{error[:message]} "
662
- end
627
+ def error_messages_from(body)
628
+ result = MultiJson.load(body, symbolize_keys: true)
629
+ (result[:errors] || []).map { |error| "#{error[:message]} " }.join
630
+ rescue MultiJson::ParseError
631
+ body.to_s
632
+ end
633
+
634
+ # Called by faraday-retry before each retry sleep so a sustained
635
+ # throttle shows up in logs instead of only as request latency.
636
+ #
637
+ # @param env [Faraday::Env]
638
+ # @param retry_count [Integer] retries already attempted, starts at 0
639
+ # @param will_retry_in [Float] seconds until the retry fires
640
+ def log_rate_limit_retry(env:, retry_count:, will_retry_in:, **)
641
+ log_warn("Canvas rate limited (attempt #{retry_count + 1}): " \
642
+ "#{env.url.path}, retrying in #{will_retry_in.round(2)}s")
643
+ end
644
+
645
+ # @param message [String]
646
+ def log_warn(message)
647
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
648
+ Rails.logger.warn(message)
649
+ else
650
+ warn(message)
663
651
  end
664
- "#{message}: #{error_messages}"
665
652
  end
666
653
 
654
+ # Pre-emptive throttle: when Canvas reports the bucket is spent, wait for
655
+ # it to refill rather than spending the next call on a 429. An absent
656
+ # header is not a quota of zero.
657
+ #
658
+ # Canvas reports the remaining quota as a float that goes negative once
659
+ # a client is over the high-water mark, e.g. "0.75" or "-2.0" -- `to_i`
660
+ # truncation read "0.75" as spent and slept unnecessarily. Parsed as a
661
+ # float instead, so only a value at or below zero throttles.
667
662
  def handle_rate_limiting(response)
668
- remaining_requests = response.headers["X-Rate-Limit-Remaining"].to_i
669
- reset_time = response.headers["X-Rate-Limit-Reset"].to_i
670
-
671
- return unless remaining_requests.zero?
663
+ remaining_requests = Float(response.headers['X-Rate-Limit-Remaining'], exception: false)
664
+ return if remaining_requests.nil? || remaining_requests.positive?
672
665
 
666
+ reset_time = response.headers['X-Rate-Limit-Reset'].to_i
673
667
  sleep_time = [reset_time - Time.now.to_i, 0].max + 1
674
668
  sleep(sleep_time)
675
669
  end
@@ -3,7 +3,7 @@
3
3
  module PlatformSdk
4
4
  MAJOR = 3
5
5
  MINOR = 33
6
- PATCH = 4
6
+ PATCH = 5
7
7
 
8
8
  VERSION = "#{PlatformSdk::MAJOR}.#{PlatformSdk::MINOR}.#{PlatformSdk::PATCH}".freeze
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strongmind-platform-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.33.4
4
+ version: 3.33.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Platform Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-18 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64