strongmind-platform-sdk 3.33.4 → 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: 80e27a2c045cd72bc8633ded3fc6505f5c2a029158bb67b120ed6b39b31c3438
4
- data.tar.gz: 9941c3cde4236922d326beb90cfef6847568fb1b996eb55bc999f86217672385
3
+ metadata.gz: '04844dcb9535437ef995d84bb438f6c214c2ae46b9e05a68f1f51309adba6f86'
4
+ data.tar.gz: 8584e6f47bd375199e84d136564933d5fb8d94ee178ff9ef95825a52564e590d
5
5
  SHA512:
6
- metadata.gz: d991813ce02b2207fc72c523930d4dbe827d05e644be07d6868caa562be89936ec71a305bf275c1c378571005775cbcf584784bb2fbc57a806ccc236e438caac
7
- data.tar.gz: c29637d88a1e720243e39a556195e89836c1346d1fc2a0e0de2d5c88a0d286c1a5bdc8096fea2be727dd2b0777f4e4fb6d32c37a3130c8ccf3b0063fbaef5588
6
+ metadata.gz: 728ad0c1173e142855ada5487833ae8896bb4dd02e383dcdc62e06098dd68109f663a10d9c598c86890958fe5833717565c9d8289f0aeeb69745df525a26d364
7
+ data.tar.gz: d8f8c3f2a78151161ef58f08971525b6ce6643df1d9984e1ca3784738e167dda798c25877e7290a29c5859010665660b9019335e7e2b723f2e54c0f530d0c96e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
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".
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.
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`.
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.
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.
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.
17
+ - Add the pre-emptive `handle_rate_limiting` check to `update_submission_excused_with_comment`, the one write endpoint that had been skipping it.
18
+ - 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.
19
+ - 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.
20
+ - 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: {...})`.
21
+ - 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.)
22
+ - 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.
23
+
3
24
  - 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
25
 
5
26
  ## [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,72 @@ module PlatformSdk
11
12
 
12
13
  PAGE_SIZE = 10
13
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
+
23
+ # Retries are scoped to Canvas's 429 throttle and nothing else.
24
+ #
25
+ # `retry_statuses` is implemented by raising the synthetic
26
+ # `Faraday::RetriableResponse`, so that class has to stay in
27
+ # `exceptions` for a 429 to be retried at all -- an empty list would
28
+ # let it escape to the caller instead. Naming it as the only entry is
29
+ # what drops faraday-retry's default `Errno::ETIMEDOUT`,
30
+ # `Timeout::Error` and `Faraday::TimeoutError`: a timed-out request may
31
+ # already have been applied by Canvas, and replaying it is not safe for
32
+ # every endpoint here. `update_submission_excused_with_comment` PUTs a
33
+ # `submission_comments` entry, which Canvas appends rather than
34
+ # replaces, so a replayed timeout would leave the student duplicate
35
+ # comments.
36
+ #
37
+ # `methods` restates faraday-retry's own IDEMPOTENT_METHODS so the
38
+ # exclusion is visible at the call site: POST is left out because a
39
+ # retry could re-create a module item. PUT is safe on the remaining 429
40
+ # path because Canvas rejects a throttled request before applying it,
41
+ # so the replay is the first time the write lands.
42
+ #
43
+ # The backoff is deliberately short: Canvas refills the rate-limit
44
+ # bucket continuously, so max: 3 with interval: 1 and
45
+ # backoff_factor: 2 spends about 1.0-1.5s, 2.0-2.5s and 4.0-4.5s (the
46
+ # randomness is additive, not a +/- percentage of the interval) before
47
+ # giving up -- that's 3 retries, 4 attempts total. Callers that need a
48
+ # different budget pass `retry_options:` to the constructor.
49
+ #
50
+ # `retry_statuses: [429]` assumes the hosted Canvas instance has
51
+ # `request_throttle.send_429_response` enabled; without it Canvas's
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.
55
+ #
56
+ # `max_interval` bounds both the exponential backoff and a `Retry-After`
57
+ # header Canvas might one day send -- without it, a single throttled
58
+ # request could block for as long as that header says (faraday-retry
59
+ # defaults `max_interval` to `Float::MAX`).
60
+ DEFAULT_RETRY_OPTIONS = {
61
+ max: 3,
62
+ interval: 1,
63
+ interval_randomness: 0.5,
64
+ backoff_factor: 2,
65
+ max_interval: 10,
66
+ retry_statuses: [429],
67
+ exceptions: [Faraday::RetriableResponse],
68
+ methods: %i[delete get head options put]
69
+ }.freeze
70
+
14
71
  # @param domain [String]
15
72
  # @param token [String]
16
- def initialize(domain:, token:)
73
+ # @param retry_options [Hash] overrides merged onto DEFAULT_RETRY_OPTIONS
74
+ def initialize(domain:, token:, retry_options: {})
17
75
  @host = "https://#{domain}"
18
76
  @token = token
19
- @connection = Faraday.new(
20
- url: host,
21
- headers: {
22
- 'Authorization' => "Bearer #{token}",
23
- 'User-Agent' => "StrongMind-PlatformSDK/#{PlatformSdk::VERSION}"
24
- }
25
- )
77
+ @retry_options = DEFAULT_RETRY_OPTIONS
78
+ .merge(retry_block: method(:log_rate_limit_retry))
79
+ .merge(retry_options)
80
+ @connection = build_connection
26
81
  end
27
82
 
28
83
  # @param course_id [Integer]
@@ -33,12 +88,7 @@ module PlatformSdk
33
88
  if success?(response.status)
34
89
  paginated_items headers: response.headers, initial_response: response.body
35
90
  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}"
91
+ handle_response_errors(response, "Error getting course modules")
42
92
  end
43
93
  end
44
94
 
@@ -48,12 +98,7 @@ module PlatformSdk
48
98
  if success?(response.status)
49
99
  paginated_items headers: response.headers, initial_response: response.body
50
100
  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}"
101
+ handle_response_errors(response, "Error getting course modules")
57
102
  end
58
103
  end
59
104
 
@@ -66,12 +111,7 @@ module PlatformSdk
66
111
  if success?(response.status)
67
112
  paginated_items headers: response.headers, initial_response: response.body
68
113
  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}"
114
+ handle_response_errors(response, "Error getting module items")
75
115
  end
76
116
  end
77
117
 
@@ -86,12 +126,7 @@ module PlatformSdk
86
126
  if success?(response.status)
87
127
  paginated_items headers: response.headers, initial_response: response.body
88
128
  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}"
129
+ handle_response_errors(response, "Error getting module items")
95
130
  end
96
131
  end
97
132
 
@@ -105,12 +140,7 @@ module PlatformSdk
105
140
  assignments = paginated_items headers: response.headers, initial_response: response.body
106
141
  assignments.select { |item| item[:type] == 'Assignment' }
107
142
  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}"
143
+ handle_response_errors(response, "Error getting assignments")
114
144
  end
115
145
  end
116
146
 
@@ -122,12 +152,7 @@ module PlatformSdk
122
152
  if success?(response.status)
123
153
  MultiJson.load response.body, symbolize_keys: true
124
154
  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}"
155
+ handle_response_errors(response, "Error getting assignment")
131
156
  end
132
157
  end
133
158
 
@@ -140,12 +165,7 @@ module PlatformSdk
140
165
  if success?(response.status)
141
166
  MultiJson.load response.body, symbolize_keys: true
142
167
  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}"
168
+ handle_response_errors(response, "Error getting module item")
149
169
  end
150
170
  end
151
171
 
@@ -155,12 +175,7 @@ module PlatformSdk
155
175
  if success?(response.status)
156
176
  MultiJson.load response.body, symbolize_keys: true
157
177
  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}"
178
+ handle_response_errors(response, "Error getting module item")
164
179
  end
165
180
  end
166
181
 
@@ -170,12 +185,7 @@ module PlatformSdk
170
185
  if success?(response.status)
171
186
  MultiJson.load response.body, symbolize_keys: true
172
187
  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}"
188
+ handle_response_errors(response, "Error getting module item")
179
189
  end
180
190
  end
181
191
 
@@ -187,12 +197,22 @@ module PlatformSdk
187
197
  if success?(response.status)
188
198
  paginated_items headers: response.headers, initial_response: response.body
189
199
  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}"
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")
196
216
  end
197
217
  end
198
218
 
@@ -210,12 +230,7 @@ module PlatformSdk
210
230
  if success?(response.status)
211
231
  MultiJson.load response.body, symbolize_keys: true
212
232
  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}"
233
+ handle_response_errors(response, "Error update submissions to excused")
219
234
  end
220
235
  end
221
236
 
@@ -232,15 +247,11 @@ module PlatformSdk
232
247
  }
233
248
  }.to_json
234
249
  end
250
+ handle_rate_limiting response
235
251
  if success?(response.status)
236
252
  MultiJson.load response.body, symbolize_keys: true
237
253
  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}"
254
+ handle_response_errors(response, "Error update submissions to excused")
244
255
  end
245
256
  end
246
257
 
@@ -253,12 +264,7 @@ module PlatformSdk
253
264
  if success?(response.status)
254
265
  paginated_items headers: response.headers, initial_response: response.body
255
266
  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}"
267
+ handle_response_errors(response, "Error getting course students")
262
268
  end
263
269
  end
264
270
 
@@ -271,12 +277,7 @@ module PlatformSdk
271
277
  if success?(response.status)
272
278
  MultiJson.load response.body, symbolize_keys: true
273
279
  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}"
280
+ handle_response_errors(response, "Error getting test students")
280
281
  end
281
282
  end
282
283
 
@@ -288,12 +289,7 @@ module PlatformSdk
288
289
  if success?(response.status)
289
290
  paginated_items headers: response.headers, initial_response: response.body
290
291
  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}"
292
+ handle_response_errors(response, "Error getting course users")
297
293
  end
298
294
  end
299
295
 
@@ -306,12 +302,7 @@ module PlatformSdk
306
302
  if success?(response.status)
307
303
  MultiJson.load response.body, symbolize_keys: true
308
304
  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}"
305
+ handle_response_errors(response, "Error getting quiz")
315
306
  end
316
307
  end
317
308
 
@@ -326,12 +317,7 @@ module PlatformSdk
326
317
  if success?(response.status)
327
318
  paginated_items headers: response.headers, initial_response: response.body
328
319
  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}"
320
+ handle_response_errors(response, "Error getting quiz submissions")
335
321
  end
336
322
  end
337
323
 
@@ -411,12 +397,7 @@ module PlatformSdk
411
397
  if success?(response.status)
412
398
  MultiJson.load response.body, symbolize_keys: true
413
399
  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}"
400
+ handle_response_errors(response, "Error posting a reply")
420
401
  end
421
402
  end
422
403
 
@@ -425,13 +406,11 @@ module PlatformSdk
425
406
  def put_relock_module(course_id:, module_id:)
426
407
  uri = "api/v1/courses/#{course_id}/modules/#{module_id}/relock"
427
408
  response = @connection.put(uri)
428
- result = MultiJson.load response.body, symbolize_keys: true
429
409
  handle_rate_limiting response
430
410
  if success?(response.status)
431
- result
411
+ MultiJson.load response.body, symbolize_keys: true
432
412
  else
433
- error_message = parse_response_errors(result:, message: 'Error re-locking module')
434
- raise CanvasClientError, error_message
413
+ handle_response_errors(response, "Error re-locking module")
435
414
  end
436
415
  end
437
416
 
@@ -446,15 +425,10 @@ module PlatformSdk
446
425
  req.body = { 'module[prerequisite_module_ids]' => prerequisite_ids }
447
426
  end
448
427
  handle_rate_limiting response
449
- result = MultiJson.load response.body, symbolize_keys: true
450
428
  if success?(response.status)
451
- result
429
+ MultiJson.load response.body, symbolize_keys: true
452
430
  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}"
431
+ handle_response_errors(response, "Error updating module prerequisite")
458
432
  end
459
433
  end
460
434
 
@@ -466,15 +440,10 @@ module PlatformSdk
466
440
  req.body = { 'module[name]' => original_name }
467
441
  end
468
442
  handle_rate_limiting response
469
- result = MultiJson.load response.body, symbolize_keys: true
470
443
  if success?(response.status)
471
- result
444
+ MultiJson.load response.body, symbolize_keys: true
472
445
  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}"
446
+ handle_response_errors(response, "Error updating refreshing module prerequisite")
478
447
  end
479
448
  end
480
449
 
@@ -490,15 +459,10 @@ module PlatformSdk
490
459
  req.body = body
491
460
  end
492
461
  handle_rate_limiting response
493
- result = MultiJson.load response.body, symbolize_keys: true
494
462
  if success?(response.status)
495
- result
463
+ MultiJson.load response.body, symbolize_keys: true
496
464
  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}"
465
+ handle_response_errors(response, "Error creating module")
502
466
  end
503
467
  end
504
468
 
@@ -518,15 +482,10 @@ module PlatformSdk
518
482
  req.body = body
519
483
  end
520
484
  handle_rate_limiting response
521
- result = MultiJson.load response.body, symbolize_keys: true
522
485
  if success?(response.status)
523
- result
486
+ MultiJson.load response.body, symbolize_keys: true
524
487
  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}"
488
+ handle_response_errors(response, "Error updating module")
530
489
  end
531
490
  end
532
491
 
@@ -546,15 +505,10 @@ module PlatformSdk
546
505
  req.body = body
547
506
  end
548
507
  handle_rate_limiting response
549
- result = MultiJson.load response.body, symbolize_keys: true
550
508
  if success?(response.status)
551
- result
509
+ MultiJson.load response.body, symbolize_keys: true
552
510
  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}"
511
+ handle_response_errors(response, "Error creating module item")
558
512
  end
559
513
  end
560
514
 
@@ -568,15 +522,10 @@ module PlatformSdk
568
522
  req.body = { 'module_item[published]' => true }
569
523
  end
570
524
  handle_rate_limiting response
571
- result = MultiJson.load response.body, symbolize_keys: true
572
525
  if success?(response.status)
573
- result
526
+ MultiJson.load response.body, symbolize_keys: true
574
527
  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}"
528
+ handle_response_errors(response, "Error publishing module item")
580
529
  end
581
530
  end
582
531
 
@@ -586,15 +535,10 @@ module PlatformSdk
586
535
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
587
536
  end
588
537
  handle_rate_limiting response
589
- result = MultiJson.load response.body, symbolize_keys: true
590
538
  if success?(response.status)
591
- result
539
+ MultiJson.load response.body, symbolize_keys: true
592
540
  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}"
541
+ handle_response_errors(response, "Error destroying module item")
598
542
  end
599
543
  end
600
544
 
@@ -604,44 +548,103 @@ module PlatformSdk
604
548
  req.headers['Content-Type'] = 'application/json'
605
549
  end
606
550
  handle_rate_limiting response
607
- result = MultiJson.load response.body, symbolize_keys: true
608
551
  if success?(response.status)
609
- result
552
+ MultiJson.load response.body, symbolize_keys: true
610
553
  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}"
554
+ handle_response_errors(response, "Error retrieving discussion topic")
616
555
  end
617
556
  end
618
557
 
619
558
  private
620
559
 
560
+ def default_headers
561
+ {
562
+ 'Authorization' => "Bearer #{token}",
563
+ 'User-Agent' => "StrongMind-PlatformSDK/#{PlatformSdk::VERSION}"
564
+ }
565
+ end
566
+
567
+ def build_connection
568
+ Faraday.new(url: host, headers: default_headers) do |f|
569
+ # Faraday installs UrlEncoded by default, but only when no block is
570
+ # given -- several endpoints here post a Hash body as form data and
571
+ # depend on it, so it has to be re-declared alongside the retry.
572
+ f.request :url_encoded
573
+ f.request :retry, @retry_options
574
+ f.adapter Faraday.default_adapter
575
+ end
576
+ end
577
+
578
+ # Walks `rel="next"` rather than comparing `rel="current"` to
579
+ # `rel="last"`. Canvas omits `last` when the total count is too
580
+ # expensive to compute, which left the old condition permanently true
581
+ # and crashed on the final page, where there is no `next` to follow.
582
+ # https://canvas.instructure.com/doc/api/file.pagination.html
583
+ #
621
584
  # @param headers [Faraday::Utils::Headers]
622
585
  # @return [Array]
623
586
  def paginated_items(headers:, initial_response:)
624
587
  pages = page_links(headers:)
625
588
  items = []
626
589
  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
590
+ while pages[:next]
591
+ response = fetch_page(pages[:next][:url])
630
592
  pages = page_links(headers: response.headers)
631
593
  items.append MultiJson.load(response.body, symbolize_keys: true)
632
594
  end
633
595
  items.flatten
634
596
  end
635
597
 
598
+ # A failed page carries no `Link` header for `page_links` to read, so
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.
607
+ #
608
+ # @param url [String]
609
+ # @return [Faraday::Response]
610
+ def fetch_page(url)
611
+ response = @connection.get(url)
612
+ handle_rate_limiting response
613
+ return response if success?(response.status)
614
+
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"
627
+ end
628
+
629
+ # Tolerates only a missing `Link` header -- an unpaginated 2xx has none
630
+ # at all, and `{}` reads as "no next page" to `paginated_items`. A
631
+ # header that is present but does not parse raises instead of also
632
+ # returning `{}`, which would read as a complete listing when records
633
+ # are actually missing. Canvas quotes `rel` per RFC 8288, so a present
634
+ # header that fails this parse is not a shape production sends.
635
+ #
636
636
  # @param headers [Faraday::Utils::Headers]
637
637
  # @return [Hash]
638
638
  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: }]
639
+ raw = headers[:link]
640
+ return {} if raw.nil?
641
+
642
+ raw.split(',').map do |part|
643
+ name = part[/rel="(.*?)"/, 1]
644
+ url = part[/<(.*?)>/, 1]
645
+ raise CanvasClientError, "Malformed Canvas Link header: #{raw}" if name.nil? || url.nil?
646
+
647
+ [name.to_sym, { url: }]
645
648
  end.to_h
646
649
  end
647
650
 
@@ -651,25 +654,179 @@ module PlatformSdk
651
654
  (200..299).cover?(response_code.to_i)
652
655
  end
653
656
 
654
- # @param message [String]
655
- # @param result [Hash]
657
+ # Canvas's own 429 body (raised by `RequestThrottle`, not the Rails app)
658
+ # is plain text, not the JSON error shape every other failure returns.
659
+ # Without this, an exhausted retry surfaced as `MultiJson::ParseError`
660
+ # instead of the descriptive error each caller raises.
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
+ #
675
+ # @param body [String]
656
676
  # @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
677
+ def error_messages_from(body)
678
+ return EMPTY_BODY if body.to_s.strip.empty?
679
+
680
+ result = MultiJson.load(body, symbolize_keys: true)
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(" ")
685
+ rescue MultiJson::ParseError
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]
663
698
  end
664
- "#{message}: #{error_messages}"
665
699
  end
666
700
 
667
- 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
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
670
761
 
671
- return unless remaining_requests.zero?
762
+ status == 403 && response.body.to_s.start_with?(THROTTLED_403_BODY)
763
+ end
672
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)
780
+ end
781
+
782
+ # Called by faraday-retry before each retry sleep so a sustained
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.
787
+ #
788
+ # @param env [Faraday::Env]
789
+ # @param retry_count [Integer] retries already attempted, starts at 0
790
+ # @param will_retry_in [Float] seconds until the retry fires
791
+ def log_rate_limit_retry(env:, retry_count:, will_retry_in:, **)
792
+ log_warn("Canvas rate limited (attempt #{retry_count + 1}): " \
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")
801
+ end
802
+
803
+ # @param message [String]
804
+ def log_warn(message)
805
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
806
+ Rails.logger.warn(message)
807
+ else
808
+ warn(message)
809
+ end
810
+ end
811
+
812
+ # Pre-emptive throttle: when Canvas reports the bucket is spent, wait for
813
+ # it to refill rather than spending the next call on a 429. An absent
814
+ # header is not a quota of zero.
815
+ #
816
+ # Canvas reports the remaining quota as a float that goes negative once
817
+ # a client is over the high-water mark, e.g. "0.75" or "-2.0" -- `to_i`
818
+ # truncation read "0.75" as spent and slept unnecessarily. Parsed as a
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.
825
+ def handle_rate_limiting(response)
826
+ remaining_requests = Float(response.headers['X-Rate-Limit-Remaining'], exception: false)
827
+ return if remaining_requests.nil? || remaining_requests.positive?
828
+
829
+ reset_time = response.headers['X-Rate-Limit-Reset'].to_i
673
830
  sleep_time = [reset_time - Time.now.to_i, 0].max + 1
674
831
  sleep(sleep_time)
675
832
  end
@@ -677,25 +834,40 @@ module PlatformSdk
677
834
  # @param response [Faraday::Response]
678
835
  # @param message [String]
679
836
  def handle_post_response_errors(response, message)
680
- message = "#{message},\n" + "host: #{host},\n" + "response: #{response.body}"
681
- case response.status
682
- when 403
683
- raise LockedModuleItemError, message
684
- when 404
685
- raise MissingModuleItemError, message
686
- when 401
687
- raise UnauthorizedError, message
688
- else
689
- raise CanvasClientError, message
690
- end
837
+ raise_typed_error(response, error_message_for(response, message), locked_on_403: true)
691
838
  end
692
839
  end
693
840
 
694
- 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
695
854
 
696
855
  class LockedModuleItemError < CanvasClientError; end
697
856
 
698
857
  class MissingModuleItemError < CanvasClientError; end
699
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
700
872
  end
701
873
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module PlatformSdk
4
4
  MAJOR = 3
5
- MINOR = 33
6
- PATCH = 4
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.4
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-08-18 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