parse-stack-next 5.7.6 → 5.8.1
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 +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- 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_dispatcher.rb +8 -1
- 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 +298 -21
- 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/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -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 +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- 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/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- 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 +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- 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 +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- 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 +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- 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 +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- 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 +91 -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 +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- metadata +8 -1
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
|
|
@@ -269,6 +283,14 @@ module Parse
|
|
|
269
283
|
DEFAULT_RETRIES = 2
|
|
270
284
|
# The wait time in seconds between retries
|
|
271
285
|
RETRY_DELAY = 1.5
|
|
286
|
+
# SDK-internal value for the `metadata_master:` request option. A request
|
|
287
|
+
# that passes it together with `use_master_key: true` reads metadata (a
|
|
288
|
+
# class schema, the role graph) and keeps the master key inside
|
|
289
|
+
# {Parse.without_master_key}. Only this exact object turns it on.
|
|
290
|
+
# @!visibility private
|
|
291
|
+
METADATA_MASTER_REQUEST = Object.new.tap do |o|
|
|
292
|
+
def o.inspect = "#<Parse::Client::METADATA_MASTER_REQUEST>"
|
|
293
|
+
end.freeze
|
|
272
294
|
|
|
273
295
|
# An error when a general response error occurs when communicating with Parse server.
|
|
274
296
|
class ResponseError < Parse::Error; end
|
|
@@ -446,12 +468,10 @@ module Parse
|
|
|
446
468
|
#
|
|
447
469
|
# total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
|
|
448
470
|
#
|
|
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}.
|
|
471
|
+
# Scopes REST-routed operations (`find` / `get` / `count` / `save`) and
|
|
472
|
+
# mongo-direct reads (`results_direct`, `count_direct`, Atlas Search), which
|
|
473
|
+
# resolve the same ambient token when the query carries no explicit
|
|
474
|
+
# `session_token:`, `acl_user:`, or `use_master_key: true`.
|
|
455
475
|
#
|
|
456
476
|
# @raise [ArgumentError] if this client has no bound session token (scoping
|
|
457
477
|
# would be a no-op and almost certainly a mistake).
|
|
@@ -1098,6 +1118,22 @@ module Parse
|
|
|
1098
1118
|
@retry_limit
|
|
1099
1119
|
end
|
|
1100
1120
|
|
|
1121
|
+
# The longest one request through {#request} can take, in seconds,
|
|
1122
|
+
# counting every retry attempt at its full open and read timeout plus
|
|
1123
|
+
# the largest retry backoff. A server-supplied Retry-After is not
|
|
1124
|
+
# included. Used to size locks that must be held across a request.
|
|
1125
|
+
# @return [Float]
|
|
1126
|
+
def request_time_budget
|
|
1127
|
+
options = @conn.respond_to?(:options) ? @conn.options : nil
|
|
1128
|
+
read = options.respond_to?(:timeout) ? options.timeout.to_f : 0.0
|
|
1129
|
+
open = options.respond_to?(:open_timeout) ? options.open_timeout.to_f : 0.0
|
|
1130
|
+
read = 30.0 if read <= 0
|
|
1131
|
+
open = 5.0 if open <= 0
|
|
1132
|
+
retries = [retry_limit.to_i, 0].max
|
|
1133
|
+
backoff = (1..retries).sum { |attempt| RETRY_DELAY * attempt * 1.25 }
|
|
1134
|
+
(open + read) * (retries + 1) + backoff
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1101
1137
|
# @return [String] the url prefix of the Parse Server url.
|
|
1102
1138
|
def url_prefix
|
|
1103
1139
|
@conn.url_prefix
|
|
@@ -1221,6 +1257,15 @@ module Parse
|
|
|
1221
1257
|
_retry_count = nil
|
|
1222
1258
|
_retry_delay = nil
|
|
1223
1259
|
_request = nil
|
|
1260
|
+
# A Parse::Request carries its own per-request options (session_token,
|
|
1261
|
+
# use_master_key, cache, retry, ...). Merge them in before anything
|
|
1262
|
+
# below reads `opts`, or the auth resolution never sees them and a
|
|
1263
|
+
# request built for a session with `use_master_key: false` still goes
|
|
1264
|
+
# out with the configured master key. Options passed to this call
|
|
1265
|
+
# directly win over the request's own.
|
|
1266
|
+
if method.is_a?(Request) && method.opts.is_a?(Hash) && !method.opts.empty?
|
|
1267
|
+
opts = method.opts.merge(opts.is_a?(Hash) ? opts : {})
|
|
1268
|
+
end
|
|
1224
1269
|
# Kwarg-absorption guard. The `**opts` splat in API helper methods
|
|
1225
1270
|
# (lib/parse/api/*.rb) absorbs a caller-passed `opts: { ... }`
|
|
1226
1271
|
# keyword as a key named `:opts` rather than as the request options
|
|
@@ -1244,10 +1289,12 @@ module Parse
|
|
|
1244
1289
|
# attempt, turning a transient 500/503/429 into an infinite retry loop.
|
|
1245
1290
|
_retry_count ||= self.retry_limit
|
|
1246
1291
|
|
|
1292
|
+
# `retry: false` and `retry: 0` both disable retries; a positive
|
|
1293
|
+
# Integer sets the budget. Anything else keeps the client default.
|
|
1247
1294
|
if opts[:retry] == false
|
|
1248
1295
|
_retry_count = 0
|
|
1249
|
-
elsif opts[:retry].
|
|
1250
|
-
_retry_count = opts[:retry]
|
|
1296
|
+
elsif opts[:retry].is_a?(Numeric)
|
|
1297
|
+
_retry_count = [opts[:retry].to_i, 0].max
|
|
1251
1298
|
end
|
|
1252
1299
|
|
|
1253
1300
|
# The effective starting budget, captured ONCE after the opts override
|
|
@@ -1259,6 +1306,15 @@ module Parse
|
|
|
1259
1306
|
# silently disabling the backoff (every retry firing at zero delay).
|
|
1260
1307
|
_retry_max ||= _retry_count
|
|
1261
1308
|
|
|
1309
|
+
# Work on a copy of the caller's headers. The request id header is
|
|
1310
|
+
# written into this hash, and it must survive the `retry` keyword (so
|
|
1311
|
+
# it is set above the `begin`) without leaking into a hash the caller
|
|
1312
|
+
# reuses for its next request.
|
|
1313
|
+
headers = headers ? headers.dup : {}
|
|
1314
|
+
# Set once a request is re-sent after an ambiguous failure, so a
|
|
1315
|
+
# replayed DELETE that finds the object already gone can be recognized.
|
|
1316
|
+
_replayed = false
|
|
1317
|
+
|
|
1262
1318
|
begin
|
|
1263
1319
|
headers ||= {}
|
|
1264
1320
|
# if the first argument is a Parse::Request object, then construct it
|
|
@@ -1271,7 +1327,13 @@ module Parse
|
|
|
1271
1327
|
body ||= _request.body
|
|
1272
1328
|
headers.merge! _request.headers
|
|
1273
1329
|
else
|
|
1274
|
-
|
|
1330
|
+
# The metadata-master sentinel stays off the stored request: a
|
|
1331
|
+
# caller replaying `response.request` later must not inherit it.
|
|
1332
|
+
_request = Parse::Request.new(method, uri, body: body, headers: headers,
|
|
1333
|
+
opts: opts.except(:metadata_master))
|
|
1334
|
+
# Request copies the headers it is given, so carry its request id
|
|
1335
|
+
# back onto the outgoing headers.
|
|
1336
|
+
headers.merge!(_request.headers)
|
|
1275
1337
|
end
|
|
1276
1338
|
|
|
1277
1339
|
# http method
|
|
@@ -1297,6 +1359,14 @@ module Parse
|
|
|
1297
1359
|
# never sent unless the caller explicitly passed `use_master_key: true`
|
|
1298
1360
|
explicit_master = opts.key?(:use_master_key)
|
|
1299
1361
|
|
|
1362
|
+
# SDK metadata reads (class schemas, the role graph) keep the master
|
|
1363
|
+
# key inside `Parse.without_master_key`. Only the internal sentinel
|
|
1364
|
+
# turns this on; a `true` or any other value from a caller does not.
|
|
1365
|
+
# The marker goes on a per-attempt copy of the headers handed to the
|
|
1366
|
+
# connection (below), never on `headers` or the stored request, so a
|
|
1367
|
+
# retry, `response.request`, or an error reporter never carries it.
|
|
1368
|
+
metadata_master = opts[:metadata_master].equal?(METADATA_MASTER_REQUEST) && opts[:use_master_key] == true
|
|
1369
|
+
|
|
1300
1370
|
if opts[:use_master_key] == false
|
|
1301
1371
|
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
1302
1372
|
elsif Parse.client_mode && opts[:use_master_key] != true
|
|
@@ -1305,7 +1375,18 @@ module Parse
|
|
|
1305
1375
|
end
|
|
1306
1376
|
|
|
1307
1377
|
raw_token = opts[:session_token]
|
|
1308
|
-
#
|
|
1378
|
+
# A session token the caller put in the request headers directly
|
|
1379
|
+
# (`current_user(token)`, `fetch_session(token)`, `logout(token)`)
|
|
1380
|
+
# is as explicit as `session_token:`. Treat it as the per-call token
|
|
1381
|
+
# so the ambient `with_session` token or this client's bound token
|
|
1382
|
+
# cannot overwrite it: otherwise `User.session(token_a)` inside
|
|
1383
|
+
# `with_session(token_b)` resolved user B, and the identity cache
|
|
1384
|
+
# then mapped token A (or a garbage token) to user B for its TTL.
|
|
1385
|
+
if raw_token.nil?
|
|
1386
|
+
header_token = headers[Parse::Protocol::SESSION_TOKEN]
|
|
1387
|
+
raw_token = header_token if header_token.is_a?(String)
|
|
1388
|
+
end
|
|
1389
|
+
# SEC-02:an EXPLICITLY-supplied session_token that is a blank /
|
|
1309
1390
|
# whitespace-only string is an unusable credential — NOT an invitation
|
|
1310
1391
|
# to fall back to the master key. Treat it as "no credential"
|
|
1311
1392
|
# (anonymous) and fail closed: suppress the master key and send no
|
|
@@ -1325,7 +1406,15 @@ module Parse
|
|
|
1325
1406
|
# nested inside a `with_session(user)` block (or on a token-bound client)
|
|
1326
1407
|
# would silently downgrade. The ambient wins over the bound token so a
|
|
1327
1408
|
# `with_session` override inside a user-scoped client still takes effect.
|
|
1328
|
-
|
|
1409
|
+
# Inside `Parse.with_session(nil)` (or a token-less user) the block is
|
|
1410
|
+
# anonymous: no ambient, no bound token, and no master key, unless
|
|
1411
|
+
# this call passed a token or `use_master_key: true` itself.
|
|
1412
|
+
anonymous_block = token.nil? && !explicit_blank_token &&
|
|
1413
|
+
!(explicit_master && opts[:use_master_key] == true) &&
|
|
1414
|
+
Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
|
|
1415
|
+
if anonymous_block
|
|
1416
|
+
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
1417
|
+
elsif token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
|
|
1329
1418
|
ambient = Parse.current_session_token
|
|
1330
1419
|
# A whitespace-only ambient must not count as present: otherwise it
|
|
1331
1420
|
# blocks the bound-token fallback below and then fails the later
|
|
@@ -1336,6 +1425,7 @@ module Parse
|
|
|
1336
1425
|
if explicit_blank_token
|
|
1337
1426
|
# Fail closed: never send the master key for an unusable explicit token.
|
|
1338
1427
|
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
1428
|
+
headers.delete(Parse::Protocol::SESSION_TOKEN)
|
|
1339
1429
|
elsif token.present?
|
|
1340
1430
|
token = token.session_token if token.respond_to?(:session_token)
|
|
1341
1431
|
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
@@ -1346,7 +1436,12 @@ module Parse
|
|
|
1346
1436
|
params = (method == :get ? query : body) || {}
|
|
1347
1437
|
# if the path does not start with the '/1/' prefix, then add it to be nice.
|
|
1348
1438
|
# actually send the request and return the body
|
|
1349
|
-
|
|
1439
|
+
send_headers = headers
|
|
1440
|
+
if metadata_master
|
|
1441
|
+
send_headers = headers.merge(Parse::Middleware::Authentication::METADATA_MASTER =>
|
|
1442
|
+
Parse::Middleware::Authentication::METADATA_MASTER_TOKEN)
|
|
1443
|
+
end
|
|
1444
|
+
response_env = @conn.send(method, uri, params, send_headers)
|
|
1350
1445
|
response = response_env.body
|
|
1351
1446
|
response.request = _request
|
|
1352
1447
|
|
|
@@ -1355,11 +1450,25 @@ module Parse
|
|
|
1355
1450
|
Parse::Client._safe_warn("AuthenticationError", response)
|
|
1356
1451
|
raise Parse::Error::AuthenticationError, response
|
|
1357
1452
|
when 400, 408
|
|
1358
|
-
|
|
1453
|
+
# Only Parse's timeout code maps here. Code 143 is Parse Server's
|
|
1454
|
+
# WEBHOOK_ERROR (for example "no function named: foo is defined"),
|
|
1455
|
+
# not a timeout.
|
|
1456
|
+
if response.code == Parse::Response::ERROR_TIMEOUT
|
|
1359
1457
|
Parse::Client._safe_warn("TimeoutError", response)
|
|
1360
1458
|
raise Parse::Error::TimeoutError, response
|
|
1361
1459
|
end
|
|
1362
1460
|
when 404
|
|
1461
|
+
if response.object_not_found? && _replayed && method == :delete
|
|
1462
|
+
# An earlier attempt of this DELETE failed ambiguously and was
|
|
1463
|
+
# re-sent. "Object not found" on the replay means the first
|
|
1464
|
+
# attempt removed it, so report the delete as applied.
|
|
1465
|
+
warn "[Parse:Retry] #{_request} : object already deleted by an earlier attempt"
|
|
1466
|
+
applied = Parse::Response.new({})
|
|
1467
|
+
applied.http_status = 200
|
|
1468
|
+
applied.headers = response.headers
|
|
1469
|
+
applied.request = _request
|
|
1470
|
+
return applied
|
|
1471
|
+
end
|
|
1363
1472
|
unless response.object_not_found?
|
|
1364
1473
|
Parse::Client._safe_warn("ConnectionError", response)
|
|
1365
1474
|
raise Parse::Error::ConnectionError, response
|
|
@@ -1370,12 +1479,24 @@ module Parse
|
|
|
1370
1479
|
when 429 # Request over the throttle limit
|
|
1371
1480
|
Parse::Client._safe_warn("RequestLimitExceededError", response)
|
|
1372
1481
|
raise Parse::Error::RequestLimitExceededError, response
|
|
1373
|
-
when 500, 503
|
|
1482
|
+
when 500, 502, 503, 504
|
|
1483
|
+
# 502 and 504 come from a gateway or load balancer in front of
|
|
1484
|
+
# Parse Server. Like 500/503, the request may or may not have been
|
|
1485
|
+
# applied, so they are retried only when the request is idempotent.
|
|
1374
1486
|
Parse::Client._safe_warn("ServiceUnavailableError", response)
|
|
1375
1487
|
raise Parse::Error::ServiceUnavailableError, response
|
|
1376
1488
|
end
|
|
1377
1489
|
|
|
1378
1490
|
if response.error?
|
|
1491
|
+
if response.code == -1 && response.error.to_s.start_with?("Missing additional authData mfa")
|
|
1492
|
+
# Parse Server's OTHER_CAUSE (-1) for a password login on an
|
|
1493
|
+
# account with MFA enabled and no token supplied. It is a 400, not
|
|
1494
|
+
# an outage: without this branch the `<= 2` check below mapped it
|
|
1495
|
+
# to ServiceUnavailableError and retried the login. Surface the
|
|
1496
|
+
# typed error callers are documented to rescue to prompt for a code.
|
|
1497
|
+
Parse::Client._safe_warn("MFA::RequiredError", response)
|
|
1498
|
+
raise Parse::MFA::RequiredError
|
|
1499
|
+
end
|
|
1379
1500
|
if response.code <= Parse::Response::ERROR_SERVICE_UNAVAILABLE
|
|
1380
1501
|
Parse::Client._safe_warn("ServiceUnavailableError", response)
|
|
1381
1502
|
raise Parse::Error::ServiceUnavailableError, response
|
|
@@ -1404,10 +1525,11 @@ module Parse
|
|
|
1404
1525
|
# re-sending is safe for any method. 500/503 (ServiceUnavailable) is
|
|
1405
1526
|
# ambiguous — a write may have applied before the error — so only
|
|
1406
1527
|
# re-send when the request is idempotent (see #idempotent_retry?).
|
|
1407
|
-
retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers)
|
|
1528
|
+
retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers, uri)
|
|
1408
1529
|
if _retry_count > 0 && retryable
|
|
1409
|
-
warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response
|
|
1530
|
+
warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response&.request || _request}"
|
|
1410
1531
|
_retry_count -= 1
|
|
1532
|
+
_replayed = true unless e.is_a?(Parse::Error::RequestLimitExceededError)
|
|
1411
1533
|
# Use Retry-After header if available, otherwise use linear backoff
|
|
1412
1534
|
retry_after = response.retry_after if response.respond_to?(:retry_after)
|
|
1413
1535
|
if retry_after && retry_after > 0
|
|
@@ -1443,8 +1565,9 @@ module Parse
|
|
|
1443
1565
|
# Retrying only adds backoff latency and `[Parse:Retry]` noise
|
|
1444
1566
|
# before the inevitable error, so it propagates raw and fast.
|
|
1445
1567
|
raise unless connection_reset_error?(e)
|
|
1446
|
-
if _retry_count > 0 && idempotent_retry?(method, body, headers)
|
|
1568
|
+
if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
|
|
1447
1569
|
_retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
|
|
1570
|
+
_replayed = true
|
|
1448
1571
|
retry
|
|
1449
1572
|
end
|
|
1450
1573
|
raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
|
|
@@ -1458,8 +1581,9 @@ module Parse
|
|
|
1458
1581
|
# not `ClientError`, so it must be listed explicitly to be caught.
|
|
1459
1582
|
# `Faraday::ConnectionFailed` is handled in its own rescue above,
|
|
1460
1583
|
# split into retry-reset / fail-fast-refused.
|
|
1461
|
-
if _retry_count > 0 && idempotent_retry?(method, body, headers)
|
|
1584
|
+
if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
|
|
1462
1585
|
_retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
|
|
1586
|
+
_replayed = true
|
|
1463
1587
|
retry
|
|
1464
1588
|
end
|
|
1465
1589
|
raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
|
|
@@ -1547,9 +1671,12 @@ module Parse
|
|
|
1547
1671
|
# @param body [Hash, Object, nil] the request body.
|
|
1548
1672
|
# @param headers [Hash, nil] the outgoing request headers (consulted for
|
|
1549
1673
|
# the request-id header on the server-dedup fast path).
|
|
1674
|
+
# @param uri [String, nil] the request path, used to confirm the
|
|
1675
|
+
# endpoint is one Parse Server deduplicates. Without it the
|
|
1676
|
+
# server-dedup fast path does not apply.
|
|
1550
1677
|
# @return [Boolean]
|
|
1551
|
-
def idempotent_retry?(method, body, headers = nil)
|
|
1552
|
-
return true if server_deduped_request?(headers)
|
|
1678
|
+
def idempotent_retry?(method, body, headers = nil, uri = nil)
|
|
1679
|
+
return true if server_deduped_request?(headers, method, uri)
|
|
1553
1680
|
case method
|
|
1554
1681
|
when :get, :delete then true
|
|
1555
1682
|
when :put then !body_carries_atomic_op?(body)
|
|
@@ -1557,34 +1684,90 @@ module Parse
|
|
|
1557
1684
|
end
|
|
1558
1685
|
end
|
|
1559
1686
|
|
|
1687
|
+
# Routes on which Parse Server runs its request-id deduplication
|
|
1688
|
+
# (`promiseEnsureIdempotency`), by HTTP method. Paths are relative to the
|
|
1689
|
+
# mount point. Every other endpoint (notably `POST /batch`, files, push,
|
|
1690
|
+
# login, logout, schemas, config, and hooks) applies a replay again, so
|
|
1691
|
+
# a request to one of them is never treated as server-deduplicated.
|
|
1692
|
+
SERVER_DEDUP_ROUTES = {
|
|
1693
|
+
post: [
|
|
1694
|
+
%r{\Aclasses/[^/]+\z},
|
|
1695
|
+
%r{\Ausers\z},
|
|
1696
|
+
%r{\Ainstallations\z},
|
|
1697
|
+
%r{\Afunctions/[^/]+\z},
|
|
1698
|
+
%r{\Ajobs/[^/]+\z},
|
|
1699
|
+
].freeze,
|
|
1700
|
+
put: [
|
|
1701
|
+
%r{\Aclasses/[^/]+/[^/]+\z},
|
|
1702
|
+
%r{\Ausers/[^/]+\z},
|
|
1703
|
+
%r{\Ainstallations/[^/]+\z},
|
|
1704
|
+
].freeze,
|
|
1705
|
+
}.freeze
|
|
1706
|
+
|
|
1560
1707
|
# Whether this request is covered by Parse Server's server-side request-id
|
|
1561
1708
|
# 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).
|
|
1709
|
+
# opted in via {Parse::Request.assume_server_idempotency}, the request
|
|
1710
|
+
# carries a non-blank request-id header, AND it targets a route Parse
|
|
1711
|
+
# Server deduplicates (see {SERVER_DEDUP_ROUTES}).
|
|
1566
1712
|
# @param headers [Hash, nil] the outgoing request headers.
|
|
1713
|
+
# @param method [Symbol, nil] the HTTP method.
|
|
1714
|
+
# @param uri [String, nil] the request path.
|
|
1567
1715
|
# @return [Boolean]
|
|
1568
|
-
def server_deduped_request?(headers)
|
|
1716
|
+
def server_deduped_request?(headers, method = nil, uri = nil)
|
|
1569
1717
|
return false unless Parse::Request.assume_server_idempotency
|
|
1570
1718
|
return false unless headers.is_a?(Hash)
|
|
1571
1719
|
rid = headers[Parse::Request.request_id_header]
|
|
1572
|
-
rid.is_a?(String) && !rid.strip.empty?
|
|
1720
|
+
return false unless rid.is_a?(String) && !rid.strip.empty?
|
|
1721
|
+
routes = SERVER_DEDUP_ROUTES[method.to_s.downcase.to_sym] if method
|
|
1722
|
+
return false if routes.nil?
|
|
1723
|
+
path = api_relative_path(uri)
|
|
1724
|
+
return false if path.nil?
|
|
1725
|
+
routes.any? { |route| path.match?(route) }
|
|
1573
1726
|
end
|
|
1574
1727
|
|
|
1575
|
-
#
|
|
1576
|
-
#
|
|
1577
|
-
#
|
|
1578
|
-
#
|
|
1579
|
-
#
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1728
|
+
# The request path relative to the Parse mount point, without a query
|
|
1729
|
+
# string or surrounding slashes. Accepts relative paths (`classes/Post`)
|
|
1730
|
+
# and absolute ones that include the mount prefix (`/parse/classes/Post`).
|
|
1731
|
+
# @param uri [String, nil]
|
|
1732
|
+
# @return [String, nil]
|
|
1733
|
+
def api_relative_path(uri)
|
|
1734
|
+
return nil if uri.nil?
|
|
1735
|
+
path = uri.to_s.split("?", 2).first.to_s
|
|
1736
|
+
prefix = url_prefix.path.to_s rescue ""
|
|
1737
|
+
prefix = prefix.chomp("/")
|
|
1738
|
+
if !prefix.empty? && path.start_with?("#{prefix}/")
|
|
1739
|
+
path = path.delete_prefix(prefix)
|
|
1740
|
+
end
|
|
1741
|
+
# Trim slashes with string operations rather than an end-anchored
|
|
1742
|
+
# regex, which backtracks polynomially on long runs of "/".
|
|
1743
|
+
path = path.delete_prefix("/") while path.start_with?("/")
|
|
1744
|
+
path = path.delete_suffix("/") while path.end_with?("/")
|
|
1745
|
+
path
|
|
1746
|
+
end
|
|
1747
|
+
|
|
1748
|
+
# Whether a request body carries a Parse operation: an `__op` key at any
|
|
1749
|
+
# depth, including the top level. That covers field ops (Increment, Add,
|
|
1750
|
+
# AddUnique, Remove, AddRelation, RemoveRelation, Delete), nested ops such
|
|
1751
|
+
# as a schema update's `fields: { name: { __op: "Delete" } }`, and
|
|
1752
|
+
# whole-body ops such as a hook delete's `{ __op: "Delete" }`. Replaying
|
|
1753
|
+
# any of them after the first attempt applied turns a landed write into a
|
|
1754
|
+
# reported failure, or applies it twice. A pre-serialized String body is
|
|
1755
|
+
# scanned for the literal `"__op"` key.
|
|
1583
1756
|
# @param body [Object] the request body.
|
|
1584
1757
|
# @return [Boolean]
|
|
1585
|
-
def body_carries_atomic_op?(body)
|
|
1586
|
-
return false
|
|
1587
|
-
body
|
|
1758
|
+
def body_carries_atomic_op?(body, depth = 0)
|
|
1759
|
+
return false if depth > 32
|
|
1760
|
+
case body
|
|
1761
|
+
when Hash
|
|
1762
|
+
body.key?("__op") || body.key?(:__op) ||
|
|
1763
|
+
body.each_value.any? { |v| body_carries_atomic_op?(v, depth + 1) }
|
|
1764
|
+
when Array
|
|
1765
|
+
body.any? { |v| body_carries_atomic_op?(v, depth + 1) }
|
|
1766
|
+
when String
|
|
1767
|
+
depth.zero? && body.include?('"__op"')
|
|
1768
|
+
else
|
|
1769
|
+
false
|
|
1770
|
+
end
|
|
1588
1771
|
end
|
|
1589
1772
|
|
|
1590
1773
|
# Send a GET request.
|
|
@@ -1629,7 +1812,11 @@ module Parse
|
|
|
1629
1812
|
# @return (see #request)
|
|
1630
1813
|
def send_request(req) #Parse::Request object
|
|
1631
1814
|
raise ArgumentError, "Object not of Parse::Request type." unless req.is_a?(Parse::Request)
|
|
1632
|
-
request
|
|
1815
|
+
# Forward the request's own options so its session token, master-key
|
|
1816
|
+
# opt-out, cache directive and retry budget reach the auth resolution.
|
|
1817
|
+
# {#request} also merges them; passing them here keeps the contract
|
|
1818
|
+
# explicit at the public entry point.
|
|
1819
|
+
request req, opts: (req.opts || {})
|
|
1633
1820
|
end
|
|
1634
1821
|
|
|
1635
1822
|
# The connectable module adds methods to objects so that they can get a default
|