cledar-sdk 2.0.2__py3-none-any.whl → 2.1.0__py3-none-any.whl

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 (85) hide show
  1. cledar/__init__.py +1 -0
  2. cledar/kafka/README.md +239 -0
  3. cledar/kafka/__init__.py +42 -0
  4. cledar/kafka/clients/base.py +117 -0
  5. cledar/kafka/clients/consumer.py +138 -0
  6. cledar/kafka/clients/producer.py +97 -0
  7. cledar/kafka/config/schemas.py +262 -0
  8. cledar/kafka/exceptions.py +17 -0
  9. cledar/kafka/handlers/dead_letter.py +88 -0
  10. cledar/kafka/handlers/parser.py +83 -0
  11. cledar/kafka/logger.py +5 -0
  12. cledar/kafka/models/input.py +17 -0
  13. cledar/kafka/models/message.py +14 -0
  14. cledar/kafka/models/output.py +12 -0
  15. cledar/kafka/tests/.env.test.kafka +3 -0
  16. cledar/kafka/tests/README.md +216 -0
  17. cledar/kafka/tests/conftest.py +104 -0
  18. cledar/kafka/tests/integration/__init__.py +1 -0
  19. cledar/kafka/tests/integration/conftest.py +78 -0
  20. cledar/kafka/tests/integration/helpers.py +47 -0
  21. cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
  22. cledar/kafka/tests/integration/test_integration.py +394 -0
  23. cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
  24. cledar/kafka/tests/integration/test_producer_integration.py +217 -0
  25. cledar/kafka/tests/unit/__init__.py +1 -0
  26. cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
  27. cledar/kafka/tests/unit/test_config_validation.py +609 -0
  28. cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
  29. cledar/kafka/tests/unit/test_error_handling.py +674 -0
  30. cledar/kafka/tests/unit/test_input_parser.py +310 -0
  31. cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
  32. cledar/kafka/tests/unit/test_utils.py +25 -0
  33. cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
  34. cledar/kafka/utils/callbacks.py +28 -0
  35. cledar/kafka/utils/messages.py +39 -0
  36. cledar/kafka/utils/topics.py +15 -0
  37. cledar/kserve/README.md +352 -0
  38. cledar/kserve/__init__.py +5 -0
  39. cledar/kserve/tests/__init__.py +0 -0
  40. cledar/kserve/tests/test_utils.py +64 -0
  41. cledar/kserve/utils.py +30 -0
  42. cledar/logging/README.md +53 -0
  43. cledar/logging/__init__.py +5 -0
  44. cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
  45. cledar/logging/universal_plaintext_formatter.py +99 -0
  46. cledar/monitoring/README.md +71 -0
  47. cledar/monitoring/__init__.py +5 -0
  48. cledar/monitoring/monitoring_server.py +156 -0
  49. cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
  50. cledar/monitoring/tests/test_monitoring_server.py +59 -0
  51. cledar/nonce/README.md +99 -0
  52. cledar/nonce/__init__.py +5 -0
  53. cledar/nonce/nonce_service.py +62 -0
  54. cledar/nonce/tests/__init__.py +0 -0
  55. cledar/nonce/tests/test_nonce_service.py +136 -0
  56. cledar/redis/README.md +536 -0
  57. cledar/redis/__init__.py +17 -0
  58. cledar/redis/async_example.py +112 -0
  59. cledar/redis/example.py +67 -0
  60. cledar/redis/exceptions.py +25 -0
  61. cledar/redis/logger.py +5 -0
  62. cledar/redis/model.py +14 -0
  63. cledar/redis/redis.py +764 -0
  64. cledar/redis/redis_config_store.py +333 -0
  65. cledar/redis/tests/test_async_integration_redis.py +158 -0
  66. cledar/redis/tests/test_async_redis_service.py +380 -0
  67. cledar/redis/tests/test_integration_redis.py +119 -0
  68. cledar/redis/tests/test_redis_service.py +319 -0
  69. cledar/storage/README.md +529 -0
  70. cledar/storage/__init__.py +6 -0
  71. cledar/storage/constants.py +5 -0
  72. cledar/storage/exceptions.py +79 -0
  73. cledar/storage/models.py +41 -0
  74. cledar/storage/object_storage.py +1274 -0
  75. cledar/storage/tests/conftest.py +18 -0
  76. cledar/storage/tests/test_abfs.py +164 -0
  77. cledar/storage/tests/test_integration_filesystem.py +359 -0
  78. cledar/storage/tests/test_integration_s3.py +453 -0
  79. cledar/storage/tests/test_local.py +384 -0
  80. cledar/storage/tests/test_s3.py +521 -0
  81. {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/METADATA +1 -1
  82. cledar_sdk-2.1.0.dist-info/RECORD +84 -0
  83. cledar_sdk-2.0.2.dist-info/RECORD +0 -4
  84. {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/WHEEL +0 -0
  85. {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,162 @@
1
+ # pylint: disable=redefined-outer-name, unreachable
2
+ import logging
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ import pytest
6
+ from fastapi import FastAPI
7
+ from httpx import ASGITransport, AsyncClient
8
+
9
+ from cledar.monitoring import EndpointFilter, MonitoringServer, MonitoringServerConfig
10
+
11
+ # ------------------------------------------------------------------------------
12
+ # Fixtures
13
+ # ------------------------------------------------------------------------------
14
+
15
+
16
+ @pytest.fixture
17
+ def test_app() -> FastAPI:
18
+ """Creates a FastAPI app with configured monitoring endpoints."""
19
+ app = FastAPI()
20
+ config = MonitoringServerConfig(
21
+ readiness_checks={"db": lambda: True},
22
+ liveness_checks={"heartbeat": lambda: True},
23
+ )
24
+ server = MonitoringServer("127.0.0.1", 8000, config)
25
+ server.add_paths(app)
26
+ return app
27
+
28
+
29
+ ASGIClientFactory = Callable[[FastAPI], Awaitable[AsyncClient]]
30
+
31
+
32
+ @pytest.fixture
33
+ def asgi_client_factory() -> ASGIClientFactory:
34
+ """Factory to create an httpx.AsyncClient for ASGI apps (httpx>=0.28)."""
35
+
36
+ async def _make_client(app: FastAPI) -> AsyncClient:
37
+ transport = ASGITransport(app=app)
38
+ return AsyncClient(transport=transport, base_url="http://test")
39
+
40
+ return _make_client
41
+
42
+
43
+ # ------------------------------------------------------------------------------
44
+ # Readiness & Liveness Endpoints
45
+ # ------------------------------------------------------------------------------
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_readiness_ok(
50
+ test_app: FastAPI, asgi_client_factory: ASGIClientFactory
51
+ ) -> None:
52
+ """Readiness endpoint returns OK when all checks pass."""
53
+ async with await asgi_client_factory(test_app) as client:
54
+ response = await client.get("/healthz/readiness")
55
+
56
+ data = response.json()
57
+ assert response.status_code == 200
58
+ assert data["status"] == "ok"
59
+ assert data["checks"]["db"] is True
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_liveness_ok(
64
+ test_app: FastAPI, asgi_client_factory: ASGIClientFactory
65
+ ) -> None:
66
+ """Liveness endpoint returns OK when all checks pass."""
67
+ async with await asgi_client_factory(test_app) as client:
68
+ response = await client.get("/healthz/liveness")
69
+
70
+ data = response.json()
71
+ assert response.status_code == 200
72
+ assert data["status"] == "ok"
73
+ assert data["checks"]["heartbeat"] is True
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_readiness_failing_check(asgi_client_factory: ASGIClientFactory) -> None:
78
+ """Readiness endpoint returns 503 and error status if a check fails."""
79
+ app = FastAPI()
80
+ config = MonitoringServerConfig(readiness_checks={"db": lambda: False})
81
+ server = MonitoringServer("127.0.0.1", 8000, config)
82
+ server.add_paths(app)
83
+
84
+ async with await asgi_client_factory(app) as client:
85
+ response = await client.get("/healthz/readiness")
86
+
87
+ data = response.json()
88
+ assert response.status_code == 503
89
+ assert data["status"] == "error"
90
+ assert data["checks"]["db"] is False
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_readiness_with_exception(asgi_client_factory: ASGIClientFactory) -> None:
95
+ """Readiness returns 503 if a check raises an exception."""
96
+
97
+ def failing_check() -> bool:
98
+ raise RuntimeError("DB not reachable")
99
+ return False
100
+
101
+ app = FastAPI()
102
+ config = MonitoringServerConfig(readiness_checks={"db": failing_check})
103
+ server = MonitoringServer("127.0.0.1", 8000, config)
104
+ server.add_paths(app)
105
+
106
+ async with await asgi_client_factory(app) as client:
107
+ response = await client.get("/healthz/readiness")
108
+
109
+ data = response.json()
110
+ assert response.status_code == 503
111
+ assert data["status"] == "error"
112
+ assert "DB not reachable" in data["message"]
113
+
114
+
115
+ # ------------------------------------------------------------------------------
116
+ # Metrics Endpoint
117
+ # ------------------------------------------------------------------------------
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_metrics_endpoint_returns_prometheus_format(
122
+ test_app: FastAPI, asgi_client_factory: ASGIClientFactory
123
+ ) -> None:
124
+ """Metrics endpoint should return valid Prometheus output."""
125
+ async with await asgi_client_factory(test_app) as client:
126
+ response = await client.get("/metrics")
127
+
128
+ assert response.status_code == 200
129
+ assert response.headers["content-type"].startswith("text/plain")
130
+ assert "python_info" in response.text # standard metric
131
+
132
+
133
+ # ------------------------------------------------------------------------------
134
+ # Logging Filter
135
+ # ------------------------------------------------------------------------------
136
+
137
+
138
+ def test_endpoint_filter_excludes_health_paths() -> None:
139
+ """EndpointFilter should exclude healthz paths from logs."""
140
+ filter_ = EndpointFilter(["/healthz/readiness", "/healthz/liveness"])
141
+
142
+ record_excluded = logging.LogRecord(
143
+ name="uvicorn.access",
144
+ level=logging.INFO,
145
+ pathname="",
146
+ lineno=0,
147
+ msg="GET /healthz/readiness 200 OK",
148
+ args=(),
149
+ exc_info=None,
150
+ )
151
+ assert filter_.filter(record_excluded) is False
152
+
153
+ record_included = logging.LogRecord(
154
+ name="uvicorn.access",
155
+ level=logging.INFO,
156
+ pathname="",
157
+ lineno=0,
158
+ msg="GET /metrics 200 OK",
159
+ args=(),
160
+ exc_info=None,
161
+ )
162
+ assert filter_.filter(record_included) is True
@@ -0,0 +1,59 @@
1
+ import pytest
2
+ from fastapi import FastAPI
3
+ from fastapi.testclient import TestClient
4
+
5
+ from cledar.monitoring import MonitoringServer, MonitoringServerConfig
6
+
7
+ HOST = "localhost"
8
+ PORT = 9999
9
+
10
+
11
+ class ReadinessFlag:
12
+ def __init__(self) -> None:
13
+ self.is_ready = False
14
+
15
+ def mark_ready(self) -> None:
16
+ self.is_ready = True
17
+
18
+ def check_if_ready(self) -> bool:
19
+ return self.is_ready
20
+
21
+
22
+ @pytest.fixture
23
+ def readiness_flag() -> ReadinessFlag:
24
+ _readiness_flag = ReadinessFlag()
25
+ return _readiness_flag
26
+
27
+
28
+ @pytest.fixture
29
+ def app(readiness_flag: ReadinessFlag) -> FastAPI:
30
+ _app = FastAPI()
31
+ default_readiness_checks = dict({"is_ready": readiness_flag.check_if_ready})
32
+ config = MonitoringServerConfig(default_readiness_checks)
33
+ monitoring_server = MonitoringServer(HOST, PORT, config)
34
+ monitoring_server.add_paths(_app)
35
+ return _app
36
+
37
+
38
+ @pytest.fixture
39
+ def client(app: FastAPI) -> TestClient:
40
+ _client = TestClient(app)
41
+ return _client
42
+
43
+
44
+ def test_liveness(client: TestClient) -> None:
45
+ response = client.get("/healthz/liveness")
46
+ assert response.status_code == 200
47
+ assert response.text == '{"status": "ok", "checks": {}}'
48
+
49
+
50
+ def test_readiness(client: TestClient, readiness_flag: ReadinessFlag) -> None:
51
+ response = client.get("/healthz/readiness")
52
+ assert response.status_code == 503
53
+ assert response.text == '{"status": "error", "checks": {"is_ready": false}}'
54
+
55
+ readiness_flag.mark_ready()
56
+
57
+ response = client.get("/healthz/readiness")
58
+ assert response.status_code == 200
59
+ assert response.text == '{"status": "ok", "checks": {"is_ready": true}}'
cledar/nonce/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # NonceService
2
+
3
+ Simple nonce service for preventing duplicate request processing using Redis with TTL-based automatic cleanup.
4
+
5
+ It is useful for implementing idempotency in APIs, background workers, and event processing pipelines where the same request/message may arrive more than once.
6
+
7
+ ## Features
8
+ - Fast duplicate detection backed by Redis SET NX
9
+ - Per-endpoint scoping (the same nonce can be used across different endpoints)
10
+ - Automatic expiry via TTL (default 1 hour)
11
+ - Tiny API surface and easy integration
12
+
13
+ ## Requirements
14
+ - Redis server accessible from your application
15
+ - Python dependencies:
16
+ - `redis` (redis-py)
17
+ - This repository/module (imports shown below)
18
+
19
+ ## Installation
20
+ Make sure you have `redis` installed in your environment:
21
+
22
+ ```bash
23
+ uv add redis
24
+ ```
25
+
26
+ This module lives inside this repository. Import it directly from your code as shown in the examples below.
27
+
28
+ ## Quickstart
29
+
30
+ ```python
31
+ import asyncio
32
+ from cledar.redis.redis import RedisService, RedisServiceConfig
33
+ from cledar.nonce import NonceService
34
+
35
+ # 1) Create a Redis client
36
+ config = RedisServiceConfig(redis_host="localhost", redis_port=6379, redis_db=0)
37
+ redis_client = RedisService(config)
38
+
39
+ # 2) Create the NonceService
40
+ nonce_service = NonceService(redis_client)
41
+
42
+ # Optional: override default TTL (seconds)
43
+ nonce_service.default_ttl = 7200 # 2 hours
44
+
45
+ async def main():
46
+ nonce = "request-id-123"
47
+ endpoint = "/api/payment" # any string identifying the endpoint or operation
48
+
49
+ # First time: not a duplicate -> returns False
50
+ first = await nonce_service.is_duplicate(nonce, endpoint)
51
+ print(first) # False
52
+
53
+ # Second time (same nonce + same endpoint): duplicate -> returns True
54
+ second = await nonce_service.is_duplicate(nonce, endpoint)
55
+ print(second) # True
56
+
57
+ # Same nonce but a different endpoint is treated independently
58
+ third = await nonce_service.is_duplicate(nonce, "/api/other-endpoint")
59
+ print(third) # False
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ## How it works
65
+ Under the hood, `NonceService.is_duplicate(nonce, endpoint)` performs a Redis `SET` with the flags `NX` (only set if not exists) and `EX=<TTL>`. If Redis returns that the key was set, this is the first time the nonce is seen for that endpoint (not a duplicate -> returns `False`). If the key already exists, Redis returns `None`, which the service treats as a duplicate -> returns `True`.
66
+
67
+ - Redis key format: `nonce:{endpoint}:{nonce}`
68
+ - Default TTL: `3600` seconds (1 hour); can be changed via `nonce_service.default_ttl`
69
+ - Endpoint scoping: the same nonce value can be used across different endpoints without being considered a duplicate between them
70
+
71
+ ## API
72
+
73
+ ```python
74
+ from cledar.redis.redis import RedisService
75
+
76
+ class NonceService:
77
+ def __init__(self, redis_client: RedisService):
78
+ """Simple service for managing nonces to prevent duplicate requests."""
79
+ ...
80
+
81
+ async def is_duplicate(self, nonce: str, endpoint: str) -> bool:
82
+ """Return True if (nonce, endpoint) was already used within TTL."""
83
+ ...
84
+ ```
85
+
86
+ ### Errors
87
+ - `RuntimeError("Redis client is not initialized")` — raised when `redis_client._client` is `None`.
88
+
89
+ ## Testing
90
+ This module includes unit tests. To run them:
91
+
92
+ ```bash
93
+ uv run pytest nonce_service/tests -q
94
+ ```
95
+
96
+ ## Best practices
97
+ - Use a stable nonce that uniquely identifies an operation (e.g., request ID, message ID)
98
+ - Choose a TTL that matches the maximum time window in which duplicates must be rejected
99
+ - Scope by a meaningful `endpoint` string to separate distinct operations
@@ -0,0 +1,5 @@
1
+ """Nonce service module for managing unique request identifiers."""
2
+
3
+ from .nonce_service import NonceService
4
+
5
+ __all__ = ["NonceService"]
@@ -0,0 +1,62 @@
1
+ """Simple nonce service for preventing duplicate request processing.
2
+
3
+ Uses Redis with TTL for automatic cleanup.
4
+ """
5
+
6
+ from cledar.redis.redis import RedisService
7
+
8
+
9
+ class NonceService:
10
+ """Simple service for managing nonces to prevent duplicate requests."""
11
+
12
+ def __init__(self, redis_client: RedisService):
13
+ """Initialize the nonce service.
14
+
15
+ Args:
16
+ redis_client: The Redis client service used for storage.
17
+
18
+ """
19
+ self.redis_client = redis_client
20
+ self.nonce_prefix = "nonce"
21
+ self.default_ttl = 3600 # 1 hour
22
+
23
+ def _get_nonce_key(self, nonce: str, endpoint: str) -> str:
24
+ """Generate Redis key for nonce.
25
+
26
+ Args:
27
+ nonce: The unique identifier for the request.
28
+ endpoint: The endpoint the request is directed at.
29
+
30
+ Returns:
31
+ str: The formatted Redis key.
32
+
33
+ """
34
+ return f"{self.nonce_prefix}:{endpoint}:{nonce}"
35
+
36
+ async def is_duplicate(self, nonce: str, endpoint: str) -> bool:
37
+ """Check if nonce was already used (returns True if duplicate).
38
+
39
+ Args:
40
+ nonce: The unique identifier for the request.
41
+ endpoint: The endpoint the request is directed at.
42
+
43
+ Returns:
44
+ bool: True if the nonce is a duplicate, False otherwise.
45
+
46
+ Raises:
47
+ RuntimeError: If the Redis client is not initialized.
48
+
49
+ """
50
+ nonce_key = self._get_nonce_key(nonce, endpoint)
51
+
52
+ if self.redis_client._client is None:
53
+ raise RuntimeError("Redis client is not initialized")
54
+
55
+ result = self.redis_client._client.set(
56
+ nonce_key,
57
+ "used",
58
+ nx=True,
59
+ ex=self.default_ttl,
60
+ )
61
+
62
+ return result is None
File without changes
@@ -0,0 +1,136 @@
1
+ # mypy: disable-error-code=no-untyped-def
2
+ from unittest.mock import MagicMock
3
+
4
+ import pytest
5
+
6
+ from cledar.nonce import NonceService
7
+
8
+
9
+ @pytest.fixture(name="redis_client")
10
+ def fixture_redis_client():
11
+ """Mock Redis client"""
12
+ mock_redis = MagicMock()
13
+ mock_redis._client = MagicMock()
14
+ return mock_redis
15
+
16
+
17
+ @pytest.fixture(name="nonce_service")
18
+ def fixture_nonce_service(redis_client):
19
+ """Create NonceService with a mocked Redis client"""
20
+ return NonceService(redis_client)
21
+
22
+
23
+ def test_init(redis_client):
24
+ """Test NonceService initialization"""
25
+ service = NonceService(redis_client)
26
+
27
+ assert service.redis_client == redis_client
28
+ assert service.nonce_prefix == "nonce"
29
+ assert service.default_ttl == 3600
30
+
31
+
32
+ def test_get_nonce_key(nonce_service):
33
+ """Test nonce key generation"""
34
+ nonce = "test-nonce-123"
35
+ endpoint = "/api/payment"
36
+
37
+ key = nonce_service._get_nonce_key(nonce, endpoint)
38
+
39
+ assert key == "nonce:/api/payment:test-nonce-123"
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_is_duplicate_first_time(nonce_service):
44
+ """Test that the first use of nonce returns False (not duplicate)"""
45
+ nonce = "unique-nonce-456"
46
+ endpoint = "/api/transaction"
47
+
48
+ # Mock Redis set to return True (key was set successfully)
49
+ nonce_service.redis_client._client.set.return_value = True
50
+
51
+ result = await nonce_service.is_duplicate(nonce, endpoint)
52
+
53
+ assert result is False
54
+ nonce_service.redis_client._client.set.assert_called_once_with(
55
+ "nonce:/api/transaction:unique-nonce-456",
56
+ "used",
57
+ nx=True,
58
+ ex=3600,
59
+ )
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_is_duplicate_second_time(nonce_service):
64
+ """Test that second use of same nonce returns True (duplicate)"""
65
+ nonce = "duplicate-nonce-789"
66
+ endpoint = "/api/refund"
67
+
68
+ # Mock Redis set to return None (key already exists)
69
+ nonce_service.redis_client._client.set.return_value = None
70
+
71
+ result = await nonce_service.is_duplicate(nonce, endpoint)
72
+
73
+ assert result is True
74
+ nonce_service.redis_client._client.set.assert_called_once_with(
75
+ "nonce:/api/refund:duplicate-nonce-789",
76
+ "used",
77
+ nx=True,
78
+ ex=3600,
79
+ )
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_is_duplicate_redis_not_initialized(nonce_service):
84
+ """Test that RuntimeError is raised when a Redis client is not initialized"""
85
+ nonce = "test-nonce"
86
+ endpoint = "/api/test"
87
+
88
+ # Simulate uninitialized Redis client
89
+ nonce_service.redis_client._client = None
90
+
91
+ with pytest.raises(RuntimeError, match="Redis client is not initialized"):
92
+ await nonce_service.is_duplicate(nonce, endpoint)
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_is_duplicate_different_endpoints(nonce_service):
97
+ """Test that the same nonce is independent across different endpoints"""
98
+ nonce = "same-nonce"
99
+ endpoint1 = "/api/endpoint1"
100
+ endpoint2 = "/api/endpoint2"
101
+
102
+ # Both calls should treat the nonce as new (not duplicate)
103
+ nonce_service.redis_client._client.set.return_value = True
104
+
105
+ result1 = await nonce_service.is_duplicate(nonce, endpoint1)
106
+ result2 = await nonce_service.is_duplicate(nonce, endpoint2)
107
+
108
+ assert result1 is False
109
+ assert result2 is False
110
+
111
+ # Verify both keys were set with different endpoint prefixes
112
+ assert nonce_service.redis_client._client.set.call_count == 2
113
+ calls = nonce_service.redis_client._client.set.call_args_list
114
+ assert calls[0][0][0] == "nonce:/api/endpoint1:same-nonce"
115
+ assert calls[1][0][0] == "nonce:/api/endpoint2:same-nonce"
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_is_duplicate_custom_ttl(redis_client):
120
+ """Test that custom TTL is used correctly"""
121
+ service = NonceService(redis_client)
122
+ service.default_ttl = 7200 # 2 hours
123
+
124
+ nonce = "test-nonce"
125
+ endpoint = "/api/test"
126
+
127
+ redis_client._client.set.return_value = True
128
+
129
+ await service.is_duplicate(nonce, endpoint)
130
+
131
+ redis_client._client.set.assert_called_once_with(
132
+ "nonce:/api/test:test-nonce",
133
+ "used",
134
+ nx=True,
135
+ ex=7200,
136
+ )