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 +4 -4
- data/CHANGELOG.md +10 -1
- data/lib/platform_sdk/canvas_api/client.rb +230 -52
- data/lib/platform_sdk/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '04844dcb9535437ef995d84bb438f6c214c2ae46b9e05a68f1f51309adba6f86'
|
|
4
|
+
data.tar.gz: 8584e6f47bd375199e84d136564933d5fb8d94ee178ff9ef95825a52564e590d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
45
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
576
|
-
#
|
|
577
|
-
#
|
|
578
|
-
#
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/platform_sdk/version.rb
CHANGED
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.
|
|
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
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|