vortexpy 6.2.5__tar.gz → 6.2.6__tar.gz

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.
Files changed (160) hide show
  1. {vortexpy-6.2.5 → vortexpy-6.2.6}/PKG-INFO +1 -1
  2. {vortexpy-6.2.5 → vortexpy-6.2.6}/pyproject.toml +1 -1
  3. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/DeferUtil.py +16 -0
  4. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServer.py +21 -0
  5. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServer_test.py +38 -0
  6. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexSession.py +7 -2
  7. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexUtil.py +105 -1
  8. vortexpy-6.2.6/vortex/VortexUtil_test.py +292 -0
  9. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/__init__.py +1 -1
  10. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableHandler.py +34 -5
  11. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableHandler_test.py +46 -0
  12. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/twisted_runtime_metrics.py +17 -0
  13. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortexpy.egg-info/PKG-INFO +1 -1
  14. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortexpy.egg-info/SOURCES.txt +1 -0
  15. {vortexpy-6.2.5 → vortexpy-6.2.6}/README.md +0 -0
  16. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/TwistedDeferredTimeout.py +0 -0
  17. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/__init__.py +0 -0
  18. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_ping_client.py +0 -0
  19. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_ping_server.py +0 -0
  20. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_rpc_test_client.py +0 -0
  21. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_rpc_test_server.py +0 -0
  22. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/run_websocket_rpc_test_client.py +0 -0
  23. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/run_websocket_rpc_test_server.py +0 -0
  24. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/setup_example_logging.py +0 -0
  25. {vortexpy-6.2.5 → vortexpy-6.2.6}/example/store_and_load_json.py +0 -0
  26. {vortexpy-6.2.5 → vortexpy-6.2.6}/setup.cfg +0 -0
  27. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/DataWrapTuple.py +0 -0
  28. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/DeferUtil_test.py +0 -0
  29. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/Jsonable.py +0 -0
  30. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/MtlsAuthorizedClientRegistry.py +0 -0
  31. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/MtlsAuthorizedClientRegistry_test.py +0 -0
  32. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/MtlsServerSslOptions.py +0 -0
  33. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/Payload.py +0 -0
  34. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadEndpoint.py +0 -0
  35. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadEndpointTest.py +0 -0
  36. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadEnvelope.py +0 -0
  37. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadFilterKeys.py +0 -0
  38. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadIO.py +0 -0
  39. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadPriority.py +0 -0
  40. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadResponse.py +0 -0
  41. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadResponseTest.py +0 -0
  42. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadResponse_test.py +0 -0
  43. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PayloadTest.py +0 -0
  44. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/PerformTestActionTuple.py +0 -0
  45. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/SerialiseUtil.py +0 -0
  46. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/SerialiseUtil_test.py +0 -0
  47. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TestTuple.py +0 -0
  48. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/Tuple.py +0 -0
  49. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleAction.py +0 -0
  50. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleBson.py +0 -0
  51. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleBson_test.py +0 -0
  52. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleDataForTest.py +0 -0
  53. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleDocumentConverter.py +0 -0
  54. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TuplePerfTest.py +0 -0
  55. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
  56. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleRestful.py +0 -0
  57. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleRestful_test.py +0 -0
  58. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleSelector.py +0 -0
  59. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/TupleTest.py +0 -0
  60. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexABC.py +0 -0
  61. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexAccessControlContextABC.py +0 -0
  62. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexAuthenticatorABC.py +0 -0
  63. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexClientHttp.py +0 -0
  64. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexClientHttpTest.py +0 -0
  65. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexClientTcp.py +0 -0
  66. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexClientWebsocketFactory.py +0 -0
  67. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexConnectionABC.py +0 -0
  68. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexEndpointPermission.py +0 -0
  69. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexFactory.py +0 -0
  70. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexFactory_test.py +0 -0
  71. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexPayloadProtocol.py +0 -0
  72. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexPermission.py +0 -0
  73. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexPermissionError.py +0 -0
  74. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServerConnection.py +0 -0
  75. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServerConnection_test.py +0 -0
  76. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServerHttpResource.py +0 -0
  77. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServerTcp.py +0 -0
  78. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServerTcp_test.py +0 -0
  79. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServerWebsocket.py +0 -0
  80. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexServerWebsocket_test.py +0 -0
  81. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexSession_test.py +0 -0
  82. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
  83. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
  84. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexTest.py +0 -0
  85. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexUserDetailsABC.py +0 -0
  86. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexWritePushProducer.py +0 -0
  87. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/VortexWritePushProducer_test.py +0 -0
  88. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
  89. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLiveLockManager_test.py +0 -0
  90. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoader.py +0 -0
  91. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
  92. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
  93. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
  94. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/data_loader/__init__.py +0 -0
  95. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/event_bus/TupleChangeEventABC.py +0 -0
  96. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/event_bus/TupleChangeEventBus.py +0 -0
  97. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
  98. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/event_bus/__init__.py +0 -0
  99. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessor.py +0 -0
  100. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessorProxy.py +0 -0
  101. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
  102. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessor_shutdown_test.py +0 -0
  103. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessor_test.py +0 -0
  104. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleActionPushOffline.py +0 -0
  105. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleActionPushOffline_test.py +0 -0
  106. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
  107. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataActionClient.py +0 -0
  108. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataActionClient_test.py +0 -0
  109. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableCache.py +0 -0
  110. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableCache_test.py +0 -0
  111. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableProxyHandler.py +0 -0
  112. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableProxyHandler_test.py +0 -0
  113. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
  114. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataObserverClient.py +0 -0
  115. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataOfflineObserverService.py +0 -0
  116. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataOfflineObserverService_test.py +0 -0
  117. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/TupleDataProviderABC.py +0 -0
  118. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
  119. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/handler/__init__.py +0 -0
  120. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/mypy_plugin.py +0 -0
  121. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/payload_many_tuples_benchmark_test.py +0 -0
  122. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/py.typed +0 -0
  123. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/GzippedDataHttpClient.py +0 -0
  124. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
  125. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
  126. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/RestfulHttpClient.py +0 -0
  127. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/RestfulResource.py +0 -0
  128. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/__init__.py +0 -0
  129. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/restful_http_client_test.py +0 -0
  130. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/restful/restful_resource_test.py +0 -0
  131. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/rpc/RPC.py +0 -0
  132. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/rpc/RPCTest.py +0 -0
  133. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/rpc/RPC_test.py +0 -0
  134. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/rpc/__init__.py +0 -0
  135. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
  136. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
  137. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/sqla_orm/VortexTupleJson.py +0 -0
  138. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/sqla_orm/__init__.py +0 -0
  139. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleActionStorageSqlite.py +0 -0
  140. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageNameService.py +0 -0
  141. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageSecureService.py +0 -0
  142. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageSecureService_test.py +0 -0
  143. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageService.py +0 -0
  144. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleStorageServiceABC.py +0 -0
  145. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleStorageSqlite.py +0 -0
  146. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleStorageSqliteService.py +0 -0
  147. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/TupleStorageSqliteService_test.py +0 -0
  148. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/storage/__init__.py +0 -0
  149. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/tuple_json_schema/__init__.py +0 -0
  150. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/tuple_json_schema/constants.py +0 -0
  151. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
  152. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
  153. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
  154. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
  155. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/tuple_test_fixtures.py +0 -0
  156. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortex/twisted_runtime_metrics_test.py +0 -0
  157. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortexpy.egg-info/dependency_links.txt +0 -0
  158. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortexpy.egg-info/entry_points.txt +0 -0
  159. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortexpy.egg-info/requires.txt +0 -0
  160. {vortexpy-6.2.5 → vortexpy-6.2.6}/vortexpy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortexpy
3
- Version: 6.2.5
3
+ Version: 6.2.6
4
4
  Summary: Flying Ion's observable, routable, data serialisation and transport code.
5
5
  Author-email: Flying Ion Pty Ltd <contact@flyingion.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vortexpy"
7
- version = "6.2.5"
7
+ version = "6.2.6"
8
8
  license = {text = "MIT"}
9
9
  authors = [{name = "Flying Ion Pty Ltd", email = "contact@flyingion.com"}]
10
10
  description = "Flying Ion's observable, routable, data serialisation and transport code."
@@ -16,6 +16,7 @@ import twisted
16
16
  from twisted.internet import reactor
17
17
  from twisted.internet.defer import (
18
18
  maybeDeferred,
19
+ CancelledError,
19
20
  Deferred,
20
21
  inlineCallbacks,
21
22
  ensureDeferred,
@@ -239,6 +240,17 @@ def deferToThreadWrapWithLogger(
239
240
  )
240
241
 
241
242
  def handleFailure(failure):
243
+ if failure.check(CancelledError):
244
+ # Cancellation is designed control flow (for example a
245
+ # limitConcurrency timeout quarantining the thread), not
246
+ # a threaded call failure.
247
+ loggerArg.debug(
248
+ "Threaded call cancelled in %s.%s",
249
+ funcToWrap.__module__,
250
+ funcToWrap.__qualname__,
251
+ )
252
+ return True if consumeError else failure
253
+
242
254
  if logException:
243
255
  return vortexLogFailure(
244
256
  failure, loggerArg, consumeError=consumeError
@@ -253,6 +265,10 @@ def deferToThreadWrapWithLogger(
253
265
  return True if consumeError else failure
254
266
 
255
267
  d: Deferred[_R] = deferToThread(timedFuncToRun, *args, **kwargs)
268
+ # Marks this Deferred as threaded for limitConcurrency: a timed
269
+ # out threaded call cannot be killed, so the limiter accumulates
270
+ # marked Deferreds in its quarantine registry.
271
+ d._isVortexThreadedCall = True # type: ignore[attr-defined]
256
272
  d.addCallback(checkForInlineCallbackYield)
257
273
  d.addErrback(handleFailure)
258
274
  return d
@@ -27,6 +27,7 @@ from zope.interface.declarations import implementer
27
27
  from .DeferUtil import isMainThread
28
28
  from .DeferUtil import vortexLogFailure
29
29
  from .VortexSession import IVortexSession
30
+ from .VortexSession import VortexTcpSession
30
31
  from .PayloadEnvelope import PayloadEnvelope
31
32
  from .PayloadEnvelope import VortexMsgList
32
33
  from .PayloadFilterKeys import rapuiServerEcho
@@ -394,3 +395,23 @@ class VortexSession(object):
394
395
 
395
396
 
396
397
  registerAdapter(VortexSession, Session, VortexSessionI)
398
+
399
+
400
+ def _tcpSessionVortexSessionIAdapter(session: VortexTcpSession) -> VortexSession:
401
+ """Adapt a :class:`VortexTcpSession` to :class:`VortexSessionI`.
402
+
403
+ Twisted ``Session`` instances cache adapters through
404
+ ``Componentized.__conform__``; the TCP stub is not a ``Componentized``,
405
+ so the adapter is cached on the stub's own component store — without
406
+ this, every adaptation would return a fresh adapter with an empty
407
+ ``connections`` dict and ``connectionOpened`` registrations would be
408
+ lost.
409
+ """
410
+ adapter = session.getComponent(VortexSessionI)
411
+ if adapter is None:
412
+ adapter = VortexSession(session)
413
+ session.setComponent(VortexSessionI, adapter)
414
+ return adapter
415
+
416
+
417
+ registerAdapter(_tcpSessionVortexSessionIAdapter, VortexTcpSession, VortexSessionI)
@@ -14,6 +14,7 @@ from vortex.VortexServer import (
14
14
  VortexSessionI,
15
15
  )
16
16
  from vortex.VortexServerWebsocket import VortexWebsocketServerProtocol
17
+ from vortex.VortexSession import VortexTcpSession
17
18
 
18
19
 
19
20
  class _MockServerConnection:
@@ -217,6 +218,43 @@ class VortexSessionAdapterTimeoutTest(SynchronousTestCase):
217
218
  self.assertEqual(session.sessionTimeout, HEART_BEAT_TIMEOUT)
218
219
 
219
220
 
221
+ class VortexTcpSessionAdapterTest(SynchronousTestCase):
222
+ def testTcpStubAdaptsToVortexSessionI(self) -> None:
223
+ stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
224
+
225
+ adapter = VortexSessionI(stub)
226
+
227
+ self.assertEqual(adapter.connections, {})
228
+
229
+ def testTcpStubAdaptationIsCached(self) -> None:
230
+ stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
231
+
232
+ first = VortexSessionI(stub)
233
+ first.connections["uuid-a"] = object()
234
+
235
+ self.assertIs(VortexSessionI(stub), first)
236
+ self.assertIn("uuid-a", VortexSessionI(stub).connections)
237
+
238
+ def testTcpStubTimeoutRaisedToHeartbeatFloor(self) -> None:
239
+ stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
240
+ stub.sessionTimeout = 5
241
+
242
+ VortexSessionI(stub)
243
+
244
+ self.assertEqual(stub.sessionTimeout, HEART_BEAT_TIMEOUT)
245
+
246
+ def testConnectionOpenedAndClosedWithTcpStub(self) -> None:
247
+ vortexServer = VortexServer("test-server")
248
+ stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
249
+ conn = _MockServerConnection(vortexServer, "uuid-a", "remote", stub)
250
+
251
+ vortexServer.connectionOpened(stub, conn)
252
+ self.assertIn("uuid-a", VortexSessionI(stub).connections)
253
+
254
+ vortexServer.connectionClosed(conn)
255
+ self.assertNotIn("uuid-a", VortexSessionI(stub).connections)
256
+
257
+
220
258
  class VortexWebsocketServerProtocolConnectionLostTest(SynchronousTestCase):
221
259
  def testConnectionLostTwiceDoesNotRaise(self) -> None:
222
260
  vortex_server = VortexServer("test-server")
@@ -65,12 +65,17 @@ class VortexTcpSession:
65
65
 
66
66
  TCP connections have no HTTP session. This stub exposes the subset of
67
67
  the Twisted ``Session`` API that vortex code uses (``uid``, ``touch``,
68
- ``notifyOnExpire``, ``expire``, ``getComponent``, ``setComponent``) so
69
- the rest of the codebase can treat all sessions uniformly.
68
+ ``notifyOnExpire``, ``expire``, ``getComponent``, ``setComponent``,
69
+ ``sessionTimeout``) so the rest of the codebase can treat all sessions
70
+ uniformly.
70
71
 
71
72
  A :class:`VortexSession` is attached immediately on construction.
72
73
  """
73
74
 
75
+ # Mirrors twisted.web.server.Session.sessionTimeout; the
76
+ # VortexSessionI adapter factory in VortexServer reads and raises it.
77
+ sessionTimeout = 900
78
+
74
79
  def __init__(self, ip: str, vortexName: str):
75
80
  self.uid: bytes = uuid4().bytes
76
81
  self._expireCallbacks = []
@@ -7,6 +7,7 @@
7
7
  # Support : contact@flyingion.com
8
8
 
9
9
  import json
10
+ from collections import deque
10
11
  from collections import namedtuple
11
12
  from datetime import datetime
12
13
  from datetime import timedelta
@@ -20,10 +21,10 @@ from uuid import uuid4
20
21
 
21
22
  import pytz
22
23
  from twisted.internet import reactor
24
+ from twisted.internet.defer import CancelledError
23
25
  from twisted.internet.defer import Deferred
24
26
  from twisted.internet.defer import fail
25
27
  from twisted.internet.defer import maybeDeferred
26
- from twisted.python.failure import Failure
27
28
 
28
29
  from vortex.PayloadEnvelope import PayloadEnvelope
29
30
  from vortex.PayloadEnvelope import VortexMsgList
@@ -37,6 +38,20 @@ _LimitConcurrencyArgsTuple = namedtuple(
37
38
  ("deferred", "funcSelf", "args", "kwargs", "queuedTime"),
38
39
  )
39
40
 
41
+ _LimitConcurrencyQuarantinedCallTuple = namedtuple(
42
+ "_LimitConcurrencyQuarantinedCallTuple",
43
+ ("methodQualname", "callKey", "startDate", "timeoutDate", "deferred"),
44
+ )
45
+
46
+
47
+ class LimitConcurrencyTimeoutError(Exception):
48
+ """Limit Concurrency Timeout Error
49
+
50
+ Raised on the caller's Deferred when a call started by
51
+ L{limitConcurrency} exceeds C{killOrQuarantineCallsLongerThanSeconds}
52
+ and is cancelled.
53
+ """
54
+
40
55
 
41
56
  def _dedupProcessorCallKeys(
42
57
  payloadEnvelope: PayloadEnvelope,
@@ -56,6 +71,7 @@ def limitConcurrency(
56
71
  concurrency: int,
57
72
  logTaskTimeGreaterThanSeconds=5.0,
58
73
  deduplicateBasedOnKwArgsKey: Optional[Callable] = None,
74
+ killOrQuarantineCallsLongerThanSeconds: Optional[float] = None,
59
75
  ):
60
76
  """Limit Concurrency to a call, decorator
61
77
 
@@ -66,6 +82,17 @@ def limitConcurrency(
66
82
  of strings. If this argument is provided,
67
83
  then duplicate calls with the same values for these arguments will
68
84
  be deduplicated. Duplicate calls will use the newest set of arguments.
85
+ :param killOrQuarantineCallsLongerThanSeconds: When set, any started call
86
+ still running after this many seconds is cancelled: @inlineCallbacks
87
+ callees are killed (CancelledError reaches the generator), threaded
88
+ callees are quarantined (the caller's Deferred is released while the
89
+ thread runs on). An error is logged, the limiter's alarm count is
90
+ incremented in TwistedRuntimeMetrics, the caller's Deferred
91
+ errbacks with LimitConcurrencyTimeoutError. Timed out threaded
92
+ calls (deferToThreadWrapWithLogger Deferreds) are accumulated with
93
+ their Deferred reference in the limiter's bounded _quarantinedCalls
94
+ deque for manhole inspection; killed @inlineCallbacks callees die
95
+ and are not registered.
69
96
 
70
97
  # As of python 3.6, dicts are ordered.
71
98
 
@@ -97,6 +124,8 @@ def limitConcurrency(
97
124
  self._callQueue: dict[Any, _LimitConcurrencyArgsTuple] = {}
98
125
  self._runningCount = 0
99
126
  self._processedCount = 0
127
+ self._callTimeoutCount = 0
128
+ self._quarantinedCalls: deque = deque(maxlen=50)
100
129
  self._lastLogTime = None
101
130
  self._lastQueueProcessingDelta = None
102
131
  TwistedRuntimeMetrics._collector()._registerLimitConcurrencyLimiter(
@@ -146,12 +175,87 @@ def limitConcurrency(
146
175
  )
147
176
  d = maybeDeferred(callWithArgs)
148
177
  d.addBoth(self._callComplete, now, queueWaitMs)
178
+
179
+ if killOrQuarantineCallsLongerThanSeconds is not None and not d.called:
180
+ self._utilArmCallTimeout(
181
+ d, killOrQuarantineCallsLongerThanSeconds, nextKey, now
182
+ )
183
+
149
184
  d.chainDeferred(args.deferred)
150
185
 
151
186
  self._logQueueSize()
152
187
  self._f.running = self._runningCount
153
188
  self._f.waiting = len(self._callQueue)
154
189
 
190
+ def _utilArmCallTimeout(self, d, killSeconds, callKey, startDate):
191
+ def onTimeout(d=d, callKey=callKey, startDate=startDate):
192
+ if d.called:
193
+ return
194
+
195
+ # A deferToThreadWrapWithLogger-marked Deferred means the
196
+ # callee is a thread that cannot be killed: it is
197
+ # quarantined, and the Deferred is accumulated in the
198
+ # bounded registry for manhole inspection. maybeDeferred
199
+ # passes a threaded callee's Deferred through as d; an
200
+ # @inlineCallbacks callee pauses d on it as d.result.
201
+ # Killed @inlineCallbacks callees die and are not registered.
202
+ if getattr(d, "_isVortexThreadedCall", False):
203
+ quarantinedDeferred = d
204
+ else:
205
+ awaitedDeferred = getattr(d, "result", None)
206
+ quarantinedDeferred = (
207
+ awaitedDeferred
208
+ if getattr(awaitedDeferred, "_isVortexThreadedCall", False)
209
+ else None
210
+ )
211
+
212
+ self._callTimeoutCount += 1
213
+ if quarantinedDeferred is not None:
214
+ self._quarantinedCalls.append(
215
+ _LimitConcurrencyQuarantinedCallTuple(
216
+ methodQualname=self._methodQualname,
217
+ callKey=callKey,
218
+ startDate=startDate,
219
+ timeoutDate=datetime.now(pytz.utc),
220
+ deferred=quarantinedDeferred,
221
+ )
222
+ )
223
+ TwistedRuntimeMetrics._collector()._recordLimitConcurrencyCallTimeout(
224
+ self._methodQualname
225
+ )
226
+ decoratorLogger.error(
227
+ "%s call exceeded killOrQuarantineCallsLongerThanSeconds"
228
+ " of %ss (key=%s, timeout count=%s),"
229
+ " killing or quarantining the call",
230
+ self._methodQualname,
231
+ killSeconds,
232
+ callKey,
233
+ self._callTimeoutCount,
234
+ )
235
+ d.cancel()
236
+
237
+ timeoutCall = reactor.callLater(killSeconds, onTimeout)
238
+
239
+ def cancelTimeoutCall(result):
240
+ if timeoutCall.active():
241
+ timeoutCall.cancel()
242
+ return result
243
+
244
+ def convertCancelledToTimeout(failure):
245
+ # The watchdog's own `called` flag marks whether this
246
+ # cancellation came from it: DelayedCall.called is only set
247
+ # once onTimeout begins running.
248
+ if not timeoutCall.called:
249
+ return failure
250
+ failure.trap(CancelledError)
251
+ raise LimitConcurrencyTimeoutError(
252
+ "%s call exceeded killOrQuarantineCallsLongerThanSeconds"
253
+ " of %ss" % (self._methodQualname, killSeconds)
254
+ )
255
+
256
+ d.addBoth(cancelTimeoutCall)
257
+ d.addErrback(convertCancelledToTimeout)
258
+
155
259
  def call(self, funcSelf, *args, **kwargs):
156
260
  if deduplicateBasedOnKwArgsKey:
157
261
  key = deduplicateBasedOnKwArgsKey(*args, **kwargs)
@@ -0,0 +1,292 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ # This software is open source; the MIT license applies.
3
+ # See LICENSE for the full license text.
4
+ #
5
+ # Website : https://flyingion.com
6
+ # Support : contact@flyingion.com
7
+
8
+ from __future__ import annotations
9
+
10
+ import gc
11
+ import logging
12
+ import threading
13
+ from collections.abc import Generator
14
+
15
+ import pytest
16
+ from pytest_twisted import ensureDeferred # type: ignore[import-untyped]
17
+ from twisted.internet import threads
18
+ from twisted.internet.defer import CancelledError
19
+ from twisted.internet.defer import Deferred
20
+ from twisted.internet.defer import inlineCallbacks
21
+ from twisted.internet.task import Clock
22
+ from twisted.python.failure import Failure
23
+
24
+ from vortex import VortexUtil
25
+ from vortex.DeferUtil import deferToThreadWrapWithLogger
26
+ from vortex.VortexUtil import LimitConcurrencyTimeoutError
27
+ from vortex.VortexUtil import limitConcurrency
28
+ from vortex.twisted_runtime_metrics import TwistedRuntimeMetrics
29
+ from vortex.twisted_runtime_metrics_test import (
30
+ utilResetTwistedRuntimeMetricsSingletonForTests,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _KILL_SECONDS = 10.0
36
+
37
+
38
+ @pytest.fixture(autouse=True)
39
+ def resetMetricsSingleton() -> Generator[None, None, None]:
40
+ utilResetTwistedRuntimeMetricsSingletonForTests()
41
+ yield
42
+ utilResetTwistedRuntimeMetricsSingletonForTests()
43
+
44
+
45
+ @pytest.fixture
46
+ def clock(monkeypatch: pytest.MonkeyPatch) -> Clock:
47
+ clock = Clock()
48
+ monkeypatch.setattr(VortexUtil, "reactor", clock)
49
+ return clock
50
+
51
+
52
+ class _SwitchableWorker:
53
+ """One decorated method: parks forever or completes, by flag."""
54
+
55
+ def __init__(self) -> None:
56
+ self.behaviour = "stuck"
57
+ self.cancelledCount = 0
58
+
59
+ @limitConcurrency(logger, 1, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS)
60
+ @inlineCallbacks
61
+ def work(self) -> Generator[Deferred, object, str]:
62
+ if self.behaviour == "fast":
63
+ return "done"
64
+
65
+ try:
66
+ yield Deferred() # Never fires, the call is stuck
67
+ except CancelledError:
68
+ self.cancelledCount += 1
69
+ raise
70
+ return "unreachable"
71
+
72
+
73
+ class _ConcurrencyTwoWorker:
74
+ def __init__(self) -> None:
75
+ self.cancelledCount = 0
76
+
77
+ @limitConcurrency(logger, 2, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS)
78
+ @inlineCallbacks
79
+ def work(self) -> Generator[Deferred, object, str]:
80
+ try:
81
+ yield Deferred() # Never fires, the call is stuck
82
+ except CancelledError:
83
+ self.cancelledCount += 1
84
+ raise
85
+ return "unreachable"
86
+
87
+
88
+ class _NoTimeoutWorker:
89
+ @limitConcurrency(logger, 1)
90
+ @inlineCallbacks
91
+ def work(self) -> Generator[Deferred, object, str]:
92
+ yield Deferred() # Never fires, the call is stuck
93
+ return "unreachable"
94
+
95
+
96
+ class _ThreadedWorker:
97
+ """A limiter-wrapped call that blocks in a real threadpool thread."""
98
+
99
+ def __init__(self) -> None:
100
+ self.behaviour = "stuck"
101
+ self.threadStarted = threading.Event()
102
+ self.threadRelease = threading.Event()
103
+ self.threadFinished = threading.Event()
104
+ self.threadCompleted = False
105
+
106
+ @limitConcurrency(logger, 1, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS)
107
+ def work(self) -> Deferred:
108
+ return self._threadedWork()
109
+
110
+ @deferToThreadWrapWithLogger(logger)
111
+ def _threadedWork(self) -> str:
112
+ if self.behaviour == "fast":
113
+ return "done"
114
+
115
+ self.threadStarted.set()
116
+ self.threadRelease.wait(timeout=30)
117
+ self.threadCompleted = True
118
+ self.threadFinished.set()
119
+ return "thread-done"
120
+
121
+
122
+ def _callerFailure(caller: Deferred) -> Failure:
123
+ assert caller.called, "caller Deferred must have fired"
124
+ assert isinstance(caller.result, Failure), (
125
+ f"caller must errback, got {caller.result!r}"
126
+ )
127
+ return caller.result
128
+
129
+
130
+ def test_stuck_call_times_out_kills_generator_and_frees_slot(clock) -> None:
131
+ worker = _SwitchableWorker()
132
+
133
+ caller1 = worker.work()
134
+ assert not caller1.called, "stuck call must not fire before the timeout"
135
+
136
+ clock.advance(_KILL_SECONDS)
137
+
138
+ failure = _callerFailure(caller1)
139
+ failure.trap(LimitConcurrencyTimeoutError)
140
+ assert worker.cancelledCount == 1, (
141
+ "CancelledError must reach the inlineCallbacks generator"
142
+ )
143
+
144
+ worker.behaviour = "fast"
145
+ caller2 = worker.work()
146
+ assert caller2.called and caller2.result == "done", (
147
+ "the timed out call must free its concurrency slot"
148
+ )
149
+
150
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
151
+ assert snapshot.limitConcurrencyCallTimeoutCount == 1, (
152
+ "one call timeout must be recorded in the alarm metrics"
153
+ )
154
+ assert snapshot.limitConcurrencyLastCallTimeoutMethodName is not None
155
+ assert snapshot.limitConcurrencyLastCallTimeoutMethodName.endswith("work")
156
+
157
+
158
+ def test_fast_call_is_not_timed_out(clock) -> None:
159
+ worker = _SwitchableWorker()
160
+ worker.behaviour = "fast"
161
+
162
+ caller = worker.work()
163
+ clock.advance(_KILL_SECONDS * 2)
164
+
165
+ assert caller.called and caller.result == "done", (
166
+ "fast call must complete normally and cancel its watchdog"
167
+ )
168
+
169
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
170
+ assert snapshot.limitConcurrencyCallTimeoutCount == 0, (
171
+ "no timeout may be recorded for a fast call"
172
+ )
173
+
174
+
175
+ def test_timeouts_are_per_call_at_concurrency_two(clock) -> None:
176
+ worker = _ConcurrencyTwoWorker()
177
+
178
+ caller1 = worker.work()
179
+ caller2 = worker.work()
180
+ clock.advance(_KILL_SECONDS)
181
+
182
+ _callerFailure(caller1).trap(LimitConcurrencyTimeoutError)
183
+ _callerFailure(caller2).trap(LimitConcurrencyTimeoutError)
184
+ assert worker.cancelledCount == 2, "each started call must have its own watchdog"
185
+
186
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
187
+ assert snapshot.limitConcurrencyCallTimeoutCount == 2
188
+
189
+
190
+ def test_no_timeout_when_parameter_not_set(clock) -> None:
191
+ worker = _NoTimeoutWorker()
192
+
193
+ caller = worker.work()
194
+ clock.advance(1000)
195
+
196
+ assert not caller.called, (
197
+ "without killOrQuarantineCallsLongerThanSeconds, calls must not be timed out"
198
+ )
199
+
200
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
201
+ assert snapshot.limitConcurrencyCallTimeoutCount == 0
202
+
203
+
204
+ def test_timed_out_call_is_registered_for_manhole(clock) -> None:
205
+ # A function-local class gets its own limiter Wrap, isolated from the
206
+ # module-level workers' shared Wraps.
207
+ class _ManholeWorker:
208
+ @limitConcurrency(
209
+ logger, 1, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS
210
+ )
211
+ @inlineCallbacks
212
+ def work(self) -> Generator[Deferred, object, str]:
213
+ yield Deferred() # Never fires, the call is stuck
214
+ return "unreachable"
215
+
216
+ worker = _ManholeWorker()
217
+
218
+ caller1 = worker.work()
219
+ clock.advance(_KILL_SECONDS)
220
+ _callerFailure(caller1).trap(LimitConcurrencyTimeoutError)
221
+
222
+ # Reach the limiter the way manhole inspection does in production.
223
+ wraps = [
224
+ o
225
+ for o in gc.get_objects()
226
+ if type(o).__name__ == "Wrap"
227
+ and hasattr(o, "_callQueue")
228
+ and any(
229
+ entry.methodQualname.endswith("_ManholeWorker.work")
230
+ for entry in o._quarantinedCalls
231
+ )
232
+ ]
233
+ assert wraps == [], (
234
+ "a killed inlineCallbacks call must not be quarantine-registered;"
235
+ " only timed out threaded calls accumulate"
236
+ )
237
+
238
+
239
+ @pytest.mark.twisted
240
+ @ensureDeferred
241
+ async def test_threaded_call_is_quarantined_not_killed(clock) -> None:
242
+ worker = _ThreadedWorker()
243
+
244
+ caller1 = worker.work()
245
+ await threads.deferToThread(worker.threadStarted.wait, 5)
246
+
247
+ clock.advance(_KILL_SECONDS)
248
+
249
+ with pytest.raises(LimitConcurrencyTimeoutError):
250
+ await caller1
251
+
252
+ worker.behaviour = "fast"
253
+ result = await worker.work()
254
+ assert result == "done", (
255
+ "a quarantined call must be moved out of the limited count:"
256
+ " the freed slot serves the next call while the thread runs on"
257
+ )
258
+
259
+ assert worker.threadCompleted is False, (
260
+ "the quarantined thread must still be blocked; it cannot be killed"
261
+ )
262
+
263
+ worker.threadRelease.set()
264
+ await threads.deferToThread(worker.threadFinished.wait, 5)
265
+ assert worker.threadCompleted is True, (
266
+ "the late thread result must be discarded without AlreadyCalledError"
267
+ )
268
+
269
+ # The timed out threaded Deferred is accumulated in the limiter's
270
+ # quarantine registry, reachable the way manhole inspection works.
271
+ wraps = [
272
+ o
273
+ for o in gc.get_objects()
274
+ if type(o).__name__ == "Wrap"
275
+ and hasattr(o, "_callQueue")
276
+ and any(
277
+ entry.methodQualname.endswith("_ThreadedWorker.work")
278
+ for entry in o._quarantinedCalls
279
+ )
280
+ ]
281
+ assert len(wraps) == 1, "one limiter must hold the quarantined thread"
282
+
283
+ entry = wraps[0]._quarantinedCalls[0]
284
+ assert entry.startDate <= entry.timeoutDate, (
285
+ "quarantine entry must carry the call start and timeout dates"
286
+ )
287
+ assert getattr(entry.deferred, "_isVortexThreadedCall", False), (
288
+ "the registry must hold the threaded Deferred itself"
289
+ )
290
+
291
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
292
+ assert snapshot.limitConcurrencyCallTimeoutCount == 1
@@ -6,4 +6,4 @@
6
6
  # Website : https://flyingion.com
7
7
  # Support : contact@flyingion.com
8
8
 
9
- __version__ = '6.2.5'
9
+ __version__ = '6.2.6'
@@ -271,7 +271,14 @@ class TupleDataObservableHandler:
271
271
  if d:
272
272
  d.addErrback(vortexLogFailure, logger, consumeError=True)
273
273
 
274
- @limitConcurrency(logger, 25, deduplicateBasedOnKwArgsKey=_dedupProcessorCallKeys)
274
+ @limitConcurrency(
275
+ logger,
276
+ 25,
277
+ deduplicateBasedOnKwArgsKey=_dedupProcessorCallKeys,
278
+ # Legitimate slow queries run to ~2 minutes; 300s bounds slot
279
+ # starvation to 5 minutes without killing healthy calls.
280
+ killOrQuarantineCallsLongerThanSeconds=300.0,
281
+ )
275
282
  def _processLimitedDecorated(
276
283
  self,
277
284
  payloadEnvelope: PayloadEnvelope,
@@ -394,10 +401,32 @@ class TupleDataObservableHandler:
394
401
  vortexUuid
395
402
  ] = accessControlContext
396
403
 
397
- vortexMsg = yield self._createVortexMsg(
398
- payloadEnvelope.filt,
399
- tupleSelector,
400
- )
404
+ try:
405
+ vortexMsg = yield self._createVortexMsg(
406
+ payloadEnvelope.filt,
407
+ tupleSelector,
408
+ )
409
+ except Exception as e:
410
+ # The caller is waiting on a PayloadResponse; without an error
411
+ # response it waits the full client timeout for every failure.
412
+ logger.error(
413
+ "Observable:%s provider failed for TupleSelector = %s,"
414
+ " sending error response",
415
+ self._observableName,
416
+ tsStr,
417
+ )
418
+ logger.exception(e)
419
+ # DatabaseError strings include the full SQL and parameters;
420
+ # the leading characters carry the actual error code.
421
+ errorText = f"{e.__class__.__name__}: {e}"[:500]
422
+ errorEnvelope = PayloadEnvelope(
423
+ filt=payloadEnvelope.filt,
424
+ result=f"Tuple provider failed on the server: {errorText}",
425
+ )
426
+ errorVortexMsg = yield errorEnvelope.toVortexMsgDefer()
427
+ yield sendResponse(errorVortexMsg)
428
+ return
429
+
401
430
  try:
402
431
  yield sendResponse(vortexMsg)
403
432
  except Exception as e: