kiba-core 0.5.3.dev73__tar.gz → 0.5.3.dev75__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.
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/CHANGELOG.md +3 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/PKG-INFO +1 -1
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/json_route.py +2 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/streaming_json_route.py +2 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/aqs.py +2 -2
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/cosmos.py +39 -11
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/message_queue.py +2 -1
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/message_queue_processor.py +12 -8
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/sql.py +3 -2
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/sqs.py +2 -2
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/kiba_core.egg-info/PKG-INFO +1 -1
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/pyproject.toml +1 -1
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_json_route.py +11 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_streaming_json_route.py +11 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/queues/test_cosmos_queue.py +40 -5
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/queues/test_sql_queue.py +25 -1
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/.github/workflows/pull-request.yml +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/.gitignore +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/README.md +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/authorizer.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/default_routes.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/openapi.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/rate_limit.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/request_context.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/route.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/route_auth.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/route_metadata.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/cron/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/cron/api_request_cron_job_processor.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/cron/cron_executor.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/cron/cron_job_processor.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/cron/model.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/locks/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/locks/lock.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/locks/model.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/locks/sql.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/logging.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/notifications/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/queues/model.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/s3_manager.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/store/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/store/database.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/store/retriever.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/date_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/json_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/process_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/typing_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/web3/eth_client.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/web3/eth_client_registry.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/kiba_core.egg-info/SOURCES.txt +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/kiba_core.egg-info/requires.txt +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/makefile +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_api_response.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_openapi.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_rate_limit.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_request_context.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_route.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/cron/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/cron/test_cron_executor.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/locks/test_sql_lock.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/queues/conftest.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/queues/test_aqs_queue.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/queues/test_message_queue_processor.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/queues/test_sqs_queue.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/requester/test_requester.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/test_database.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/test_exceptions.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/test_logging.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_process_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/web3/test_eth_client_registry.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/web3/test_rest_eth_client.py +0 -0
- {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/uv.lock +0 -0
|
@@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
|
+
- [PATCH] Fixed `json_route` and `streaming_json_route` returning a 500 when the JSON body isn't an object (e.g. `true` or `[1]`); they now raise `BadRequestException` (400)
|
|
12
|
+
- [MINOR] Added `shouldNotifyRetriedFailures` (default `True`) to `MessageQueueProcessor`: when `False`, failure notifications are only sent once the queue gives up on a message, not for failures it will retry. `MessageQueue.fail_message` now returns whether the queue scheduled another attempt: `SqlMessageQueue` and `CosmosMessageQueue` return `True` until `maxAttempts` is reached, while `SqsMessageQueue` and `AqsMessageQueue` always return `False` as the platform decides on redelivery, so they keep notifying every failure
|
|
13
|
+
- [MAJOR] Updated `CosmosMessageQueue` to retry failed messages like `SqlMessageQueue`: added `maxAttempts` (default 3) and `failureRetryDelaySeconds` (default 60). `fail_message` reschedules the message with linear backoff and stores `lastError`, and a visible message whose lease expired counts as an attempt when it is claimed again. Once `postCount` reaches `maxAttempts` the message is removed, along with its deduplication item. Previously failed or abandoned messages reappeared once their lease expired and were retried forever
|
|
11
14
|
- [MINOR] Added `pollLogIntervalSeconds` (default 600) to `MessageQueueProcessor`: `Retrieving messages...` is now logged on the first poll and then at most once per interval instead of on every poll, so it still shows the worker is alive without flooding the logs; pass `0` to log every poll as before
|
|
12
15
|
- [MAJOR] Updated `RestEthClient` to raise `RpcServerException` (a `ServerException` keeping the original status code and message, with the original exception as its cause) when the node responds with a 5xx, so callers can tell node failures apart from other server errors; `RequesterTimeoutException` is still raised unchanged. Code catching `InternalServerErrorException` (or `BadGatewayException`, `ServiceUnavailableException`, …) around `RestEthClient` calls must catch `RpcServerException` instead
|
|
13
16
|
- [MINOR] Added `RestEthClient.get_code`
|
|
@@ -30,6 +30,8 @@ def json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
|
|
|
30
30
|
body = typing.cast(JsonObject, json_util.loads(bodyBytes.decode()))
|
|
31
31
|
except json_util.JsonDecodeException as exception:
|
|
32
32
|
raise BadRequestException(f'Invalid JSON body: {exception}')
|
|
33
|
+
if not isinstance(body, dict):
|
|
34
|
+
raise BadRequestException('Invalid JSON body: expected an object')
|
|
33
35
|
allParams = {**pathParams, **body, **queryParams}
|
|
34
36
|
try:
|
|
35
37
|
requestParams = requestType(**allParams)
|
|
@@ -39,6 +39,8 @@ def streaming_json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
|
|
|
39
39
|
body = typing.cast(JsonObject, json_util.loads(bodyBytes.decode()))
|
|
40
40
|
except json_util.JsonDecodeException as exception:
|
|
41
41
|
raise BadRequestException(f'Invalid JSON body: {exception}')
|
|
42
|
+
if not isinstance(body, dict):
|
|
43
|
+
raise BadRequestException('Invalid JSON body: expected an object')
|
|
42
44
|
allParams = {**pathParams, **body, **queryParams}
|
|
43
45
|
try:
|
|
44
46
|
requestParams = requestType(**allParams)
|
|
@@ -96,9 +96,9 @@ class AqsMessageQueue(MessageQueue[AqsMessage]):
|
|
|
96
96
|
await self.send_message(message=message, delaySeconds=delaySeconds)
|
|
97
97
|
await self.complete_message(message=message)
|
|
98
98
|
|
|
99
|
-
async def fail_message(self, message: AqsMessage, errorMessage: str | None) ->
|
|
99
|
+
async def fail_message(self, message: AqsMessage, errorMessage: str | None) -> bool: # noqa: ARG002
|
|
100
100
|
# NOTE(krishan711): the message reappears after its visibility timeout
|
|
101
|
-
|
|
101
|
+
return False
|
|
102
102
|
|
|
103
103
|
async def extend_message_lease(self, message: AqsMessage, expectedProcessingSeconds: int) -> bool:
|
|
104
104
|
if not self._aqsClient:
|
|
@@ -48,10 +48,12 @@ class CosmosMessage(Message):
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
class CosmosMessageQueue(MessageQueue[CosmosMessage]):
|
|
51
|
-
def __init__(self, container: ContainerProxy, queueName: str, pollIntervalSeconds: float = 1.0) -> None:
|
|
51
|
+
def __init__(self, container: ContainerProxy, queueName: str, pollIntervalSeconds: float = 1.0, maxAttempts: int = 3, failureRetryDelaySeconds: int = 60) -> None:
|
|
52
52
|
self.container = container
|
|
53
53
|
self.queueName = queueName
|
|
54
54
|
self.pollIntervalSeconds = pollIntervalSeconds
|
|
55
|
+
self.maxAttempts = maxAttempts
|
|
56
|
+
self.failureRetryDelaySeconds = failureRetryDelaySeconds
|
|
55
57
|
|
|
56
58
|
async def connect(self) -> None:
|
|
57
59
|
# The caller supplies a shared ContainerProxy and owns CosmosClient lifecycle.
|
|
@@ -148,6 +150,12 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
|
|
|
148
150
|
messages: list[CosmosMessage] = []
|
|
149
151
|
async for candidate in candidates:
|
|
150
152
|
item = {key: value for key, value in candidate.items() if not key.startswith('_')}
|
|
153
|
+
# NOTE(krishan711): a visible message that still has a lease was abandoned by its worker, which counts as an attempt
|
|
154
|
+
if candidate.get('leaseId') is not None:
|
|
155
|
+
if (candidate.get('postCount') or 0) >= self.maxAttempts:
|
|
156
|
+
await self._remove_message_if_unchanged(itemId=candidate['id'], etag=candidate['_etag'], deduplicationId=candidate.get('deduplicationId'))
|
|
157
|
+
continue
|
|
158
|
+
item['postCount'] = (candidate.get('postCount') or 0) + 1
|
|
151
159
|
leaseId = uuid.uuid4().hex
|
|
152
160
|
item['leaseId'] = leaseId
|
|
153
161
|
item['visibleDate'] = now + expectedProcessingSeconds
|
|
@@ -170,27 +178,38 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
|
|
|
170
178
|
break
|
|
171
179
|
return messages
|
|
172
180
|
|
|
173
|
-
async def
|
|
174
|
-
if
|
|
181
|
+
async def _remove_message(self, itemId: str, etag: str, deduplicationId: str | None) -> None:
|
|
182
|
+
if deduplicationId is None:
|
|
175
183
|
await self.container.delete_item(
|
|
176
|
-
item=
|
|
184
|
+
item=itemId,
|
|
177
185
|
partition_key=self.queueName,
|
|
178
|
-
etag=
|
|
186
|
+
etag=etag,
|
|
179
187
|
match_condition=MatchConditions.IfNotModified,
|
|
180
188
|
)
|
|
181
189
|
return
|
|
182
190
|
|
|
183
|
-
deduplicationKey = f'{self.queueName}:{
|
|
191
|
+
deduplicationKey = f'{self.queueName}:{deduplicationId}'
|
|
184
192
|
deduplicationItemId = f'dedup-{hashlib.sha256(deduplicationKey.encode()).hexdigest()}'
|
|
185
193
|
await self.container.execute_item_batch(
|
|
186
194
|
batch_operations=[
|
|
187
|
-
('delete', (
|
|
195
|
+
('delete', (itemId,), {'if_match_etag': etag}),
|
|
188
196
|
('delete', (deduplicationItemId,)),
|
|
189
197
|
],
|
|
190
198
|
partition_key=self.queueName,
|
|
191
199
|
)
|
|
192
200
|
|
|
193
|
-
async def
|
|
201
|
+
async def _remove_message_if_unchanged(self, itemId: str, etag: str, deduplicationId: str | None) -> None:
|
|
202
|
+
try:
|
|
203
|
+
await self._remove_message(itemId=itemId, etag=etag, deduplicationId=deduplicationId)
|
|
204
|
+
except (cosmos_exceptions.CosmosHttpResponseError, cosmos_exceptions.CosmosBatchOperationError) as exception:
|
|
205
|
+
# NOTE(krishan711): 412 means another worker changed the message first, so it now owns it
|
|
206
|
+
if exception.status_code != 412: # noqa: PLR2004
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
async def complete_message(self, message: CosmosMessage) -> None:
|
|
210
|
+
await self._remove_message(itemId=message.id, etag=message.etag, deduplicationId=message.deduplicationId)
|
|
211
|
+
|
|
212
|
+
async def _reschedule_message(self, message: CosmosMessage, delaySeconds: float, extraOperations: list[dict[str, Any]]) -> None: # type: ignore[explicit-any]
|
|
194
213
|
try:
|
|
195
214
|
await self.container.patch_item(
|
|
196
215
|
item=message.id,
|
|
@@ -200,6 +219,7 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
|
|
|
200
219
|
{'op': 'set', 'path': '/visibleDate', 'value': time.time() + delaySeconds},
|
|
201
220
|
{'op': 'set', 'path': '/postCount', 'value': (message.postCount or 0) + 1},
|
|
202
221
|
{'op': 'set', 'path': '/postDate', 'value': date_util.datetime_from_now().isoformat()},
|
|
222
|
+
*extraOperations,
|
|
203
223
|
],
|
|
204
224
|
etag=message.etag,
|
|
205
225
|
match_condition=MatchConditions.IfNotModified,
|
|
@@ -209,9 +229,17 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
|
|
|
209
229
|
if exception.status_code != 412: # noqa: PLR2004
|
|
210
230
|
raise
|
|
211
231
|
|
|
212
|
-
async def
|
|
213
|
-
|
|
214
|
-
|
|
232
|
+
async def retry_message(self, message: CosmosMessage, delaySeconds: int = 0) -> None:
|
|
233
|
+
await self._reschedule_message(message=message, delaySeconds=delaySeconds, extraOperations=[])
|
|
234
|
+
|
|
235
|
+
async def fail_message(self, message: CosmosMessage, errorMessage: str | None) -> bool:
|
|
236
|
+
postCount = message.postCount or 0
|
|
237
|
+
if postCount >= self.maxAttempts:
|
|
238
|
+
# NOTE(krishan711): cosmos keeps no message history, so a message out of attempts is removed like a completed one
|
|
239
|
+
await self._remove_message_if_unchanged(itemId=message.id, etag=message.etag, deduplicationId=message.deduplicationId)
|
|
240
|
+
return False
|
|
241
|
+
await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, extraOperations=[{'op': 'set', 'path': '/lastError', 'value': errorMessage}])
|
|
242
|
+
return True
|
|
215
243
|
|
|
216
244
|
async def extend_message_lease(self, message: CosmosMessage, expectedProcessingSeconds: int) -> bool:
|
|
217
245
|
try:
|
|
@@ -39,7 +39,8 @@ class MessageQueue[MessageType: Message](ABC):
|
|
|
39
39
|
raise NotImplementedError
|
|
40
40
|
|
|
41
41
|
@abc.abstractmethod
|
|
42
|
-
async def fail_message(self, message: MessageType, errorMessage: str | None) ->
|
|
42
|
+
async def fail_message(self, message: MessageType, errorMessage: str | None) -> bool:
|
|
43
|
+
# NOTE(krishan711): returns whether the queue itself scheduled another attempt; queues where the platform decides on redelivery return False
|
|
43
44
|
raise NotImplementedError
|
|
44
45
|
|
|
45
46
|
@abc.abstractmethod
|
|
@@ -40,25 +40,29 @@ class MessageLeaseLostException(InternalServerErrorException):
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
class MessageQueueProcessor[MessageType: Message]:
|
|
43
|
-
def __init__(self, queue: MessageQueue[MessageType], messageProcessor: MessageProcessor, notificationClients: list[NotificationClient], requestIdHolder: RequestIdHolder | None = None, pollLogIntervalSeconds: float = 600) -> None:
|
|
43
|
+
def __init__(self, queue: MessageQueue[MessageType], messageProcessor: MessageProcessor, notificationClients: list[NotificationClient], requestIdHolder: RequestIdHolder | None = None, pollLogIntervalSeconds: float = 600, shouldNotifyRetriedFailures: bool = True) -> None:
|
|
44
44
|
self.queue = queue
|
|
45
45
|
self.messageProcessor = messageProcessor
|
|
46
46
|
self.notificationClients = notificationClients
|
|
47
47
|
self.requestIdHolder = requestIdHolder
|
|
48
48
|
self.pollLogIntervalSeconds = pollLogIntervalSeconds
|
|
49
|
+
self.shouldNotifyRetriedFailures = shouldNotifyRetriedFailures
|
|
49
50
|
self._lastPollLogTime: float | None = None
|
|
50
51
|
|
|
51
52
|
async def _handle_failure(self, message: MessageType, exception: Exception, requestId: str) -> int:
|
|
52
53
|
logging.error('Caught exception whilst processing message:')
|
|
53
54
|
logging.exception(exception)
|
|
54
55
|
kibaException = KibaException.from_exception(exception=exception)
|
|
55
|
-
await self.queue.fail_message(message=message, errorMessage=kibaException.message)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
isRetryScheduled = await self.queue.fail_message(message=message, errorMessage=kibaException.message)
|
|
57
|
+
if isRetryScheduled and not self.shouldNotifyRetriedFailures:
|
|
58
|
+
logging.info(msg=f'Skipping failure notification for message:{message.command} as another attempt is scheduled')
|
|
59
|
+
else:
|
|
60
|
+
for client in self.notificationClients:
|
|
61
|
+
try:
|
|
62
|
+
await client.post(messageText=f'Error processing message: {message.command}\n```\n{requestId}\n{message.content}\n{kibaException.exceptionType}: {kibaException.message}```')
|
|
63
|
+
except Exception as notificationException: # noqa: BLE001
|
|
64
|
+
logging.error('Failed to send message failure notification:')
|
|
65
|
+
logging.exception(notificationException)
|
|
62
66
|
return exception.statusCode if isinstance(exception, KibaException) else 500
|
|
63
67
|
|
|
64
68
|
async def _keep_message_alive(self, message: MessageType, expectedProcessingSeconds: int, stopEvent: asyncio.Event) -> bool:
|
|
@@ -212,14 +212,15 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
|
|
|
212
212
|
async def retry_message(self, message: SqlMessage, delaySeconds: int = 0) -> None:
|
|
213
213
|
await self._reschedule_message(message=message, delaySeconds=delaySeconds, extraValues={})
|
|
214
214
|
|
|
215
|
-
async def fail_message(self, message: SqlMessage, errorMessage: str | None) ->
|
|
215
|
+
async def fail_message(self, message: SqlMessage, errorMessage: str | None) -> bool:
|
|
216
216
|
postCount = message.postCount or 0
|
|
217
217
|
if postCount >= self.maxAttempts:
|
|
218
218
|
async with self.database.create_transaction() as connection:
|
|
219
219
|
query = self._owned_update(message=message).values(status=MESSAGE_STATUS_FAILED, lockToken=None, completedDate=self.database.now(), lastError=errorMessage)
|
|
220
220
|
await self.database.execute(query=query, connection=connection)
|
|
221
|
-
return
|
|
221
|
+
return False
|
|
222
222
|
await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, extraValues={'lastError': errorMessage})
|
|
223
|
+
return True
|
|
223
224
|
|
|
224
225
|
async def _reschedule_message(self, message: SqlMessage, delaySeconds: float, extraValues: dict[str, Any]) -> None: # type: ignore[explicit-any]
|
|
225
226
|
async with self.database.create_transaction() as connection:
|
|
@@ -113,9 +113,9 @@ class SqsMessageQueue(MessageQueue[SqsMessage]):
|
|
|
113
113
|
await self.send_message(message=message, delaySeconds=delaySeconds)
|
|
114
114
|
await self.complete_message(message=message)
|
|
115
115
|
|
|
116
|
-
async def fail_message(self, message: SqsMessage, errorMessage: str | None) ->
|
|
116
|
+
async def fail_message(self, message: SqsMessage, errorMessage: str | None) -> bool: # noqa: ARG002
|
|
117
117
|
# NOTE(krishan711): the message reappears after its visibility timeout and the queue's redrive policy decides when to dead-letter it
|
|
118
|
-
|
|
118
|
+
return False
|
|
119
119
|
|
|
120
120
|
async def extend_message_lease(self, message: SqsMessage, expectedProcessingSeconds: int) -> bool:
|
|
121
121
|
if not self._sqsClient:
|
|
@@ -134,3 +134,14 @@ def test_json_route_empty_body(client):
|
|
|
134
134
|
data = response.json()
|
|
135
135
|
assert data["request_name"] == "EmptyBody"
|
|
136
136
|
assert data["request_age"] == 50
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize("content", ["true", "[1, 2]", "\"text\"", "null", "1"])
|
|
140
|
+
def test_json_route_with_non_object_json(client, content):
|
|
141
|
+
response = client.post(
|
|
142
|
+
"/test?name=NonObject&age=1",
|
|
143
|
+
content=content,
|
|
144
|
+
headers={"Content-Type": "application/json"}
|
|
145
|
+
)
|
|
146
|
+
assert response.status_code == 400
|
|
147
|
+
assert "expected an object" in response.json().get("message", "")
|
|
@@ -184,3 +184,14 @@ def test_error_thrown_by_endpoint(client):
|
|
|
184
184
|
data = dataList[0]
|
|
185
185
|
assert data["request_name"] == "Name"
|
|
186
186
|
assert data["request_age"] == 30
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.parametrize("content", ["true", "[1, 2]", "\"text\"", "null", "1"])
|
|
190
|
+
def test_streaming_json_route_with_non_object_json(client, content):
|
|
191
|
+
response = client.post(
|
|
192
|
+
"/test?name=NonObject&age=1",
|
|
193
|
+
content=content,
|
|
194
|
+
headers={"Content-Type": "application/json"}
|
|
195
|
+
)
|
|
196
|
+
assert response.status_code == 400
|
|
197
|
+
assert "expected an object" in response.json().get("message", "")
|
|
@@ -130,11 +130,46 @@ async def test_extend_after_losing_the_lease_reports_it_lost(workers: tuple[Cosm
|
|
|
130
130
|
assert await workerA.extend_message_lease(message=staleMessage, expectedProcessingSeconds=60) is False
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
async def
|
|
133
|
+
async def test_failed_message_backs_off_then_is_removed_after_max_attempts(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
|
|
134
134
|
workerA, _ = workers
|
|
135
135
|
await workerA.send_message(message=_message())
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
for attempt in range(1, 4):
|
|
137
|
+
message = await _claim(queue=workerA)
|
|
138
|
+
assert message.postCount == attempt
|
|
139
|
+
isRetryScheduled = await workerA.fail_message(message=message, errorMessage=f'boom {attempt}')
|
|
140
|
+
assert isRetryScheduled == (attempt < 3) # noqa: PLR2004
|
|
141
|
+
if attempt < 3: # noqa: PLR2004
|
|
142
|
+
item = await workerA.container.read_item(item=message.id, partition_key=workerA.queueName)
|
|
143
|
+
assert item['lastError'] == f'boom {attempt}'
|
|
144
|
+
assert await workerA.get_message() is None
|
|
145
|
+
await _make_visible(queue=workerA, messageId=message.id)
|
|
146
|
+
assert await workerA.get_message_count() == 0
|
|
147
|
+
assert await workerA.get_inflight_message_count() == 0
|
|
148
|
+
await workerA.send_message(message=_message())
|
|
149
|
+
assert await workerA.get_message_count() == 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def test_abandoned_message_counts_as_an_attempt_and_is_removed_after_max_attempts(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
|
|
153
|
+
workerA, _ = workers
|
|
154
|
+
await workerA.send_message(message=_message())
|
|
155
|
+
for attempt in range(1, 4):
|
|
156
|
+
message = await _claim(queue=workerA)
|
|
157
|
+
assert message.postCount == attempt
|
|
158
|
+
await _make_visible(queue=workerA, messageId=message.id)
|
|
138
159
|
assert await workerA.get_message() is None
|
|
139
|
-
await
|
|
140
|
-
|
|
160
|
+
assert await workerA.get_message_count() == 0
|
|
161
|
+
await workerA.send_message(message=_message())
|
|
162
|
+
assert await workerA.get_message_count() == 1
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def test_fail_after_losing_the_lease_leaves_the_new_holder_untouched(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
|
|
166
|
+
workerA, workerB = workers
|
|
167
|
+
await workerA.send_message(message=_message())
|
|
168
|
+
staleMessage = await _claim(queue=workerA)
|
|
169
|
+
await _make_visible(queue=workerA, messageId=staleMessage.id)
|
|
170
|
+
newMessage = await _claim(queue=workerB)
|
|
171
|
+
staleMessage.postCount = 3
|
|
172
|
+
assert await workerA.fail_message(message=staleMessage, errorMessage='boom') is False
|
|
173
|
+
assert await workerA.get_inflight_message_count() == 1
|
|
174
|
+
await workerB.complete_message(message=newMessage)
|
|
175
|
+
assert await workerA.get_inflight_message_count() == 0
|
|
@@ -132,6 +132,29 @@ async def test_processor_fails_message_even_when_notifications_fail(workers: tup
|
|
|
132
132
|
assert (await _rows(queue=workerA))[0]['status'] == 'failed'
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
@pytest.mark.parametrize(('shouldNotifyRetriedFailures', 'expectedNotificationCounts'), [(True, [1, 2, 3]), (False, [0, 0, 1])])
|
|
136
|
+
async def test_processor_only_notifies_retried_failures_when_enabled(workers: tuple[SqlMessageQueue, SqlMessageQueue], shouldNotifyRetriedFailures: bool, expectedNotificationCounts: list[int]):
|
|
137
|
+
workerA, _ = workers
|
|
138
|
+
notifications: list[str] = []
|
|
139
|
+
|
|
140
|
+
class BrokenProcessor(MessageProcessor):
|
|
141
|
+
async def process_message(self, message: Message) -> None:
|
|
142
|
+
raise ValueError('boom')
|
|
143
|
+
|
|
144
|
+
class RecordingNotificationClient(NotificationClient):
|
|
145
|
+
async def post(self, messageText: str) -> KibaResponse:
|
|
146
|
+
notifications.append(messageText)
|
|
147
|
+
return typing.cast(KibaResponse, None)
|
|
148
|
+
|
|
149
|
+
processor = MessageQueueProcessor(queue=workerA, messageProcessor=BrokenProcessor(), notificationClients=[RecordingNotificationClient()], shouldNotifyRetriedFailures=shouldNotifyRetriedFailures)
|
|
150
|
+
await workerA.send_message(message=_message())
|
|
151
|
+
for expectedNotificationCount in expectedNotificationCounts:
|
|
152
|
+
assert await processor.execute(longPollSeconds=0)
|
|
153
|
+
assert len(notifications) == expectedNotificationCount
|
|
154
|
+
await _make_visible(queue=workerA, messageId=(await _rows(queue=workerA))[0]['id'])
|
|
155
|
+
assert (await _rows(queue=workerA))[0]['status'] == 'failed'
|
|
156
|
+
|
|
157
|
+
|
|
135
158
|
async def test_concurrent_claims_never_hand_out_the_same_message(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
|
|
136
159
|
await workers[0].send_messages(messages=[_message(deduplicationId=None, userId=str(index)) for index in range(20)])
|
|
137
160
|
results = await asyncio.gather(*[worker.get_messages(limit=3) for _ in range(5) for worker in workers])
|
|
@@ -257,7 +280,8 @@ async def test_failed_message_backs_off_then_fails_after_max_attempts(workers: t
|
|
|
257
280
|
for attempt in range(1, 4):
|
|
258
281
|
message = await _claim(queue=workerA)
|
|
259
282
|
assert message.postCount == attempt
|
|
260
|
-
await workerA.fail_message(message=message, errorMessage=f'boom {attempt}')
|
|
283
|
+
isRetryScheduled = await workerA.fail_message(message=message, errorMessage=f'boom {attempt}')
|
|
284
|
+
assert isRetryScheduled == (attempt < 3) # noqa: PLR2004
|
|
261
285
|
row = await _row(queue=workerA, messageId=message.id)
|
|
262
286
|
if attempt < 3: # noqa: PLR2004
|
|
263
287
|
assert row['status'] == 'pending'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/exception_handling_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/core/api/middleware/server_headers_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/api/test_exception_handling_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev75}/tests/queues/test_message_queue_processor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|