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 +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/platform_sdk/canvas_api/client.rb +382 -210
- 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,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
|
-
|
|
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
|
-
@
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
432
412
|
else
|
|
433
|
-
|
|
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
|
-
|
|
429
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
452
430
|
else
|
|
453
|
-
|
|
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
|
-
|
|
444
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
472
445
|
else
|
|
473
|
-
|
|
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
|
-
|
|
463
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
496
464
|
else
|
|
497
|
-
|
|
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
|
-
|
|
486
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
524
487
|
else
|
|
525
|
-
|
|
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
|
-
|
|
509
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
552
510
|
else
|
|
553
|
-
|
|
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
|
-
|
|
526
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
574
527
|
else
|
|
575
|
-
|
|
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
|
-
|
|
539
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
592
540
|
else
|
|
593
|
-
|
|
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
|
-
|
|
552
|
+
MultiJson.load response.body, symbolize_keys: true
|
|
610
553
|
else
|
|
611
|
-
|
|
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[:
|
|
628
|
-
response =
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
#
|
|
655
|
-
#
|
|
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
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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-
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|