parse-stack-next 5.7.5 → 5.8.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 +856 -0
- data/README.md +15 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +190 -14
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +318 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +290 -17
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
|
@@ -133,19 +133,62 @@ module Parse
|
|
|
133
133
|
@batch_response
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
-
# If it is a batch
|
|
136
|
+
# If it is a batch response, we'll create an array of Response objects for each
|
|
137
137
|
# of the ones in the batch.
|
|
138
|
-
#
|
|
138
|
+
#
|
|
139
|
+
# Each entry is classified by its envelope key, not by its contents. A
|
|
140
|
+
# `success` entry is always a successful response, even when the saved
|
|
141
|
+
# object carries a column named `code` or `error` (for example one set by a
|
|
142
|
+
# beforeSave trigger). An `error` entry is always a failed response. An
|
|
143
|
+
# entry with neither key, or one that is not a Hash, is malformed and is
|
|
144
|
+
# reported as a failed response so the caller never treats it as a write
|
|
145
|
+
# that landed.
|
|
146
|
+
# @return [Array<Parse::Response>] an array of Response objects.
|
|
139
147
|
def batch_responses
|
|
140
|
-
return [
|
|
148
|
+
return [self] unless @batch_response
|
|
141
149
|
# if batch response, generate array based on the response hash.
|
|
142
150
|
@result.map do |r|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
if r.is_a?(Hash) && r.key?(SUCCESS)
|
|
152
|
+
Parse::Response.success_entry(r[SUCCESS])
|
|
153
|
+
elsif r.is_a?(Hash) && r[ERROR].is_a?(Hash)
|
|
154
|
+
Parse::Response.error_response(
|
|
155
|
+
r[ERROR][CODE] || ERROR_INTERNAL,
|
|
156
|
+
r[ERROR][ERROR] || "Unknown batch error",
|
|
157
|
+
)
|
|
158
|
+
elsif r.is_a?(Hash) && r.key?(ERROR)
|
|
159
|
+
Parse::Response.error_response(ERROR_INTERNAL, r[ERROR].to_s)
|
|
160
|
+
else
|
|
161
|
+
Parse::Response.error_response(ERROR_INTERNAL, "Malformed batch response entry")
|
|
162
|
+
end
|
|
146
163
|
end
|
|
147
164
|
end
|
|
148
165
|
|
|
166
|
+
# Build a successful response from a result hash. Unlike {#initialize},
|
|
167
|
+
# a `code` or `error` key in the hash is kept as data and never turns the
|
|
168
|
+
# response into a failure.
|
|
169
|
+
# @param result [Hash, Object] the result body.
|
|
170
|
+
# @return [Parse::Response]
|
|
171
|
+
def self.success_entry(result)
|
|
172
|
+
r = new(result.is_a?(Hash) ? result : {})
|
|
173
|
+
r.code = nil
|
|
174
|
+
r.error = nil
|
|
175
|
+
r.result = result unless result.is_a?(Hash)
|
|
176
|
+
r
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Build a failed response with the given code and message.
|
|
180
|
+
# @param code [Integer] the Parse error code.
|
|
181
|
+
# @param message [String] the error message.
|
|
182
|
+
# @param http_status [Integer] the HTTP status to record.
|
|
183
|
+
# @return [Parse::Response]
|
|
184
|
+
def self.error_response(code, message, http_status: 0)
|
|
185
|
+
r = new
|
|
186
|
+
r.code = code
|
|
187
|
+
r.error = message
|
|
188
|
+
r.http_status = http_status
|
|
189
|
+
r
|
|
190
|
+
end
|
|
191
|
+
|
|
149
192
|
# This method takes the result hash and determines if it is a regular
|
|
150
193
|
# parse query result, object result or a count result. The response should
|
|
151
194
|
# be a hash either containing the result data or the error.
|
data/lib/parse/client.rb
CHANGED
|
@@ -53,8 +53,22 @@ module Parse
|
|
|
53
53
|
# An error when the Parse server returned invalid code.
|
|
54
54
|
class ServerError < Error; end
|
|
55
55
|
|
|
56
|
-
# An error when a Parse server
|
|
57
|
-
|
|
56
|
+
# An error when a Parse server (or a gateway in front of it) responds
|
|
57
|
+
# with HTTP 500, 502, 503, or 504.
|
|
58
|
+
class ServiceUnavailableError < Error
|
|
59
|
+
# @return [Parse::Response, nil] the response that raised the error.
|
|
60
|
+
attr_reader :response
|
|
61
|
+
|
|
62
|
+
def initialize(message = nil)
|
|
63
|
+
@response = message if message.is_a?(Parse::Response)
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Integer, nil] the HTTP status of the response, when known.
|
|
68
|
+
def http_status
|
|
69
|
+
@response&.http_status&.to_i
|
|
70
|
+
end
|
|
71
|
+
end
|
|
58
72
|
|
|
59
73
|
# An error when the authentication credentials in the request are invalid.
|
|
60
74
|
class AuthenticationError < Error; end
|
|
@@ -446,12 +460,10 @@ module Parse
|
|
|
446
460
|
#
|
|
447
461
|
# total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
|
|
448
462
|
#
|
|
449
|
-
# Scopes REST-routed operations (`find` / `get` / `count` / `save`)
|
|
450
|
-
#
|
|
451
|
-
#
|
|
452
|
-
# `acl_user
|
|
453
|
-
# inside this block is a full master read, not anonymous. Scope mongo-direct
|
|
454
|
-
# explicitly with a per-query `session_token:` or a scoped {Parse::Agent}.
|
|
463
|
+
# Scopes REST-routed operations (`find` / `get` / `count` / `save`) and
|
|
464
|
+
# mongo-direct reads (`results_direct`, `count_direct`, Atlas Search), which
|
|
465
|
+
# resolve the same ambient token when the query carries no explicit
|
|
466
|
+
# `session_token:`, `acl_user:`, or `use_master_key: true`.
|
|
455
467
|
#
|
|
456
468
|
# @raise [ArgumentError] if this client has no bound session token (scoping
|
|
457
469
|
# would be a no-op and almost certainly a mistake).
|
|
@@ -1098,6 +1110,22 @@ module Parse
|
|
|
1098
1110
|
@retry_limit
|
|
1099
1111
|
end
|
|
1100
1112
|
|
|
1113
|
+
# The longest one request through {#request} can take, in seconds,
|
|
1114
|
+
# counting every retry attempt at its full open and read timeout plus
|
|
1115
|
+
# the largest retry backoff. A server-supplied Retry-After is not
|
|
1116
|
+
# included. Used to size locks that must be held across a request.
|
|
1117
|
+
# @return [Float]
|
|
1118
|
+
def request_time_budget
|
|
1119
|
+
options = @conn.respond_to?(:options) ? @conn.options : nil
|
|
1120
|
+
read = options.respond_to?(:timeout) ? options.timeout.to_f : 0.0
|
|
1121
|
+
open = options.respond_to?(:open_timeout) ? options.open_timeout.to_f : 0.0
|
|
1122
|
+
read = 30.0 if read <= 0
|
|
1123
|
+
open = 5.0 if open <= 0
|
|
1124
|
+
retries = [retry_limit.to_i, 0].max
|
|
1125
|
+
backoff = (1..retries).sum { |attempt| RETRY_DELAY * attempt * 1.25 }
|
|
1126
|
+
(open + read) * (retries + 1) + backoff
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1101
1129
|
# @return [String] the url prefix of the Parse Server url.
|
|
1102
1130
|
def url_prefix
|
|
1103
1131
|
@conn.url_prefix
|
|
@@ -1221,6 +1249,15 @@ module Parse
|
|
|
1221
1249
|
_retry_count = nil
|
|
1222
1250
|
_retry_delay = nil
|
|
1223
1251
|
_request = nil
|
|
1252
|
+
# A Parse::Request carries its own per-request options (session_token,
|
|
1253
|
+
# use_master_key, cache, retry, ...). Merge them in before anything
|
|
1254
|
+
# below reads `opts`, or the auth resolution never sees them and a
|
|
1255
|
+
# request built for a session with `use_master_key: false` still goes
|
|
1256
|
+
# out with the configured master key. Options passed to this call
|
|
1257
|
+
# directly win over the request's own.
|
|
1258
|
+
if method.is_a?(Request) && method.opts.is_a?(Hash) && !method.opts.empty?
|
|
1259
|
+
opts = method.opts.merge(opts.is_a?(Hash) ? opts : {})
|
|
1260
|
+
end
|
|
1224
1261
|
# Kwarg-absorption guard. The `**opts` splat in API helper methods
|
|
1225
1262
|
# (lib/parse/api/*.rb) absorbs a caller-passed `opts: { ... }`
|
|
1226
1263
|
# keyword as a key named `:opts` rather than as the request options
|
|
@@ -1244,10 +1281,12 @@ module Parse
|
|
|
1244
1281
|
# attempt, turning a transient 500/503/429 into an infinite retry loop.
|
|
1245
1282
|
_retry_count ||= self.retry_limit
|
|
1246
1283
|
|
|
1284
|
+
# `retry: false` and `retry: 0` both disable retries; a positive
|
|
1285
|
+
# Integer sets the budget. Anything else keeps the client default.
|
|
1247
1286
|
if opts[:retry] == false
|
|
1248
1287
|
_retry_count = 0
|
|
1249
|
-
elsif opts[:retry].
|
|
1250
|
-
_retry_count = opts[:retry]
|
|
1288
|
+
elsif opts[:retry].is_a?(Numeric)
|
|
1289
|
+
_retry_count = [opts[:retry].to_i, 0].max
|
|
1251
1290
|
end
|
|
1252
1291
|
|
|
1253
1292
|
# The effective starting budget, captured ONCE after the opts override
|
|
@@ -1259,6 +1298,15 @@ module Parse
|
|
|
1259
1298
|
# silently disabling the backoff (every retry firing at zero delay).
|
|
1260
1299
|
_retry_max ||= _retry_count
|
|
1261
1300
|
|
|
1301
|
+
# Work on a copy of the caller's headers. The request id header is
|
|
1302
|
+
# written into this hash, and it must survive the `retry` keyword (so
|
|
1303
|
+
# it is set above the `begin`) without leaking into a hash the caller
|
|
1304
|
+
# reuses for its next request.
|
|
1305
|
+
headers = headers ? headers.dup : {}
|
|
1306
|
+
# Set once a request is re-sent after an ambiguous failure, so a
|
|
1307
|
+
# replayed DELETE that finds the object already gone can be recognized.
|
|
1308
|
+
_replayed = false
|
|
1309
|
+
|
|
1262
1310
|
begin
|
|
1263
1311
|
headers ||= {}
|
|
1264
1312
|
# if the first argument is a Parse::Request object, then construct it
|
|
@@ -1272,6 +1320,9 @@ module Parse
|
|
|
1272
1320
|
headers.merge! _request.headers
|
|
1273
1321
|
else
|
|
1274
1322
|
_request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts)
|
|
1323
|
+
# Request copies the headers it is given, so carry its request id
|
|
1324
|
+
# back onto the outgoing headers.
|
|
1325
|
+
headers.merge!(_request.headers)
|
|
1275
1326
|
end
|
|
1276
1327
|
|
|
1277
1328
|
# http method
|
|
@@ -1305,7 +1356,18 @@ module Parse
|
|
|
1305
1356
|
end
|
|
1306
1357
|
|
|
1307
1358
|
raw_token = opts[:session_token]
|
|
1308
|
-
#
|
|
1359
|
+
# A session token the caller put in the request headers directly
|
|
1360
|
+
# (`current_user(token)`, `fetch_session(token)`, `logout(token)`)
|
|
1361
|
+
# is as explicit as `session_token:`. Treat it as the per-call token
|
|
1362
|
+
# so the ambient `with_session` token or this client's bound token
|
|
1363
|
+
# cannot overwrite it: otherwise `User.session(token_a)` inside
|
|
1364
|
+
# `with_session(token_b)` resolved user B, and the identity cache
|
|
1365
|
+
# then mapped token A (or a garbage token) to user B for its TTL.
|
|
1366
|
+
if raw_token.nil?
|
|
1367
|
+
header_token = headers[Parse::Protocol::SESSION_TOKEN]
|
|
1368
|
+
raw_token = header_token if header_token.is_a?(String)
|
|
1369
|
+
end
|
|
1370
|
+
# SEC-02:an EXPLICITLY-supplied session_token that is a blank /
|
|
1309
1371
|
# whitespace-only string is an unusable credential — NOT an invitation
|
|
1310
1372
|
# to fall back to the master key. Treat it as "no credential"
|
|
1311
1373
|
# (anonymous) and fail closed: suppress the master key and send no
|
|
@@ -1325,7 +1387,15 @@ module Parse
|
|
|
1325
1387
|
# nested inside a `with_session(user)` block (or on a token-bound client)
|
|
1326
1388
|
# would silently downgrade. The ambient wins over the bound token so a
|
|
1327
1389
|
# `with_session` override inside a user-scoped client still takes effect.
|
|
1328
|
-
|
|
1390
|
+
# Inside `Parse.with_session(nil)` (or a token-less user) the block is
|
|
1391
|
+
# anonymous: no ambient, no bound token, and no master key, unless
|
|
1392
|
+
# this call passed a token or `use_master_key: true` itself.
|
|
1393
|
+
anonymous_block = token.nil? && !explicit_blank_token &&
|
|
1394
|
+
!(explicit_master && opts[:use_master_key] == true) &&
|
|
1395
|
+
Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
|
|
1396
|
+
if anonymous_block
|
|
1397
|
+
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
1398
|
+
elsif token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
|
|
1329
1399
|
ambient = Parse.current_session_token
|
|
1330
1400
|
# A whitespace-only ambient must not count as present: otherwise it
|
|
1331
1401
|
# blocks the bound-token fallback below and then fails the later
|
|
@@ -1336,6 +1406,7 @@ module Parse
|
|
|
1336
1406
|
if explicit_blank_token
|
|
1337
1407
|
# Fail closed: never send the master key for an unusable explicit token.
|
|
1338
1408
|
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
1409
|
+
headers.delete(Parse::Protocol::SESSION_TOKEN)
|
|
1339
1410
|
elsif token.present?
|
|
1340
1411
|
token = token.session_token if token.respond_to?(:session_token)
|
|
1341
1412
|
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
@@ -1355,11 +1426,25 @@ module Parse
|
|
|
1355
1426
|
Parse::Client._safe_warn("AuthenticationError", response)
|
|
1356
1427
|
raise Parse::Error::AuthenticationError, response
|
|
1357
1428
|
when 400, 408
|
|
1358
|
-
|
|
1429
|
+
# Only Parse's timeout code maps here. Code 143 is Parse Server's
|
|
1430
|
+
# WEBHOOK_ERROR (for example "no function named: foo is defined"),
|
|
1431
|
+
# not a timeout.
|
|
1432
|
+
if response.code == Parse::Response::ERROR_TIMEOUT
|
|
1359
1433
|
Parse::Client._safe_warn("TimeoutError", response)
|
|
1360
1434
|
raise Parse::Error::TimeoutError, response
|
|
1361
1435
|
end
|
|
1362
1436
|
when 404
|
|
1437
|
+
if response.object_not_found? && _replayed && method == :delete
|
|
1438
|
+
# An earlier attempt of this DELETE failed ambiguously and was
|
|
1439
|
+
# re-sent. "Object not found" on the replay means the first
|
|
1440
|
+
# attempt removed it, so report the delete as applied.
|
|
1441
|
+
warn "[Parse:Retry] #{_request} : object already deleted by an earlier attempt"
|
|
1442
|
+
applied = Parse::Response.new({})
|
|
1443
|
+
applied.http_status = 200
|
|
1444
|
+
applied.headers = response.headers
|
|
1445
|
+
applied.request = _request
|
|
1446
|
+
return applied
|
|
1447
|
+
end
|
|
1363
1448
|
unless response.object_not_found?
|
|
1364
1449
|
Parse::Client._safe_warn("ConnectionError", response)
|
|
1365
1450
|
raise Parse::Error::ConnectionError, response
|
|
@@ -1370,12 +1455,24 @@ module Parse
|
|
|
1370
1455
|
when 429 # Request over the throttle limit
|
|
1371
1456
|
Parse::Client._safe_warn("RequestLimitExceededError", response)
|
|
1372
1457
|
raise Parse::Error::RequestLimitExceededError, response
|
|
1373
|
-
when 500, 503
|
|
1458
|
+
when 500, 502, 503, 504
|
|
1459
|
+
# 502 and 504 come from a gateway or load balancer in front of
|
|
1460
|
+
# Parse Server. Like 500/503, the request may or may not have been
|
|
1461
|
+
# applied, so they are retried only when the request is idempotent.
|
|
1374
1462
|
Parse::Client._safe_warn("ServiceUnavailableError", response)
|
|
1375
1463
|
raise Parse::Error::ServiceUnavailableError, response
|
|
1376
1464
|
end
|
|
1377
1465
|
|
|
1378
1466
|
if response.error?
|
|
1467
|
+
if response.code == -1 && response.error.to_s.start_with?("Missing additional authData mfa")
|
|
1468
|
+
# Parse Server's OTHER_CAUSE (-1) for a password login on an
|
|
1469
|
+
# account with MFA enabled and no token supplied. It is a 400, not
|
|
1470
|
+
# an outage: without this branch the `<= 2` check below mapped it
|
|
1471
|
+
# to ServiceUnavailableError and retried the login. Surface the
|
|
1472
|
+
# typed error callers are documented to rescue to prompt for a code.
|
|
1473
|
+
Parse::Client._safe_warn("MFA::RequiredError", response)
|
|
1474
|
+
raise Parse::MFA::RequiredError
|
|
1475
|
+
end
|
|
1379
1476
|
if response.code <= Parse::Response::ERROR_SERVICE_UNAVAILABLE
|
|
1380
1477
|
Parse::Client._safe_warn("ServiceUnavailableError", response)
|
|
1381
1478
|
raise Parse::Error::ServiceUnavailableError, response
|
|
@@ -1404,10 +1501,11 @@ module Parse
|
|
|
1404
1501
|
# re-sending is safe for any method. 500/503 (ServiceUnavailable) is
|
|
1405
1502
|
# ambiguous — a write may have applied before the error — so only
|
|
1406
1503
|
# re-send when the request is idempotent (see #idempotent_retry?).
|
|
1407
|
-
retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers)
|
|
1504
|
+
retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers, uri)
|
|
1408
1505
|
if _retry_count > 0 && retryable
|
|
1409
|
-
warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response
|
|
1506
|
+
warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response&.request || _request}"
|
|
1410
1507
|
_retry_count -= 1
|
|
1508
|
+
_replayed = true unless e.is_a?(Parse::Error::RequestLimitExceededError)
|
|
1411
1509
|
# Use Retry-After header if available, otherwise use linear backoff
|
|
1412
1510
|
retry_after = response.retry_after if response.respond_to?(:retry_after)
|
|
1413
1511
|
if retry_after && retry_after > 0
|
|
@@ -1443,8 +1541,9 @@ module Parse
|
|
|
1443
1541
|
# Retrying only adds backoff latency and `[Parse:Retry]` noise
|
|
1444
1542
|
# before the inevitable error, so it propagates raw and fast.
|
|
1445
1543
|
raise unless connection_reset_error?(e)
|
|
1446
|
-
if _retry_count > 0 && idempotent_retry?(method, body, headers)
|
|
1544
|
+
if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
|
|
1447
1545
|
_retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
|
|
1546
|
+
_replayed = true
|
|
1448
1547
|
retry
|
|
1449
1548
|
end
|
|
1450
1549
|
raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
|
|
@@ -1458,8 +1557,9 @@ module Parse
|
|
|
1458
1557
|
# not `ClientError`, so it must be listed explicitly to be caught.
|
|
1459
1558
|
# `Faraday::ConnectionFailed` is handled in its own rescue above,
|
|
1460
1559
|
# split into retry-reset / fail-fast-refused.
|
|
1461
|
-
if _retry_count > 0 && idempotent_retry?(method, body, headers)
|
|
1560
|
+
if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
|
|
1462
1561
|
_retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
|
|
1562
|
+
_replayed = true
|
|
1463
1563
|
retry
|
|
1464
1564
|
end
|
|
1465
1565
|
raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
|
|
@@ -1547,9 +1647,12 @@ module Parse
|
|
|
1547
1647
|
# @param body [Hash, Object, nil] the request body.
|
|
1548
1648
|
# @param headers [Hash, nil] the outgoing request headers (consulted for
|
|
1549
1649
|
# the request-id header on the server-dedup fast path).
|
|
1650
|
+
# @param uri [String, nil] the request path, used to confirm the
|
|
1651
|
+
# endpoint is one Parse Server deduplicates. Without it the
|
|
1652
|
+
# server-dedup fast path does not apply.
|
|
1550
1653
|
# @return [Boolean]
|
|
1551
|
-
def idempotent_retry?(method, body, headers = nil)
|
|
1552
|
-
return true if server_deduped_request?(headers)
|
|
1654
|
+
def idempotent_retry?(method, body, headers = nil, uri = nil)
|
|
1655
|
+
return true if server_deduped_request?(headers, method, uri)
|
|
1553
1656
|
case method
|
|
1554
1657
|
when :get, :delete then true
|
|
1555
1658
|
when :put then !body_carries_atomic_op?(body)
|
|
@@ -1557,34 +1660,90 @@ module Parse
|
|
|
1557
1660
|
end
|
|
1558
1661
|
end
|
|
1559
1662
|
|
|
1663
|
+
# Routes on which Parse Server runs its request-id deduplication
|
|
1664
|
+
# (`promiseEnsureIdempotency`), by HTTP method. Paths are relative to the
|
|
1665
|
+
# mount point. Every other endpoint (notably `POST /batch`, files, push,
|
|
1666
|
+
# login, logout, schemas, config, and hooks) applies a replay again, so
|
|
1667
|
+
# a request to one of them is never treated as server-deduplicated.
|
|
1668
|
+
SERVER_DEDUP_ROUTES = {
|
|
1669
|
+
post: [
|
|
1670
|
+
%r{\Aclasses/[^/]+\z},
|
|
1671
|
+
%r{\Ausers\z},
|
|
1672
|
+
%r{\Ainstallations\z},
|
|
1673
|
+
%r{\Afunctions/[^/]+\z},
|
|
1674
|
+
%r{\Ajobs/[^/]+\z},
|
|
1675
|
+
].freeze,
|
|
1676
|
+
put: [
|
|
1677
|
+
%r{\Aclasses/[^/]+/[^/]+\z},
|
|
1678
|
+
%r{\Ausers/[^/]+\z},
|
|
1679
|
+
%r{\Ainstallations/[^/]+\z},
|
|
1680
|
+
].freeze,
|
|
1681
|
+
}.freeze
|
|
1682
|
+
|
|
1560
1683
|
# Whether this request is covered by Parse Server's server-side request-id
|
|
1561
1684
|
# deduplication, making a replay a no-op. True only when the operator has
|
|
1562
|
-
# opted in via {Parse::Request.assume_server_idempotency}
|
|
1563
|
-
#
|
|
1564
|
-
#
|
|
1565
|
-
# get a request id, so they correctly fail this check).
|
|
1685
|
+
# opted in via {Parse::Request.assume_server_idempotency}, the request
|
|
1686
|
+
# carries a non-blank request-id header, AND it targets a route Parse
|
|
1687
|
+
# Server deduplicates (see {SERVER_DEDUP_ROUTES}).
|
|
1566
1688
|
# @param headers [Hash, nil] the outgoing request headers.
|
|
1689
|
+
# @param method [Symbol, nil] the HTTP method.
|
|
1690
|
+
# @param uri [String, nil] the request path.
|
|
1567
1691
|
# @return [Boolean]
|
|
1568
|
-
def server_deduped_request?(headers)
|
|
1692
|
+
def server_deduped_request?(headers, method = nil, uri = nil)
|
|
1569
1693
|
return false unless Parse::Request.assume_server_idempotency
|
|
1570
1694
|
return false unless headers.is_a?(Hash)
|
|
1571
1695
|
rid = headers[Parse::Request.request_id_header]
|
|
1572
|
-
rid.is_a?(String) && !rid.strip.empty?
|
|
1696
|
+
return false unless rid.is_a?(String) && !rid.strip.empty?
|
|
1697
|
+
routes = SERVER_DEDUP_ROUTES[method.to_s.downcase.to_sym] if method
|
|
1698
|
+
return false if routes.nil?
|
|
1699
|
+
path = api_relative_path(uri)
|
|
1700
|
+
return false if path.nil?
|
|
1701
|
+
routes.any? { |route| path.match?(route) }
|
|
1573
1702
|
end
|
|
1574
1703
|
|
|
1575
|
-
#
|
|
1576
|
-
#
|
|
1577
|
-
#
|
|
1578
|
-
#
|
|
1579
|
-
#
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1704
|
+
# The request path relative to the Parse mount point, without a query
|
|
1705
|
+
# string or surrounding slashes. Accepts relative paths (`classes/Post`)
|
|
1706
|
+
# and absolute ones that include the mount prefix (`/parse/classes/Post`).
|
|
1707
|
+
# @param uri [String, nil]
|
|
1708
|
+
# @return [String, nil]
|
|
1709
|
+
def api_relative_path(uri)
|
|
1710
|
+
return nil if uri.nil?
|
|
1711
|
+
path = uri.to_s.split("?", 2).first.to_s
|
|
1712
|
+
prefix = url_prefix.path.to_s rescue ""
|
|
1713
|
+
prefix = prefix.chomp("/")
|
|
1714
|
+
if !prefix.empty? && path.start_with?("#{prefix}/")
|
|
1715
|
+
path = path.delete_prefix(prefix)
|
|
1716
|
+
end
|
|
1717
|
+
# Trim slashes with string operations rather than an end-anchored
|
|
1718
|
+
# regex, which backtracks polynomially on long runs of "/".
|
|
1719
|
+
path = path.delete_prefix("/") while path.start_with?("/")
|
|
1720
|
+
path = path.delete_suffix("/") while path.end_with?("/")
|
|
1721
|
+
path
|
|
1722
|
+
end
|
|
1723
|
+
|
|
1724
|
+
# Whether a request body carries a Parse operation: an `__op` key at any
|
|
1725
|
+
# depth, including the top level. That covers field ops (Increment, Add,
|
|
1726
|
+
# AddUnique, Remove, AddRelation, RemoveRelation, Delete), nested ops such
|
|
1727
|
+
# as a schema update's `fields: { name: { __op: "Delete" } }`, and
|
|
1728
|
+
# whole-body ops such as a hook delete's `{ __op: "Delete" }`. Replaying
|
|
1729
|
+
# any of them after the first attempt applied turns a landed write into a
|
|
1730
|
+
# reported failure, or applies it twice. A pre-serialized String body is
|
|
1731
|
+
# scanned for the literal `"__op"` key.
|
|
1583
1732
|
# @param body [Object] the request body.
|
|
1584
1733
|
# @return [Boolean]
|
|
1585
|
-
def body_carries_atomic_op?(body)
|
|
1586
|
-
return false
|
|
1587
|
-
body
|
|
1734
|
+
def body_carries_atomic_op?(body, depth = 0)
|
|
1735
|
+
return false if depth > 32
|
|
1736
|
+
case body
|
|
1737
|
+
when Hash
|
|
1738
|
+
body.key?("__op") || body.key?(:__op) ||
|
|
1739
|
+
body.each_value.any? { |v| body_carries_atomic_op?(v, depth + 1) }
|
|
1740
|
+
when Array
|
|
1741
|
+
body.any? { |v| body_carries_atomic_op?(v, depth + 1) }
|
|
1742
|
+
when String
|
|
1743
|
+
depth.zero? && body.include?('"__op"')
|
|
1744
|
+
else
|
|
1745
|
+
false
|
|
1746
|
+
end
|
|
1588
1747
|
end
|
|
1589
1748
|
|
|
1590
1749
|
# Send a GET request.
|
|
@@ -1629,7 +1788,11 @@ module Parse
|
|
|
1629
1788
|
# @return (see #request)
|
|
1630
1789
|
def send_request(req) #Parse::Request object
|
|
1631
1790
|
raise ArgumentError, "Object not of Parse::Request type." unless req.is_a?(Parse::Request)
|
|
1632
|
-
request
|
|
1791
|
+
# Forward the request's own options so its session token, master-key
|
|
1792
|
+
# opt-out, cache directive and retry budget reach the auth resolution.
|
|
1793
|
+
# {#request} also merges them; passing them here keeps the contract
|
|
1794
|
+
# explicit at the public entry point.
|
|
1795
|
+
request req, opts: (req.opts || {})
|
|
1633
1796
|
end
|
|
1634
1797
|
|
|
1635
1798
|
# The connectable module adds methods to objects so that they can get a default
|