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