persistence-kit 3.14.0__tar.gz → 3.15.0__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 (119) hide show
  1. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/PKG-INFO +14 -3
  2. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/README.md +10 -1
  3. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/rate_limit.py +6 -11
  4. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/client.py +12 -4
  5. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/factory.py +2 -0
  6. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/memory.py +21 -16
  7. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/registry.py +3 -0
  8. persistence_kit-3.15.0/persistence_kit/restclient/tracing.py +59 -0
  9. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/settings/app_settings.py +2 -1
  10. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/pyproject.toml +7 -4
  11. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/LICENSE +0 -0
  12. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/__init__.py +0 -0
  13. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/__init__.py +0 -0
  14. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/common.py +0 -0
  15. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/encrypted_middleware.py +0 -0
  16. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/encrypted_routes.py +0 -0
  17. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/error_handlers.py +0 -0
  18. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/exceptions.py +0 -0
  19. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/api/route_loader.py +0 -0
  20. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/authenticated_user.py +0 -0
  21. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/bootstrap/__init__.py +0 -0
  22. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/bootstrap/configuration.py +0 -0
  23. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/bootstrap/seeders.py +0 -0
  24. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/bootstrap/startup.py +0 -0
  25. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/cache/__init__.py +0 -0
  26. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/cache/contracts.py +0 -0
  27. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/cache/dynamodb.py +0 -0
  28. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/cache/factory.py +0 -0
  29. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/cache/memory.py +0 -0
  30. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/cache/mongo.py +0 -0
  31. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/cache/namespaced.py +0 -0
  32. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/contracts/__init__.py +0 -0
  33. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/contracts/repository.py +0 -0
  34. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/contracts/view_repository.py +0 -0
  35. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/__init__.py +0 -0
  36. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/contracts.py +0 -0
  37. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/errors.py +0 -0
  38. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/factory.py +0 -0
  39. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/mailer.py +0 -0
  40. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/memory.py +0 -0
  41. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/message.py +0 -0
  42. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/mime.py +0 -0
  43. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/preview.py +0 -0
  44. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/smtp.py +0 -0
  45. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/mail/templates.py +0 -0
  46. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/py.typed +0 -0
  47. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/__init__.py +0 -0
  48. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  49. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  50. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/filter_ops.py +0 -0
  51. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  52. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  53. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  54. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  55. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  56. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  57. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  58. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  59. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  60. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  61. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  62. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository_factory/__init__.py +0 -0
  63. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  64. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  65. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  66. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  67. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  68. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  69. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/resilience/__init__.py +0 -0
  70. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/resilience/circuit.py +0 -0
  71. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/__init__.py +0 -0
  72. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/aggregate.py +0 -0
  73. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/auth/__init__.py +0 -0
  74. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/auth/api_key.py +0 -0
  75. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/auth/base.py +0 -0
  76. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/auth/basic.py +0 -0
  77. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/auth/bearer.py +0 -0
  78. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/auth/login.py +0 -0
  79. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
  80. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/caching.py +0 -0
  81. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/config.py +0 -0
  82. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/contracts.py +0 -0
  83. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/errors.py +0 -0
  84. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/mapping.py +0 -0
  85. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/payload.py +0 -0
  86. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/populate.py +0 -0
  87. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/provider.py +0 -0
  88. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/resolver.py +0 -0
  89. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/restclient/retry.py +0 -0
  90. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/__init__.py +0 -0
  91. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/encrypted/__init__.py +0 -0
  92. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/encrypted/envelope.py +0 -0
  93. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/encrypted/errors.py +0 -0
  94. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/factory.py +0 -0
  95. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/ports.py +0 -0
  96. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/providers/__init__.py +0 -0
  97. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  98. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/providers/encryption_provider.py +0 -0
  99. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  100. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/registration.py +0 -0
  101. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  102. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  103. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  104. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/settings/__init__.py +0 -0
  105. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/settings/cache_settings.py +0 -0
  106. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/settings/constants.py +0 -0
  107. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/settings/mail_settings.py +0 -0
  108. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/settings/parsers.py +0 -0
  109. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/settings/repo_settings.py +0 -0
  110. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/__init__.py +0 -0
  111. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/contracts.py +0 -0
  112. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/errors.py +0 -0
  113. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/factory.py +0 -0
  114. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/local.py +0 -0
  115. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/media.py +0 -0
  116. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/routes.py +0 -0
  117. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/storage/s3.py +0 -0
  118. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/utils/__init__.py +0 -0
  119. {persistence_kit-3.14.0 → persistence_kit-3.15.0}/persistence_kit/utils/upsert.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persistence-kit
3
- Version: 3.14.0
3
+ Version: 3.15.0
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -30,13 +30,15 @@ Provides-Extra: security-cognito
30
30
  Provides-Extra: storage-routes
31
31
  Provides-Extra: storage-s3
32
32
  Provides-Extra: testing
33
+ Provides-Extra: tracing
33
34
  Requires-Dist: asyncpg (>=0.30.0,<1.0.0)
34
35
  Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "security-cognito" or extra == "dynamodb" or extra == "all"
35
- Requires-Dist: cryptography (>=42.0.0,<43.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
36
+ Requires-Dist: cryptography (>=42.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
36
37
  Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "encrypted" or extra == "testing" or extra == "all"
37
38
  Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
38
39
  Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "mail" or extra == "testing" or extra == "all"
39
40
  Requires-Dist: motor (>=3.7.1,<4.0.0)
41
+ Requires-Dist: opentelemetry-api (>=1.20.0,<2.0.0) ; extra == "tracing" or extra == "testing" or extra == "all"
40
42
  Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
41
43
  Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
42
44
  Requires-Dist: python-multipart (>=0.0.31,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
@@ -156,7 +158,8 @@ pip install "persistence-kit[api,security,restclient]"
156
158
  | `restclient` | The REST client and its cache | httpx |
157
159
  | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
158
160
  | `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
159
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
161
+ | `tracing` | A span for every REST call, inside the request's trace | opentelemetry-api |
162
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2, opentelemetry-api |
160
163
  | `all` | Every optional capability | all of the above |
161
164
 
162
165
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -329,6 +332,13 @@ real one in tests.
329
332
  Base URLs can be overridden per environment with `REST_SERVICE_URLS`, a JSON map
330
333
  of service name to URL, without touching the registration.
331
334
 
335
+ With `[tracing]` installed and a tracer provider in the app, every call is a client
336
+ span inside the current trace, named `<METHOD> <path>` (`GET /api/invoices`) and
337
+ tagged with the service in `peer.service`, with the host, the status and the retries. The query string is
338
+ never recorded, and a failure keeps only the error type, not its message, so a
339
+ password sent as a query parameter does not reach the traces. `MemoryRestClient`
340
+ registered under a name produces the same spans, so they show up in local runs too.
341
+
332
342
  The cache is a plain key-value store with TTL, chosen by `CACHE_BACKEND` the
333
343
  same way repositories are chosen by `REPO_DATABASE`. Set `CACHE_NAMESPACE` when
334
344
  several apps share one DynamoDB table or Mongo collection so their keys do not
@@ -449,6 +459,7 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
449
459
  | `CACHE_BACKEND` | `memory`, `mongo` or `dynamodb`. Defaults to `memory` |
450
460
  | `CACHE_NAMESPACE` | Key prefix, so apps sharing a cache store do not collide |
451
461
  | `REST_SERVICE_URLS` | JSON map `{"service": "base_url"}` overriding registered REST base URLs |
462
+ | `TRUSTED_PROXY_HOPS` | Proxies in front of the app that append to `X-Forwarded-For`. The auth rate limit counts the address the outermost one saw, so a forged header does not open a new bucket. `1` by default (one load balancer), `2` behind CloudFront and an ALB, `0` with no proxy |
452
463
  | `MONGO_DSN`, `MONGO_DB` | Mongo connection |
453
464
  | `POSTGRES_DSN` | Full DSN, if you prefer it to the pieces below |
454
465
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
@@ -107,7 +107,8 @@ pip install "persistence-kit[api,security,restclient]"
107
107
  | `restclient` | The REST client and its cache | httpx |
108
108
  | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
109
109
  | `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
110
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
110
+ | `tracing` | A span for every REST call, inside the request's trace | opentelemetry-api |
111
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2, opentelemetry-api |
111
112
  | `all` | Every optional capability | all of the above |
112
113
 
113
114
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -280,6 +281,13 @@ real one in tests.
280
281
  Base URLs can be overridden per environment with `REST_SERVICE_URLS`, a JSON map
281
282
  of service name to URL, without touching the registration.
282
283
 
284
+ With `[tracing]` installed and a tracer provider in the app, every call is a client
285
+ span inside the current trace, named `<METHOD> <path>` (`GET /api/invoices`) and
286
+ tagged with the service in `peer.service`, with the host, the status and the retries. The query string is
287
+ never recorded, and a failure keeps only the error type, not its message, so a
288
+ password sent as a query parameter does not reach the traces. `MemoryRestClient`
289
+ registered under a name produces the same spans, so they show up in local runs too.
290
+
283
291
  The cache is a plain key-value store with TTL, chosen by `CACHE_BACKEND` the
284
292
  same way repositories are chosen by `REPO_DATABASE`. Set `CACHE_NAMESPACE` when
285
293
  several apps share one DynamoDB table or Mongo collection so their keys do not
@@ -400,6 +408,7 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
400
408
  | `CACHE_BACKEND` | `memory`, `mongo` or `dynamodb`. Defaults to `memory` |
401
409
  | `CACHE_NAMESPACE` | Key prefix, so apps sharing a cache store do not collide |
402
410
  | `REST_SERVICE_URLS` | JSON map `{"service": "base_url"}` overriding registered REST base URLs |
411
+ | `TRUSTED_PROXY_HOPS` | Proxies in front of the app that append to `X-Forwarded-For`. The auth rate limit counts the address the outermost one saw, so a forged header does not open a new bucket. `1` by default (one load balancer), `2` behind CloudFront and an ALB, `0` with no proxy |
403
412
  | `MONGO_DSN`, `MONGO_DB` | Mongo connection |
404
413
  | `POSTGRES_DSN` | Full DSN, if you prefer it to the pieces below |
405
414
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
@@ -48,16 +48,11 @@ class InMemoryRateLimiter:
48
48
  return None
49
49
 
50
50
 
51
- def _resolve_client_id(request: Request) -> str:
52
- forwarded_for = request.headers.get("x-forwarded-for")
53
- if forwarded_for:
54
- first_ip = forwarded_for.split(",")[0].strip()
55
- if first_ip:
56
- return first_ip
57
-
58
- cf_connecting_ip = request.headers.get("cf-connecting-ip")
59
- if cf_connecting_ip:
60
- return cf_connecting_ip.strip()
51
+ def _resolve_client_id(request: Request, trusted_proxy_hops: int) -> str:
52
+ forwarded_for = request.headers.get("x-forwarded-for", "")
53
+ chain = [hop.strip() for hop in forwarded_for.split(",") if hop.strip()]
54
+ if 0 < trusted_proxy_hops <= len(chain):
55
+ return chain[-trusted_proxy_hops]
61
56
 
62
57
  client_host = request.client.host if request.client else None
63
58
  return client_host or "unknown"
@@ -87,7 +82,7 @@ def build_auth_rate_limit_dependency(
87
82
  limit = getattr(settings, limit_setting_name)
88
83
  retry_after = await _get_rate_limiter(request).hit(
89
84
  scope=scope,
90
- client_id=_resolve_client_id(request),
85
+ client_id=_resolve_client_id(request, getattr(settings, "trusted_proxy_hops", 1)),
91
86
  limit=limit,
92
87
  window_seconds=settings.auth_rate_limit_window_seconds,
93
88
  )
@@ -23,6 +23,7 @@ from persistence_kit.restclient.mapping import ModelMappingMixin
23
23
  from persistence_kit.restclient.payload import prepare_request
24
24
  from persistence_kit.restclient.resolver import StaticEndpointResolver
25
25
  from persistence_kit.restclient.retry import RetryPolicy
26
+ from persistence_kit.restclient.tracing import traced_call
26
27
 
27
28
 
28
29
  class HttpxRestClient(ModelMappingMixin):
@@ -41,7 +42,9 @@ class HttpxRestClient(ModelMappingMixin):
41
42
  retry_policy: RetryPolicy | None = None,
42
43
  circuit_breaker: CircuitBreaker | None = None,
43
44
  client: httpx.AsyncClient | None = None,
45
+ name: str = "",
44
46
  ) -> None:
47
+ self.name = name
45
48
  self._config = config or ServiceConfig()
46
49
  self._resolver = resolver or StaticEndpointResolver()
47
50
  self._auth = authenticator or NoAuth()
@@ -89,6 +92,14 @@ class HttpxRestClient(ModelMappingMixin):
89
92
  )
90
93
  request = await self._auth.apply(request)
91
94
 
95
+ with traced_call(self.name, request.method, request.url) as call:
96
+ response, attempts = await self._exchange(request)
97
+ call.record(response.status_code, attempts)
98
+ if self._config.raise_for_status and not response.is_success:
99
+ raise RestHTTPError(response.status_code, response=response)
100
+ return response
101
+
102
+ async def _exchange(self, request: RestRequest) -> tuple[RestResponse, int]:
92
103
  attempt = 0
93
104
  refreshed = False
94
105
  while True:
@@ -128,10 +139,7 @@ class HttpxRestClient(ModelMappingMixin):
128
139
  attempt += 1
129
140
  continue
130
141
  break
131
-
132
- if self._config.raise_for_status and not response.is_success:
133
- raise RestHTTPError(response.status_code, response=response)
134
- return response
142
+ return response, attempt
135
143
 
136
144
  async def _send(self, request: RestRequest) -> RestResponse:
137
145
  try:
@@ -24,6 +24,7 @@ def build_rest_client(
24
24
  retry_policy: RetryPolicy | None = None,
25
25
  circuit_breaker: CircuitBreaker | None = None,
26
26
  settings: Any | None = None,
27
+ name: str = "",
27
28
  ) -> RestClient:
28
29
  if config is None:
29
30
  config = (
@@ -48,4 +49,5 @@ def build_rest_client(
48
49
  authenticator=authenticator,
49
50
  retry_policy=retry_policy,
50
51
  circuit_breaker=circuit_breaker,
52
+ name=name,
51
53
  )
@@ -13,6 +13,7 @@ from persistence_kit.restclient.contracts import (
13
13
  from persistence_kit.restclient.errors import RestHTTPError
14
14
  from persistence_kit.restclient.mapping import ModelMappingMixin
15
15
  from persistence_kit.restclient.payload import prepare_request
16
+ from persistence_kit.restclient.tracing import traced_call
16
17
 
17
18
  Handler = Callable[[RestRequest], RestResponse]
18
19
 
@@ -39,7 +40,9 @@ class MemoryRestClient(ModelMappingMixin):
39
40
  resolver: EndpointResolver | None = None,
40
41
  default_headers: Mapping[str, str] | None = None,
41
42
  raise_for_status: bool = True,
43
+ name: str = "",
42
44
  ) -> None:
45
+ self.name = name
43
46
  self._auth = authenticator or NoAuth()
44
47
  self._resolver = resolver
45
48
  self._default_headers = dict(default_headers or {})
@@ -100,22 +103,24 @@ class MemoryRestClient(ModelMappingMixin):
100
103
  request = await self._auth.apply(request)
101
104
  self.calls.append(request)
102
105
 
103
- stub = self._stubs.get((method.upper(), service))
104
- if stub is None:
105
- response = RestResponse(
106
- status_code=404,
107
- headers={},
108
- content=b"",
109
- url=url,
110
- )
111
- elif callable(stub):
112
- response = stub(request)
113
- else:
114
- response = stub
115
-
116
- if self._raise and not response.is_success:
117
- raise RestHTTPError(response.status_code, response=response)
118
- return response
106
+ with traced_call(self.name, request.method, request.url) as call:
107
+ stub = self._stubs.get((method.upper(), service))
108
+ if stub is None:
109
+ response = RestResponse(
110
+ status_code=404,
111
+ headers={},
112
+ content=b"",
113
+ url=url,
114
+ )
115
+ elif callable(stub):
116
+ response = stub(request)
117
+ else:
118
+ response = stub
119
+
120
+ call.record(response.status_code)
121
+ if self._raise and not response.is_success:
122
+ raise RestHTTPError(response.status_code, response=response)
123
+ return response
119
124
 
120
125
  async def get(self, service: str, **kwargs: Any) -> RestResponse:
121
126
  return await self.request("GET", service, **kwargs)
@@ -83,6 +83,8 @@ class RestClientRegistry:
83
83
  Inyecta dobles de prueba o stubs sin pasar por el transporte HTTP: ``get``
84
84
  devuelve este cliente tal cual, sin construir un ``HttpxRestClient``.
85
85
  """
86
+ if hasattr(client, "name") and not client.name:
87
+ client.name = name
86
88
  self._clients[name] = client
87
89
  return self
88
90
 
@@ -108,6 +110,7 @@ class RestClientRegistry:
108
110
  authenticator=service.authenticator,
109
111
  retry_policy=service.retry_policy,
110
112
  circuit_breaker=service.circuit_breaker,
113
+ name=name,
111
114
  )
112
115
 
113
116
  if service.config.cache_ttl_seconds or service.config.cacheable:
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from typing import Any, Iterator
5
+ from urllib.parse import urlsplit
6
+
7
+ try:
8
+ from opentelemetry import trace
9
+ from opentelemetry.trace import SpanKind, Status, StatusCode
10
+ except ImportError:
11
+ trace = None
12
+
13
+ TRACER_NAME = "persistence_kit.restclient"
14
+
15
+
16
+ class TracedCall:
17
+ def __init__(self, span: Any = None) -> None:
18
+ self._span = span
19
+
20
+ def record(self, status_code: int, attempts: int = 0) -> None:
21
+ if self._span is None:
22
+ return
23
+ self._span.set_attribute("http.response.status_code", status_code)
24
+ if attempts:
25
+ self._span.set_attribute("http.request.resend_count", attempts)
26
+ if status_code >= 400:
27
+ self._span.set_status(Status(StatusCode.ERROR))
28
+
29
+
30
+ @contextmanager
31
+ def traced_call(service_name: str, method: str, url: str) -> Iterator[TracedCall]:
32
+ if trace is None:
33
+ yield TracedCall()
34
+ return
35
+ parts = urlsplit(url)
36
+ path = parts.path or "/"
37
+ verb = method.upper()
38
+ tracer = trace.get_tracer(TRACER_NAME)
39
+ with tracer.start_as_current_span(
40
+ f"{verb} {path}",
41
+ kind=SpanKind.CLIENT,
42
+ record_exception=False,
43
+ set_status_on_exception=False,
44
+ ) as span:
45
+ span.set_attribute("http.request.method", verb)
46
+ span.set_attribute("url.path", path)
47
+ if parts.hostname:
48
+ span.set_attribute("server.address", parts.hostname)
49
+ if service_name:
50
+ span.set_attribute("peer.service", service_name)
51
+ try:
52
+ yield TracedCall(span)
53
+ except Exception as error:
54
+ status_code = getattr(error, "status_code", None)
55
+ if isinstance(status_code, int):
56
+ span.set_attribute("http.response.status_code", status_code)
57
+ span.set_attribute("error.type", type(error).__name__)
58
+ span.set_status(Status(StatusCode.ERROR))
59
+ raise
@@ -1,6 +1,6 @@
1
1
  from enum import Enum
2
2
 
3
- from pydantic import field_validator, model_validator
3
+ from pydantic import Field, field_validator, model_validator
4
4
  from pydantic_settings import SettingsConfigDict
5
5
 
6
6
  from persistence_kit.settings.repo_settings import RepoSettings
@@ -57,6 +57,7 @@ class PersistenceKitSettings(RepoSettings):
57
57
  auth_login_rate_limit: int = 10
58
58
  auth_refresh_rate_limit: int = 30
59
59
  auth_password_rate_limit: int = 5
60
+ trusted_proxy_hops: int = Field(default=1, ge=0)
60
61
 
61
62
  # Export Storage
62
63
  export_storage_provider: ExportStorageProvider = ExportStorageProvider.LOCAL
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "persistence-kit"
3
- version = "3.14.0"
3
+ version = "3.15.0"
4
4
  description = "Reusable persistence and repository toolkit"
5
5
  authors = ["Andres Felipe Serrano Barrios <andresfserrano1@gmail.com>"]
6
6
  readme = "README.md"
@@ -35,8 +35,9 @@ boto3 = {version = "^1.35.0", optional = true}
35
35
  pyjwt = {version = "^2.10.1", extras = ["crypto"], optional = true}
36
36
  python-multipart = {version = ">=0.0.31,<1.0.0", optional = true}
37
37
  httpx = {version = ">=0.28.0,<1.0.0", optional = true}
38
- cryptography = {version = "^42.0.0", optional = true}
38
+ cryptography = {version = ">=42.0.0", optional = true}
39
39
  jinja2 = {version = "^3.1.4", optional = true}
40
+ opentelemetry-api = {version = ">=1.20.0,<2.0.0", optional = true}
40
41
 
41
42
  [tool.poetry.extras]
42
43
  api = ["fastapi", "python-multipart"]
@@ -48,12 +49,14 @@ encrypted = ["fastapi", "cryptography"]
48
49
  dynamodb = ["boto3"]
49
50
  restclient = ["httpx"]
50
51
  mail = ["jinja2"]
51
- testing = ["fastapi", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2"]
52
- all = ["fastapi", "boto3", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2"]
52
+ tracing = ["opentelemetry-api"]
53
+ testing = ["fastapi", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2", "opentelemetry-api"]
54
+ all = ["fastapi", "boto3", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2", "opentelemetry-api"]
53
55
 
54
56
  [tool.poetry.group.dev.dependencies]
55
57
  pytest = "^8.3.0"
56
58
  pytest-asyncio = ">=0.23.0,<2.0.0"
59
+ opentelemetry-sdk = ">=1.20.0,<2.0.0"
57
60
 
58
61
  [tool.pytest.ini_options]
59
62
  testpaths = ["tests"]