strongmind-platform-sdk 3.33.5 → 3.34.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: fe3ec6a94953018e636a9fc5bab7a7f66791db9d15aeed14035c1a444980f9a6
4
- data.tar.gz: 678c3fc1a62d418838ab25d1fd8bafeea342a58a986bcd1a1ec3e5d6d087e6dc
3
+ metadata.gz: '04844dcb9535437ef995d84bb438f6c214c2ae46b9e05a68f1f51309adba6f86'
4
+ data.tar.gz: 8584e6f47bd375199e84d136564933d5fb8d94ee178ff9ef95825a52564e590d
5
5
  SHA512:
6
- metadata.gz: 83d9ffbf4ee13769ae6ffb5d1346e8a37cbd6d1cfc511be1ef0d87a63863191bccc12b9a51cfdce4a4732ec70e9c35580018677e15aca638d3e9629cfc89cb37
7
- data.tar.gz: 67bb2e2e837402cb15fdedab566a343111f17204795eafc1e149fa19976d53f277ab924f0d247877c76570996e18db6f125e444df27f162b05928342ce537570
6
+ metadata.gz: 728ad0c1173e142855ada5487833ae8896bb4dd02e383dcdc62e06098dd68109f663a10d9c598c86890958fe5833717565c9d8289f0aeeb69745df525a26d364
7
+ data.tar.gz: d8f8c3f2a78151161ef58f08971525b6ce6643df1d9984e1ca3784738e167dda798c25877e7290a29c5859010665660b9019335e7e2b723f2e54c0f530d0c96e
data/CHANGELOG.md CHANGED
@@ -1,8 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Raise typed errors from every Canvas error branch. The read/list/PUT-write methods raised bare `RuntimeError` strings, which consumers could not distinguish from a bug — Central's grade-passback retry wrapper, for example, only retries `CanvasClientError` and so treated every throttled read as fatal. All error branches now raise through one handler: 404 → `MissingModuleItemError`, 401 → `UnauthorizedError`, rate-limited → `RateLimitedError` (new), anything else → `CanvasClientError`. Every class subclasses `CanvasClientError` (itself a `StandardError`), so rescues written against the old classes keep working; only code matching on `RuntimeError` specifically (none known) would notice. Message text changed (see below); no known consumer parses it.
4
+ - Stop echoing raw Canvas response bodies and page query strings into error messages. Consumers persist `error.message` (Central's `OutcomeLog.exception_message`) and forward it to Sentry, and a raw body from a submission, quiz or roster endpoint can carry student records (FERPA). Every error path now shares one message policy: `"<descriptor>: <Canvas's own error messages> (status: N, host: H)"`, with a fixed `unparseable Canvas error body` / `empty Canvas error body` / `Canvas rate limit exceeded` descriptor in place of any body that isn't a JSON error object. The write-path tail (`handle_post_response_errors`) previously appended the whole body and skipped the `error_messages_from` hardening; it now goes through the same builder. Pagination failures name the page path only, dropping the query that carries `as_user_id`/`student_id`. The raw body and status are still available as `CanvasClientError#response_body` and `#status` for a consumer that decides it is safe to inspect.
5
+ - `error_messages_from` also reads a hash-shaped `errors` (field => messages, as Canvas validation errors return) and a top-level `message` key, instead of producing an inspect blob or nothing.
6
+ - Anchor 403-throttle detection to the start of Canvas's plain-text `403 Forbidden (Rate Limit Exceeded)` body. The unanchored substring match would have turned a genuine JSON 403 that mentioned the phrase into a retryable `RateLimitedError`, the opposite of `LockedModuleItemError` on the write paths.
7
+ - The retry warning logs the HTTP method and route shape with numeric path segments replaced by `:id`, so a Canvas user id in a submission path does not land in logs.
8
+ - Add `CanvasApiWrapper::RateLimitedError`, raised for a 429 as well as the 403 + "Rate Limit Exceeded" body that hosted Canvas sends when `request_throttle.send_429_response` is disabled — the shape behind the QTY-19690 grade-loss incident, previously surfacing as `MultiJson::ParseError`/`RuntimeError`. Carries `retry_after` (seconds, `Float`) when Canvas sent a `Retry-After` header, nil otherwise; callers own the backoff policy. `handle_post_response_errors` checks it before the status mapping, so a throttled write no longer misreports as `LockedModuleItemError` (a gap the retry-options comment had documented).
9
+ - Add `Client#submission(course_id:, assignment_id:, user_id:)` — reads a single user's assignment submission. Central previously issued this GET against the raw connection and parsed the body itself, recreating the untyped-error problem the client had just fixed.
10
+ - Fix `error_messages_from` raising `NoMethodError` when the error JSON carries `errors` as a bare string (Sentry CENTRAL-7R5) or uses the singular `error` key — the formatter's own crash masked the real failure. Array-of-hashes, string, singular-key, non-hash and non-JSON bodies all format safely now.
11
+ - Fix `Client#assignment` raising "Error getting quiz submissions" — a copy-paste from `quiz_submissions`; it now says "Error getting assignment".
3
12
  - 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
13
  - 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.
14
+ - 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 substitutes a fixed descriptor when the body 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
15
  - 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
16
  - 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
17
  - Add the pre-emptive `handle_rate_limiting` check to `update_submission_excused_with_comment`, the one write endpoint that had been skipping it.
@@ -12,6 +12,14 @@ module PlatformSdk
12
12
 
13
13
  PAGE_SIZE = 10
14
14
 
15
+ # The plain-text body Canvas's `RequestThrottle` sends with a 403 when
16
+ # `request_throttle.send_429_response` is disabled.
17
+ THROTTLED_403_BODY = "403 Forbidden (Rate Limit Exceeded)"
18
+ RATE_LIMITED_MESSAGE = "Canvas rate limit exceeded"
19
+ UNPARSEABLE_BODY = "unparseable Canvas error body"
20
+ EMPTY_BODY = "empty Canvas error body"
21
+ NO_ERROR_MESSAGE = "no error message in Canvas response"
22
+
15
23
  # Retries are scoped to Canvas's 429 throttle and nothing else.
16
24
  #
17
25
  # `retry_statuses` is implemented by raising the synthetic
@@ -41,8 +49,9 @@ module PlatformSdk
41
49
  #
42
50
  # `retry_statuses: [429]` assumes the hosted Canvas instance has
43
51
  # `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.
52
+ # `RequestThrottle` returns 403 instead. That shape won't retry here;
53
+ # it is recognised by `rate_limited?` and raised as `RateLimitedError`
54
+ # on read and write paths alike, so callers can still back off.
46
55
  #
47
56
  # `max_interval` bounds both the exponential backoff and a `Retry-After`
48
57
  # header Canvas might one day send -- without it, a single throttled
@@ -79,7 +88,7 @@ module PlatformSdk
79
88
  if success?(response.status)
80
89
  paginated_items headers: response.headers, initial_response: response.body
81
90
  else
82
- raise "Error getting course modules: #{error_messages_from(response.body)}"
91
+ handle_response_errors(response, "Error getting course modules")
83
92
  end
84
93
  end
85
94
 
@@ -89,7 +98,7 @@ module PlatformSdk
89
98
  if success?(response.status)
90
99
  paginated_items headers: response.headers, initial_response: response.body
91
100
  else
92
- raise "Error getting course modules: #{error_messages_from(response.body)}"
101
+ handle_response_errors(response, "Error getting course modules")
93
102
  end
94
103
  end
95
104
 
@@ -102,7 +111,7 @@ module PlatformSdk
102
111
  if success?(response.status)
103
112
  paginated_items headers: response.headers, initial_response: response.body
104
113
  else
105
- raise "Error getting module items: #{error_messages_from(response.body)}"
114
+ handle_response_errors(response, "Error getting module items")
106
115
  end
107
116
  end
108
117
 
@@ -117,7 +126,7 @@ module PlatformSdk
117
126
  if success?(response.status)
118
127
  paginated_items headers: response.headers, initial_response: response.body
119
128
  else
120
- raise "Error getting module items: #{error_messages_from(response.body)}"
129
+ handle_response_errors(response, "Error getting module items")
121
130
  end
122
131
  end
123
132
 
@@ -131,7 +140,7 @@ module PlatformSdk
131
140
  assignments = paginated_items headers: response.headers, initial_response: response.body
132
141
  assignments.select { |item| item[:type] == 'Assignment' }
133
142
  else
134
- raise "Error getting assignments: #{error_messages_from(response.body)}"
143
+ handle_response_errors(response, "Error getting assignments")
135
144
  end
136
145
  end
137
146
 
@@ -143,7 +152,7 @@ module PlatformSdk
143
152
  if success?(response.status)
144
153
  MultiJson.load response.body, symbolize_keys: true
145
154
  else
146
- raise "Error getting quiz submissions#{error_messages_from(response.body)}"
155
+ handle_response_errors(response, "Error getting assignment")
147
156
  end
148
157
  end
149
158
 
@@ -156,7 +165,7 @@ module PlatformSdk
156
165
  if success?(response.status)
157
166
  MultiJson.load response.body, symbolize_keys: true
158
167
  else
159
- raise "Error getting module item: #{error_messages_from(response.body)}"
168
+ handle_response_errors(response, "Error getting module item")
160
169
  end
161
170
  end
162
171
 
@@ -166,7 +175,7 @@ module PlatformSdk
166
175
  if success?(response.status)
167
176
  MultiJson.load response.body, symbolize_keys: true
168
177
  else
169
- raise "Error getting module item: #{error_messages_from(response.body)}"
178
+ handle_response_errors(response, "Error getting module item")
170
179
  end
171
180
  end
172
181
 
@@ -176,7 +185,7 @@ module PlatformSdk
176
185
  if success?(response.status)
177
186
  MultiJson.load response.body, symbolize_keys: true
178
187
  else
179
- raise "Error getting module item: #{error_messages_from(response.body)}"
188
+ handle_response_errors(response, "Error getting module item")
180
189
  end
181
190
  end
182
191
 
@@ -188,7 +197,22 @@ module PlatformSdk
188
197
  if success?(response.status)
189
198
  paginated_items headers: response.headers, initial_response: response.body
190
199
  else
191
- raise "Error getting submissions: #{error_messages_from(response.body)}"
200
+ handle_response_errors(response, "Error getting submissions")
201
+ end
202
+ end
203
+
204
+ # @param course_id [Integer]
205
+ # @param assignment_id [Integer]
206
+ # @param user_id [Integer]
207
+ # @return [Hash]
208
+ def submission(course_id:, assignment_id:, user_id:)
209
+ uri = "/api/v1/courses/#{course_id}/assignments/#{assignment_id}/submissions/#{user_id}"
210
+ response = @connection.get(uri)
211
+ handle_rate_limiting response
212
+ if success?(response.status)
213
+ MultiJson.load response.body, symbolize_keys: true
214
+ else
215
+ handle_response_errors(response, "Error getting submission")
192
216
  end
193
217
  end
194
218
 
@@ -206,7 +230,7 @@ module PlatformSdk
206
230
  if success?(response.status)
207
231
  MultiJson.load response.body, symbolize_keys: true
208
232
  else
209
- raise "Error update submissions to excused: #{error_messages_from(response.body)}"
233
+ handle_response_errors(response, "Error update submissions to excused")
210
234
  end
211
235
  end
212
236
 
@@ -227,7 +251,7 @@ module PlatformSdk
227
251
  if success?(response.status)
228
252
  MultiJson.load response.body, symbolize_keys: true
229
253
  else
230
- raise "Error update submissions to excused: #{error_messages_from(response.body)}"
254
+ handle_response_errors(response, "Error update submissions to excused")
231
255
  end
232
256
  end
233
257
 
@@ -240,7 +264,7 @@ module PlatformSdk
240
264
  if success?(response.status)
241
265
  paginated_items headers: response.headers, initial_response: response.body
242
266
  else
243
- raise "Error getting course students: #{error_messages_from(response.body)}"
267
+ handle_response_errors(response, "Error getting course students")
244
268
  end
245
269
  end
246
270
 
@@ -253,7 +277,7 @@ module PlatformSdk
253
277
  if success?(response.status)
254
278
  MultiJson.load response.body, symbolize_keys: true
255
279
  else
256
- raise "Error getting test students: #{error_messages_from(response.body)}"
280
+ handle_response_errors(response, "Error getting test students")
257
281
  end
258
282
  end
259
283
 
@@ -265,7 +289,7 @@ module PlatformSdk
265
289
  if success?(response.status)
266
290
  paginated_items headers: response.headers, initial_response: response.body
267
291
  else
268
- raise "Error getting course users: #{error_messages_from(response.body)}"
292
+ handle_response_errors(response, "Error getting course users")
269
293
  end
270
294
  end
271
295
 
@@ -278,7 +302,7 @@ module PlatformSdk
278
302
  if success?(response.status)
279
303
  MultiJson.load response.body, symbolize_keys: true
280
304
  else
281
- raise "Error getting quiz: #{error_messages_from(response.body)}"
305
+ handle_response_errors(response, "Error getting quiz")
282
306
  end
283
307
  end
284
308
 
@@ -293,7 +317,7 @@ module PlatformSdk
293
317
  if success?(response.status)
294
318
  paginated_items headers: response.headers, initial_response: response.body
295
319
  else
296
- raise "Error getting quiz submissions#{error_messages_from(response.body)}"
320
+ handle_response_errors(response, "Error getting quiz submissions")
297
321
  end
298
322
  end
299
323
 
@@ -373,7 +397,7 @@ module PlatformSdk
373
397
  if success?(response.status)
374
398
  MultiJson.load response.body, symbolize_keys: true
375
399
  else
376
- raise "Error posting a reply: #{error_messages_from(response.body)}"
400
+ handle_response_errors(response, "Error posting a reply")
377
401
  end
378
402
  end
379
403
 
@@ -386,7 +410,7 @@ module PlatformSdk
386
410
  if success?(response.status)
387
411
  MultiJson.load response.body, symbolize_keys: true
388
412
  else
389
- raise CanvasClientError, "Error re-locking module: #{error_messages_from(response.body)}"
413
+ handle_response_errors(response, "Error re-locking module")
390
414
  end
391
415
  end
392
416
 
@@ -404,7 +428,7 @@ module PlatformSdk
404
428
  if success?(response.status)
405
429
  MultiJson.load response.body, symbolize_keys: true
406
430
  else
407
- raise "Error updating module prerequisite: #{error_messages_from(response.body)}"
431
+ handle_response_errors(response, "Error updating module prerequisite")
408
432
  end
409
433
  end
410
434
 
@@ -419,7 +443,7 @@ module PlatformSdk
419
443
  if success?(response.status)
420
444
  MultiJson.load response.body, symbolize_keys: true
421
445
  else
422
- raise "Error updating refreshing module prerequisite: #{error_messages_from(response.body)}"
446
+ handle_response_errors(response, "Error updating refreshing module prerequisite")
423
447
  end
424
448
  end
425
449
 
@@ -438,7 +462,7 @@ module PlatformSdk
438
462
  if success?(response.status)
439
463
  MultiJson.load response.body, symbolize_keys: true
440
464
  else
441
- raise "Error creating module: #{error_messages_from(response.body)}"
465
+ handle_response_errors(response, "Error creating module")
442
466
  end
443
467
  end
444
468
 
@@ -461,7 +485,7 @@ module PlatformSdk
461
485
  if success?(response.status)
462
486
  MultiJson.load response.body, symbolize_keys: true
463
487
  else
464
- raise "Error updating module: #{error_messages_from(response.body)}"
488
+ handle_response_errors(response, "Error updating module")
465
489
  end
466
490
  end
467
491
 
@@ -484,7 +508,7 @@ module PlatformSdk
484
508
  if success?(response.status)
485
509
  MultiJson.load response.body, symbolize_keys: true
486
510
  else
487
- raise "Error creating module item: #{error_messages_from(response.body)}"
511
+ handle_response_errors(response, "Error creating module item")
488
512
  end
489
513
  end
490
514
 
@@ -501,7 +525,7 @@ module PlatformSdk
501
525
  if success?(response.status)
502
526
  MultiJson.load response.body, symbolize_keys: true
503
527
  else
504
- raise "Error publishing module item: #{error_messages_from(response.body)}"
528
+ handle_response_errors(response, "Error publishing module item")
505
529
  end
506
530
  end
507
531
 
@@ -514,7 +538,7 @@ module PlatformSdk
514
538
  if success?(response.status)
515
539
  MultiJson.load response.body, symbolize_keys: true
516
540
  else
517
- raise "Error destroying module item: #{error_messages_from(response.body)}"
541
+ handle_response_errors(response, "Error destroying module item")
518
542
  end
519
543
  end
520
544
 
@@ -527,7 +551,7 @@ module PlatformSdk
527
551
  if success?(response.status)
528
552
  MultiJson.load response.body, symbolize_keys: true
529
553
  else
530
- raise "Error retrieving discussion topic: #{error_messages_from(response.body)}"
554
+ handle_response_errors(response, "Error retrieving discussion topic")
531
555
  end
532
556
  end
533
557
 
@@ -572,10 +596,14 @@ module PlatformSdk
572
596
  end
573
597
 
574
598
  # 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`.
599
+ # this has to raise rather than return. Anything but a throttle raises
600
+ # plain `CanvasClientError` instead of going through the status
601
+ # mapping: a 403 on page 2 of a listing is not a
602
+ # `LockedModuleItemError`, and a 404 mid-listing is not a permanent
603
+ # absence of the resource the caller asked for.
604
+ #
605
+ # The message names only the page's path. The query string carries
606
+ # `as_user_id`/`student_id` and is left out along with the body.
579
607
  #
580
608
  # @param url [String]
581
609
  # @return [Faraday::Response]
@@ -584,9 +612,18 @@ module PlatformSdk
584
612
  handle_rate_limiting response
585
613
  return response if success?(response.status)
586
614
 
587
- raise CanvasClientError,
588
- "Error paginating Canvas results,\nhost: #{host},\nurl: #{url},\n" \
589
- "status: #{response.status},\nresponse: #{response.body}"
615
+ message = error_message_for(response, "Error paginating Canvas results (#{path_of(url)})")
616
+ raise_rate_limited(response, message) if rate_limited?(response)
617
+
618
+ raise canvas_error(CanvasClientError, response, message)
619
+ end
620
+
621
+ # @param url [String]
622
+ # @return [String]
623
+ def path_of(url)
624
+ URI(url.to_s).path
625
+ rescue URI::InvalidURIError
626
+ "unparseable page url"
590
627
  end
591
628
 
592
629
  # Tolerates only a missing `Link` header -- an unpaginated 2xx has none
@@ -622,24 +659,145 @@ module PlatformSdk
622
659
  # Without this, an exhausted retry surfaced as `MultiJson::ParseError`
623
660
  # instead of the descriptive error each caller raises.
624
661
  #
662
+ # Canvas is also not consistent about the JSON shape itself: `errors`
663
+ # is usually an array of `{message:}` hashes but is sometimes a bare
664
+ # string or a hash of field => messages, and some endpoints use a
665
+ # singular `error` or top-level `message` key. Formatting must never
666
+ # raise -- it would mask the failure it is describing.
667
+ #
668
+ # Only Canvas's own message strings reach the result. A body that is
669
+ # not a JSON object is replaced by a fixed descriptor rather than
670
+ # echoed: these messages are persisted and sent to error tracking by
671
+ # consumers, and a raw body from a student-data endpoint can carry
672
+ # student records. The raw body stays available on the raised error's
673
+ # `response_body` for a consumer that wants it.
674
+ #
625
675
  # @param body [String]
626
676
  # @return [String]
627
677
  def error_messages_from(body)
678
+ return EMPTY_BODY if body.to_s.strip.empty?
679
+
628
680
  result = MultiJson.load(body, symbolize_keys: true)
629
- (result[:errors] || []).map { |error| "#{error[:message]} " }.join
681
+ return UNPARSEABLE_BODY unless result.is_a?(Hash)
682
+
683
+ messages = messages_in(result[:errors] || result[:error] || result[:message])
684
+ messages.empty? ? NO_ERROR_MESSAGE : messages.join(" ")
630
685
  rescue MultiJson::ParseError
631
- body.to_s
686
+ UNPARSEABLE_BODY
687
+ end
688
+
689
+ # @param value [Object] an `errors`/`error`/`message` value from Canvas
690
+ # @return [Array<String>]
691
+ def messages_in(value)
692
+ case value
693
+ when Array then value.flat_map { |item| messages_in(item) }
694
+ when Hash
695
+ value.key?(:message) ? messages_in(value[:message]) : value.values.flat_map { |item| messages_in(item) }
696
+ when nil then []
697
+ else [value.to_s]
698
+ end
699
+ end
700
+
701
+ # The single message policy for every error path: the caller's
702
+ # descriptor, Canvas's own error messages, the status and the host.
703
+ #
704
+ # @param response [Faraday::Response]
705
+ # @param prefix [String]
706
+ # @return [String]
707
+ def error_message_for(response, prefix)
708
+ details = rate_limited?(response) ? RATE_LIMITED_MESSAGE : error_messages_from(response.body)
709
+ "#{prefix}: #{details} (status: #{response.status}, host: #{host})"
710
+ end
711
+
712
+ # @param response [Faraday::Response]
713
+ # @param message [String]
714
+ def handle_response_errors(response, message)
715
+ raise_typed_error(response, error_message_for(response, message))
716
+ end
717
+
718
+ # One owner of the status→error mapping for read and write paths
719
+ # alike; a taxonomy change is an edit here, not at each call site. 404
720
+ # maps to `MissingModuleItemError` for every endpoint -- the name is
721
+ # narrower than its use here, but it is the class consumers already
722
+ # treat as "the resource is gone, do not retry". `locked_on_403:` is
723
+ # the single divergence: module-item writes read a non-throttle 403
724
+ # as Canvas content state.
725
+ #
726
+ # @param response [Faraday::Response]
727
+ # @param message [String]
728
+ # @param locked_on_403 [Boolean]
729
+ def raise_typed_error(response, message, locked_on_403: false)
730
+ raise_rate_limited(response, message) if rate_limited?(response)
731
+
732
+ error_class = case response.status.to_i
733
+ when 403 then locked_on_403 ? LockedModuleItemError : CanvasClientError
734
+ when 404 then MissingModuleItemError
735
+ when 401 then UnauthorizedError
736
+ else CanvasClientError
737
+ end
738
+ raise canvas_error(error_class, response, message)
739
+ end
740
+
741
+ # @param error_class [Class<CanvasClientError>]
742
+ # @param response [Faraday::Response]
743
+ # @param message [String]
744
+ # @return [CanvasClientError]
745
+ def canvas_error(error_class, response, message)
746
+ error_class.new(message, status: response.status.to_i, response_body: response.body)
747
+ end
748
+
749
+ # Canvas's `RequestThrottle` answers 429 when `send_429_response` is
750
+ # enabled and 403 with a plain-text "403 Forbidden (Rate Limit
751
+ # Exceeded)" body when it is not; both mean the same thing to a caller
752
+ # deciding whether to back off. The 403 match is anchored to the start
753
+ # of that body, so a genuine 403 whose JSON error happens to mention
754
+ # the phrase is not turned into a retryable throttle.
755
+ #
756
+ # @param response [Faraday::Response]
757
+ # @return [Boolean]
758
+ def rate_limited?(response)
759
+ status = response.status.to_i
760
+ return true if status == 429
761
+
762
+ status == 403 && response.body.to_s.start_with?(THROTTLED_403_BODY)
763
+ end
764
+
765
+ # @param response [Faraday::Response]
766
+ # @param message [String]
767
+ def raise_rate_limited(response, message)
768
+ raise RateLimitedError.new(message, retry_after: retry_after_from(response),
769
+ status: response.status.to_i, response_body: response.body)
770
+ end
771
+
772
+ # Seconds only: an RFC 9110 HTTP-date `Retry-After` is deliberately
773
+ # treated as absent -- Canvas sends neither form today, and a nil
774
+ # tells the caller to fall back to its own backoff.
775
+ #
776
+ # @param response [Faraday::Response]
777
+ # @return [Float, nil]
778
+ def retry_after_from(response)
779
+ Float(response.headers["Retry-After"], exception: false)
632
780
  end
633
781
 
634
782
  # Called by faraday-retry before each retry sleep so a sustained
635
- # throttle shows up in logs instead of only as request latency.
783
+ # throttle shows up in logs instead of only as request latency. Numeric
784
+ # path segments are replaced with `:id`: submission endpoints carry the
785
+ # Canvas user id in the path, and the route shape is what an operator
786
+ # needs.
636
787
  #
637
788
  # @param env [Faraday::Env]
638
789
  # @param retry_count [Integer] retries already attempted, starts at 0
639
790
  # @param will_retry_in [Float] seconds until the retry fires
640
791
  def log_rate_limit_retry(env:, retry_count:, will_retry_in:, **)
641
792
  log_warn("Canvas rate limited (attempt #{retry_count + 1}): " \
642
- "#{env.url.path}, retrying in #{will_retry_in.round(2)}s")
793
+ "#{env.method.to_s.upcase} #{route_label(env.url.path)}, " \
794
+ "retrying in #{will_retry_in.round(2)}s")
795
+ end
796
+
797
+ # @param path [String]
798
+ # @return [String]
799
+ def route_label(path)
800
+ path.to_s.gsub(%r{/\d+(?=/|\z)}, "/:id")
643
801
  end
644
802
 
645
803
  # @param message [String]
@@ -659,6 +817,11 @@ module PlatformSdk
659
817
  # a client is over the high-water mark, e.g. "0.75" or "-2.0" -- `to_i`
660
818
  # truncation read "0.75" as spent and slept unnecessarily. Parsed as a
661
819
  # float instead, so only a value at or below zero throttles.
820
+ #
821
+ # This also runs on a throttled response before `RateLimitedError` is
822
+ # raised, so a caller honouring `retry_after` may wait on top of this
823
+ # pause. That is deliberate: consumers without their own backoff still
824
+ # get the pause they always had.
662
825
  def handle_rate_limiting(response)
663
826
  remaining_requests = Float(response.headers['X-Rate-Limit-Remaining'], exception: false)
664
827
  return if remaining_requests.nil? || remaining_requests.positive?
@@ -671,25 +834,40 @@ module PlatformSdk
671
834
  # @param response [Faraday::Response]
672
835
  # @param message [String]
673
836
  def handle_post_response_errors(response, message)
674
- message = "#{message},\n" + "host: #{host},\n" + "response: #{response.body}"
675
- case response.status
676
- when 403
677
- raise LockedModuleItemError, message
678
- when 404
679
- raise MissingModuleItemError, message
680
- when 401
681
- raise UnauthorizedError, message
682
- else
683
- raise CanvasClientError, message
684
- end
837
+ raise_typed_error(response, error_message_for(response, message), locked_on_403: true)
685
838
  end
686
839
  end
687
840
 
688
- class CanvasClientError < StandardError; end
841
+ # `status` and `response_body` are set when the error came from a Canvas
842
+ # response. The message deliberately omits the raw body, which can carry
843
+ # student records; `response_body` keeps it for a consumer that decides
844
+ # it is safe to inspect. Do not forward it to logs or error tracking.
845
+ class CanvasClientError < StandardError
846
+ attr_reader :status, :response_body
847
+
848
+ def initialize(message = nil, status: nil, response_body: nil)
849
+ @status = status
850
+ @response_body = response_body
851
+ super(message)
852
+ end
853
+ end
689
854
 
690
855
  class LockedModuleItemError < CanvasClientError; end
691
856
 
692
857
  class MissingModuleItemError < CanvasClientError; end
693
858
  class UnauthorizedError < CanvasClientError; end
859
+
860
+ # Canvas throttled the request (429, or 403 with a rate-limit body).
861
+ # `retry_after` carries the `Retry-After` header in seconds when Canvas
862
+ # sent one, nil otherwise; callers own the backoff policy. The client
863
+ # may already have paused before raising (see `handle_rate_limiting`).
864
+ class RateLimitedError < CanvasClientError
865
+ attr_reader :retry_after
866
+
867
+ def initialize(message = nil, retry_after: nil, **response)
868
+ @retry_after = retry_after
869
+ super(message, **response)
870
+ end
871
+ end
694
872
  end
695
873
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module PlatformSdk
4
4
  MAJOR = 3
5
- MINOR = 33
6
- PATCH = 5
5
+ MINOR = 34
6
+ PATCH = 0
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.5
4
+ version: 3.34.0
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-09-11 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64