openframe-core 3.3.0__tar.gz → 3.4.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 (150) hide show
  1. {openframe_core-3.3.0 → openframe_core-3.4.0}/.github/CHANGELOG.md +42 -1
  2. {openframe_core-3.3.0 → openframe_core-3.4.0}/PKG-INFO +1 -1
  3. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/plugins/__init__.py +3 -3
  4. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/plugins/registry.py +9 -5
  5. openframe_core-3.4.0/openframe/core/resilience/__init__.py +44 -0
  6. openframe_core-3.4.0/openframe/core/resilience/circuit_breaker.py +260 -0
  7. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/runtime/__init__.py +4 -1
  8. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/runtime/bootstrap.py +9 -5
  9. {openframe_core-3.3.0 → openframe_core-3.4.0}/pyproject.toml +1 -1
  10. openframe_core-3.4.0/tests/test_resilience.py +233 -0
  11. {openframe_core-3.3.0 → openframe_core-3.4.0}/.github/requirements/docs.txt +0 -0
  12. {openframe_core-3.3.0 → openframe_core-3.4.0}/.github/requirements/test.txt +0 -0
  13. {openframe_core-3.3.0 → openframe_core-3.4.0}/.github/workflows/app-test.yml +0 -0
  14. {openframe_core-3.3.0 → openframe_core-3.4.0}/.github/workflows/auto-docs.yml +0 -0
  15. {openframe_core-3.3.0 → openframe_core-3.4.0}/.github/workflows/python-build.yml +0 -0
  16. {openframe_core-3.3.0 → openframe_core-3.4.0}/.gitignore +0 -0
  17. {openframe_core-3.3.0 → openframe_core-3.4.0}/.vscode/settings.json +0 -0
  18. {openframe_core-3.3.0 → openframe_core-3.4.0}/LICENSE +0 -0
  19. {openframe_core-3.3.0 → openframe_core-3.4.0}/README.md +0 -0
  20. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/assets/images/banner.png +0 -0
  21. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/assets/images/favicon.png +0 -0
  22. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/assets/images/logo-dark.png +0 -0
  23. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/assets/images/logo-light.png +0 -0
  24. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/assets/javascripts/extra.js +0 -0
  25. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/assets/stylesheets/extra.css +0 -0
  26. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/cicd/pipeline.md +0 -0
  27. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/getting-started/index.md +0 -0
  28. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/config.md +0 -0
  29. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/exceptions.md +0 -0
  30. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/health.md +0 -0
  31. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/inbound.md +0 -0
  32. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/index.md +0 -0
  33. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/middleware.md +0 -0
  34. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/plugins.md +0 -0
  35. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/ports.md +0 -0
  36. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/runtime.md +0 -0
  37. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/telemetry.md +0 -0
  38. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/testing.md +0 -0
  39. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/code/modules/tracing.md +0 -0
  40. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/developer-guide/debugging.md +0 -0
  41. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/developer-guide/explore.md +0 -0
  42. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/developer-guide/first-change.md +0 -0
  43. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/developer-guide/how-it-works.md +0 -0
  44. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/developer-guide/local-dev.md +0 -0
  45. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/developer-guide/quick-start/index.md +0 -0
  46. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/index.md +0 -0
  47. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-001-namespace.md +0 -0
  48. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-002-hexagonal.md +0 -0
  49. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-003-async.md +0 -0
  50. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-004-pydantic.md +0 -0
  51. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-005-middleware.md +0 -0
  52. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-006-unified-port-lifecycle.md +0 -0
  53. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-007-schema-contracts.md +0 -0
  54. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/adrs/index.md +0 -0
  55. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/capability-taxonomy.md +0 -0
  56. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/component-lifecycle.md +0 -0
  57. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/data-flows.md +0 -0
  58. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/design-decisions.md +0 -0
  59. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/error-taxonomy.md +0 -0
  60. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/adapter-lifecycle.md +0 -0
  61. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/error-propagation.md +0 -0
  62. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/index.md +0 -0
  63. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/template-wiring.md +0 -0
  64. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/tracing-flow.md +0 -0
  65. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/architecture/system-architecture.md +0 -0
  66. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/deployment/cicd.md +0 -0
  67. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/deployment/environments.md +0 -0
  68. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/deployment/secrets-and-config.md +0 -0
  69. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/infrastructure/networking.md +0 -0
  70. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/infrastructure/platform.md +0 -0
  71. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/infrastructure/storage.md +0 -0
  72. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/index.md +0 -0
  73. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/monitoring.md +0 -0
  74. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/runbooks/adapter-connection-failure.md +0 -0
  75. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/runbooks/modal-cold-start-spike.md +0 -0
  76. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/runbooks/postgres-pool-exhausted.md +0 -0
  77. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/runbooks/queue-consumer-stuck.md +0 -0
  78. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/runbooks/redis-connection-lost.md +0 -0
  79. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/operations/runbooks/telemetry-export-failing.md +0 -0
  80. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/overview/glossary.md +0 -0
  81. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/overview/roadmap.md +0 -0
  82. {openframe_core-3.3.0 → openframe_core-3.4.0}/docs/technical/overview/system-overview.md +0 -0
  83. {openframe_core-3.3.0 → openframe_core-3.4.0}/mkdocs.yml +0 -0
  84. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/__init__.py +0 -0
  85. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/__init__.py +0 -0
  86. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/config/__init__.py +0 -0
  87. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/config/settings.py +0 -0
  88. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/exceptions/__init__.py +0 -0
  89. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/exceptions/adapter.py +0 -0
  90. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/exceptions/base.py +0 -0
  91. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/exceptions/codes.py +0 -0
  92. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/exceptions/plugin.py +0 -0
  93. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/inbound/__init__.py +0 -0
  94. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/inbound/command.py +0 -0
  95. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/inbound/context.py +0 -0
  96. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/inbound/query.py +0 -0
  97. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/inbound/usecase.py +0 -0
  98. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/middleware/__init__.py +0 -0
  99. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/middleware/telemetry.py +0 -0
  100. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/middleware/types.py +0 -0
  101. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/__init__.py +0 -0
  102. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/capability.py +0 -0
  103. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/context.py +0 -0
  104. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/health.py +0 -0
  105. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/identity.py +0 -0
  106. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/lifecycle.py +0 -0
  107. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/outbound/__init__.py +0 -0
  108. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/outbound/consumer.py +0 -0
  109. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/outbound/producer.py +0 -0
  110. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/outbound/repository.py +0 -0
  111. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/ports/port.py +0 -0
  112. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/schemas/__init__.py +0 -0
  113. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/telemetry/__init__.py +0 -0
  114. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/telemetry/setup.py +0 -0
  115. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/__init__.py +0 -0
  116. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/contracts/__init__.py +0 -0
  117. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/contracts/consumer.py +0 -0
  118. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/contracts/inbound.py +0 -0
  119. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/contracts/lifecycle.py +0 -0
  120. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/contracts/port.py +0 -0
  121. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/contracts/producer.py +0 -0
  122. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/contracts/repository.py +0 -0
  123. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/fakes/__init__.py +0 -0
  124. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/fakes/consumer.py +0 -0
  125. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/fakes/inbound.py +0 -0
  126. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/fakes/producer.py +0 -0
  127. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/fakes/repository.py +0 -0
  128. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/testing/fixtures.py +0 -0
  129. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/tracing/__init__.py +0 -0
  130. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/tracing/propagation.py +0 -0
  131. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/core/tracing/proxy.py +0 -0
  132. {openframe_core-3.3.0 → openframe_core-3.4.0}/openframe/py.typed +0 -0
  133. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/conftest.py +0 -0
  134. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_config.py +0 -0
  135. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_exceptions.py +0 -0
  136. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_inbound.py +0 -0
  137. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_inbound_contracts.py +0 -0
  138. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_middleware.py +0 -0
  139. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_middleware_types.py +0 -0
  140. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_plugins.py +0 -0
  141. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_ports.py +0 -0
  142. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_ports_primitives.py +0 -0
  143. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_record_error.py +0 -0
  144. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_runtime.py +0 -0
  145. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_schemas.py +0 -0
  146. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_shutdown_telemetry.py +0 -0
  147. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_telemetry_sidecar.py +0 -0
  148. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_testing_fakes.py +0 -0
  149. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_tracing.py +0 -0
  150. {openframe_core-3.3.0 → openframe_core-3.4.0}/tests/test_tracing_propagation.py +0 -0
@@ -11,6 +11,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  ---
13
13
 
14
+ ## v3.4.0 — 2026-09-28
15
+
16
+ ### Added
17
+
18
+ - **`openframe.core.resilience.CircuitBreakerProxy`** (new module,
19
+ experimental) — zero-code async circuit breaker. Wraps any object and
20
+ short-circuits its async methods after `failure_threshold` consecutive
21
+ failures, raising `AdapterConnectionError` ("circuit open", retryable)
22
+ immediately instead of letting every caller block until
23
+ `operation_timeout` on every single call during a sustained outage.
24
+ Standard closed → open → half-open → closed/open state machine.
25
+ Mirrors `TracingProxy`'s exact wrapping mechanism (`__getattr__`
26
+ interception, fresh `getattr` resolution of the wrapped method on every
27
+ call, sync methods pass through unwrapped) so the two proxies compose
28
+ predictably — see the module docstring for the recommended composition
29
+ order (`CircuitBreakerProxy(TracingProxy(repo, ...), ...)`, not the
30
+ reverse, so a short-circuited call never produces a misleading adapter
31
+ span). Addresses a real gap: none of the four `openframe-adapters`
32
+ packages (or anything else in the ecosystem) previously had any
33
+ resilience pattern beyond "block until timeout, then raise."
34
+
35
+ ### Changed
36
+
37
+ - **Graduated `PluginRegistry` and `ApplicationBootstrap` from `experimental`
38
+ to `stable`** (`openframe.core.plugins.registry`, `openframe.core.runtime.bootstrap`,
39
+ and their respective package `__init__.py` re-exports). Graduation
40
+ criteria: (1) all 7 `openframe-local-validation-framework` services
41
+ already depend on both in production-shaped code across multiple
42
+ releases with no breaking change required of them; (2) v3.3.0's
43
+ `ApplicationBootstrap.compose()`/`get_all()`/`registry` additions landed
44
+ as pure additive extensions on top of the existing contract — no
45
+ breaking change was needed to add meaningful new functionality, which is
46
+ itself evidence the contract shape is right. This is the first module in
47
+ `openframe-core` to graduate via an explicit, written criteria set
48
+ (documented in each module's own docstring) rather than an unstated
49
+ judgment call — future modules going through the same
50
+ experimental→stable path should record their own criteria the same way.
51
+
52
+ ---
53
+
14
54
  ## v3.3.0 — 2026-09-28
15
55
 
16
56
  ### Added
@@ -385,7 +425,8 @@ Dev dependencies: `pytest>=8.0`, `pytest-asyncio>=0.23`, `httpx>=0.27`.
385
425
 
386
426
  ---
387
427
 
388
- [Unreleased]: https://github.com/openframe-org/openframe-core/compare/v3.3.0...HEAD
428
+ [Unreleased]: https://github.com/openframe-org/openframe-core/compare/v3.4.0...HEAD
429
+ [3.4.0]: https://github.com/openframe-org/openframe-core/compare/v3.3.0...v3.4.0
389
430
  [3.3.0]: https://github.com/openframe-org/openframe-core/compare/v3.2.1...v3.3.0
390
431
  [3.2.1]: https://github.com/openframe-org/openframe-core/compare/v3.2.0...v3.2.1
391
432
  [3.2.0]: https://github.com/openframe-org/openframe-core/compare/v3.1.0...v3.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openframe-core
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: OpenFrame Microservice Suite - platform kernel. Port contracts, testing utilities, plugin registry, and composition root.
5
5
  Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-core
6
6
  Project-URL: Documentation, https://furious-meteors.github.io/openframe-core/
@@ -9,10 +9,10 @@ plugin protocol. This package re-exports the ``ports`` types
9
9
  :class:`~openframe.core.plugins.registry.PluginRegistry` operates on for
10
10
  convenience, plus the registry itself.
11
11
 
12
- All symbols are **experimental** in v3.0 and will stabilise based on real
13
- usage.
12
+ Stable as of v3.3.0 — see ``PluginRegistry``'s own module docstring
13
+ (``openframe.core.plugins.registry``) for the graduation rationale.
14
14
 
15
- .. stability: experimental
15
+ .. stability: stable
16
16
  """
17
17
  from __future__ import annotations
18
18
 
@@ -17,10 +17,14 @@ correct shutdown ordering (ports then ``shutdown_telemetry()``). Use
17
17
  initialization order or are building a framework layer on top of
18
18
  openframe-core.
19
19
 
20
- All symbols are **experimental** in v3.0.
20
+ Stable as of v3.3.0 — graduated from experimental after all 7
21
+ ``openframe-local-validation-framework`` services proved it in
22
+ production-shaped code across multiple releases with no breaking change,
23
+ and the v3.3.0 ``ApplicationBootstrap.compose()``/``get_all()``/``registry``
24
+ additions landed as pure additive extensions on top of this contract,
25
+ confirming the shape was already right.
21
26
 
22
- .. stability: experimental
23
- Experimental — API may change or be removed in any version.
27
+ .. stability: stable
24
28
 
25
29
  Dependency order:
26
30
  ports → (apex; no plugins/registry-side imports)
@@ -52,7 +56,7 @@ if TYPE_CHECKING:
52
56
 
53
57
  __all__ = ["PluginRegistry"]
54
58
 
55
- __stability__ = "experimental"
59
+ __stability__ = "stable"
56
60
 
57
61
  _log = logging.getLogger(__name__)
58
62
 
@@ -75,7 +79,7 @@ class PluginRegistry:
75
79
  control over initialization order, or when building a framework layer
76
80
  that manages the registry lifecycle itself.
77
81
 
78
- .. stability: experimental
82
+ .. stability: stable
79
83
 
80
84
  Direct usage::
81
85
 
@@ -0,0 +1,44 @@
1
+ """
2
+ openframe/core/resilience/
3
+ ============================
4
+ Resilience patterns for the OpenFrame ecosystem.
5
+
6
+ Modules
7
+ -------
8
+ ``CircuitBreakerProxy``
9
+ Zero-code async circuit breaker. Wraps any object and short-circuits
10
+ its async methods after a run of consecutive failures, without
11
+ requiring the adapter or service layer to know it exists. Mirrors
12
+ ``openframe.core.tracing.TracingProxy``'s wrapping mechanism, so the
13
+ two proxies compose predictably.
14
+
15
+ Usage::
16
+
17
+ from openframe.core.resilience import CircuitBreakerProxy
18
+
19
+ raw_repo = PostgresRepository(settings)
20
+ guarded_repo = CircuitBreakerProxy(raw_repo, failure_threshold=5, reset_timeout=30.0)
21
+ # After 5 consecutive failures, calls raise AdapterConnectionError
22
+ # ("circuit open") immediately instead of blocking on operation_timeout.
23
+
24
+ Composing with ``TracingProxy`` — prefer wrapping the traced object, not
25
+ the other way around, so a short-circuited call never produces a
26
+ misleading adapter span for a call that never reached the adapter::
27
+
28
+ from openframe.core.tracing import TracingProxy
29
+
30
+ traced = TracingProxy(raw_repo, prefix="repository.item")
31
+ guarded = CircuitBreakerProxy(traced, failure_threshold=5, reset_timeout=30.0)
32
+
33
+ .. stability: experimental
34
+ New in v3.4.0. Experimental — API may change based on real usage,
35
+ consistent with how ``ApplicationBootstrap``/``PluginRegistry`` started
36
+ experimental and graduated once proven (see their own module docstrings).
37
+ """
38
+ from __future__ import annotations
39
+
40
+ from openframe.core.resilience.circuit_breaker import CircuitBreakerProxy, CircuitState
41
+
42
+ __all__ = ["CircuitBreakerProxy", "CircuitState"]
43
+
44
+ __stability__ = "experimental"
@@ -0,0 +1,260 @@
1
+ """
2
+ openframe/core/resilience/circuit_breaker.py
3
+ ===============================================
4
+ Generic async circuit breaker for the OpenFrame ecosystem.
5
+
6
+ ``CircuitBreakerProxy`` wraps any object and short-circuits its async
7
+ methods after a run of consecutive failures, giving a struggling backend
8
+ time to recover instead of every caller blocking until ``operation_timeout``
9
+ on every single call. Neither the wrapped adapter nor the service layer
10
+ need to know the circuit breaker exists.
11
+
12
+ Mirrors :class:`~openframe.core.tracing.proxy.TracingProxy`'s exact
13
+ wrapping mechanism (``__getattr__`` interception, ``object.__setattr__``
14
+ for the proxy's own state, fresh ``getattr`` resolution of the wrapped
15
+ method on every call — never a stale snapshot) so the two proxies compose
16
+ predictably and a reader who understands one understands the other.
17
+
18
+ State machine (per proxy instance, per wrapped object — not global)::
19
+
20
+ CLOSED --[failure_threshold consecutive failures]--> OPEN
21
+ OPEN --[reset_timeout elapsed]--> HALF_OPEN
22
+ HALF_OPEN --[probe call succeeds]--> CLOSED
23
+ HALF_OPEN --[probe call fails]--> OPEN (timer restarts)
24
+
25
+ Known simplification: multiple concurrent callers arriving right at the
26
+ ``reset_timeout`` boundary can all be let through as "probes" during the
27
+ same half-open window (this implementation does not cap in-flight probes
28
+ at exactly one). In practice this only matters under high concurrent call
29
+ volume at the exact moment the circuit would reopen or close — it does not
30
+ affect the CLOSED/OPEN transitions, which are strictly threshold- and
31
+ timeout-gated.
32
+
33
+ Dependency order: imports ``openframe.core.exceptions`` (lowest layer) only.
34
+ No dependency on ``telemetry``/``tracing`` — composes with ``TracingProxy``
35
+ by wrapping, not by sharing state.
36
+
37
+ .. stability: experimental
38
+ New in v3.4.0. API may change based on real usage.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import asyncio
43
+ import inspect
44
+ import logging
45
+ import time
46
+ from collections.abc import Callable
47
+ from enum import Enum, auto
48
+ from typing import Any
49
+
50
+ from openframe.core.exceptions import AdapterConnectionError
51
+
52
+ __all__ = ["CircuitBreakerProxy", "CircuitState"]
53
+
54
+ __stability__ = "experimental"
55
+
56
+ _log = logging.getLogger(__name__)
57
+
58
+
59
+ class CircuitState(Enum):
60
+ """Lifecycle state of a single :class:`CircuitBreakerProxy` instance."""
61
+
62
+ CLOSED = auto()
63
+ OPEN = auto()
64
+ HALF_OPEN = auto()
65
+
66
+
67
+ class CircuitBreakerProxy:
68
+ """
69
+ Generic async circuit breaker.
70
+
71
+ Wraps any object and intercepts every async method call. After
72
+ ``failure_threshold`` consecutive failures, the circuit opens: further
73
+ calls raise :class:`~openframe.core.exceptions.AdapterConnectionError`
74
+ immediately (retryable) without invoking the wrapped method, for
75
+ ``reset_timeout`` seconds. After that window elapses, the next call is
76
+ let through as a probe (half-open); if it succeeds the circuit closes
77
+ and failures reset to zero, if it fails the circuit reopens and the
78
+ timer restarts.
79
+
80
+ Reconnect safety:
81
+ Like ``TracingProxy``, the wrapped method is resolved fresh via
82
+ ``getattr`` on every invocation — never captured as a snapshot.
83
+
84
+ Sync methods:
85
+ Pass through unwrapped, uncounted — the circuit breaker only
86
+ applies to async methods, matching ``TracingProxy``'s convention.
87
+
88
+ Composing with ``TracingProxy``:
89
+ Order matters. ``TracingProxy(CircuitBreakerProxy(repo, ...), ...)``
90
+ traces the circuit breaker's own short-circuit raises as failed
91
+ spans. ``CircuitBreakerProxy(TracingProxy(repo, ...), ...)`` counts
92
+ failures on the traced calls but never creates a span for a
93
+ short-circuited call (the wrapped ``TracingProxy`` is never
94
+ reached while open). Prefer the latter — a call that never reached
95
+ the adapter is not an adapter span.
96
+
97
+ .. stability: experimental
98
+
99
+ Usage::
100
+
101
+ raw = PostgresRepository(settings)
102
+ guarded = CircuitBreakerProxy(raw, failure_threshold=5, reset_timeout=30.0)
103
+ # After 5 consecutive failures, calls raise AdapterConnectionError
104
+ # ("circuit open") immediately for 30s instead of blocking on
105
+ # operation_timeout each time.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ wrapped: object,
111
+ *,
112
+ failure_threshold: int = 5,
113
+ reset_timeout: float = 30.0,
114
+ name: str = "circuit-breaker",
115
+ ) -> None:
116
+ """
117
+ Initialise the proxy.
118
+
119
+ Args:
120
+ wrapped: The object whose async methods will be guarded.
121
+ failure_threshold: Consecutive failures (per method call,
122
+ across all methods on this proxy) before the circuit opens.
123
+ Must be >= 1.
124
+ reset_timeout: Seconds the circuit stays open before a probe
125
+ call is allowed through (half-open). Must be > 0.
126
+ name: Used as the ``adapter`` field on the
127
+ ``AdapterConnectionError`` raised while open, and in log
128
+ messages — e.g. ``"postgres-primary"``.
129
+
130
+ Raises:
131
+ ValueError: ``failure_threshold`` < 1 or ``reset_timeout`` <= 0.
132
+ """
133
+ if failure_threshold < 1:
134
+ raise ValueError(f"failure_threshold must be >= 1, got {failure_threshold!r}")
135
+ if reset_timeout <= 0:
136
+ raise ValueError(f"reset_timeout must be > 0, got {reset_timeout!r}")
137
+
138
+ object.__setattr__(self, "_wrapped", wrapped)
139
+ object.__setattr__(self, "_failure_threshold", failure_threshold)
140
+ object.__setattr__(self, "_reset_timeout", reset_timeout)
141
+ object.__setattr__(self, "_name", name)
142
+ object.__setattr__(self, "_cache", {})
143
+ object.__setattr__(self, "_state", CircuitState.CLOSED)
144
+ object.__setattr__(self, "_consecutive_failures", 0)
145
+ object.__setattr__(self, "_opened_at", 0.0)
146
+ # Guards state transitions against concurrent callers racing on
147
+ # the same proxy instance (e.g. two coroutines calling .get()
148
+ # concurrently while the circuit is deciding whether to open).
149
+ object.__setattr__(self, "_lock", asyncio.Lock())
150
+
151
+ @property
152
+ def state(self) -> CircuitState:
153
+ """Current circuit state. Read-only — for observability/tests."""
154
+ return object.__getattribute__(self, "_state")
155
+
156
+ def __getattr__(self, name: str) -> Any:
157
+ """
158
+ Intercept attribute access.
159
+
160
+ Returns a cached guarded closure for async methods, or the raw
161
+ attribute for sync methods and non-callable attributes.
162
+ """
163
+ cache: dict[str, Callable[..., Any]] = object.__getattribute__(self, "_cache")
164
+ if name in cache:
165
+ return cache[name]
166
+
167
+ wrapped = object.__getattribute__(self, "_wrapped")
168
+ probe = getattr(wrapped, name)
169
+
170
+ if not inspect.iscoroutinefunction(probe):
171
+ return probe
172
+
173
+ async def _guarded(*args: Any, **kwargs: Any) -> Any:
174
+ await self._before_call(name)
175
+ try:
176
+ current = getattr(object.__getattribute__(self, "_wrapped"), name)
177
+ result = await current(*args, **kwargs)
178
+ except Exception:
179
+ await self._on_failure(name)
180
+ raise
181
+ else:
182
+ await self._on_success(name)
183
+ return result
184
+
185
+ cache[name] = _guarded
186
+ return _guarded
187
+
188
+ async def _before_call(self, method_name: str) -> None:
189
+ """
190
+ Called before every guarded invocation.
191
+
192
+ Raises ``AdapterConnectionError`` immediately if the circuit is
193
+ open and ``reset_timeout`` has not yet elapsed. If the timeout has
194
+ elapsed, transitions to half-open and lets exactly this one call
195
+ through as a probe.
196
+ """
197
+ lock: asyncio.Lock = object.__getattribute__(self, "_lock")
198
+ async with lock:
199
+ state: CircuitState = object.__getattribute__(self, "_state")
200
+ if state is not CircuitState.OPEN:
201
+ return
202
+
203
+ opened_at: float = object.__getattribute__(self, "_opened_at")
204
+ reset_timeout: float = object.__getattribute__(self, "_reset_timeout")
205
+ if time.monotonic() - opened_at < reset_timeout:
206
+ name: str = object.__getattribute__(self, "_name")
207
+ raise AdapterConnectionError(
208
+ f"circuit open — {name}.{method_name} short-circuited "
209
+ f"after repeated failures; retry after "
210
+ f"{reset_timeout - (time.monotonic() - opened_at):.1f}s",
211
+ adapter=name,
212
+ operation=method_name,
213
+ )
214
+
215
+ # reset_timeout elapsed — allow exactly this call through as a probe.
216
+ object.__setattr__(self, "_state", CircuitState.HALF_OPEN)
217
+ _log.info("CircuitBreakerProxy %r half-open — probing %r", object.__getattribute__(self, "_name"), method_name)
218
+
219
+ async def _on_success(self, method_name: str) -> None:
220
+ """Reset failure count and close the circuit on any success."""
221
+ lock: asyncio.Lock = object.__getattribute__(self, "_lock")
222
+ async with lock:
223
+ object.__setattr__(self, "_consecutive_failures", 0)
224
+ if object.__getattribute__(self, "_state") is not CircuitState.CLOSED:
225
+ object.__setattr__(self, "_state", CircuitState.CLOSED)
226
+ _log.info(
227
+ "CircuitBreakerProxy %r closed — %r succeeded",
228
+ object.__getattribute__(self, "_name"),
229
+ method_name,
230
+ )
231
+
232
+ async def _on_failure(self, method_name: str) -> None:
233
+ """
234
+ Count the failure. Opens the circuit if the threshold is reached
235
+ (from closed) or immediately re-opens it (from half-open, where a
236
+ single probe failure is sufficient — the backend is still down).
237
+ """
238
+ lock: asyncio.Lock = object.__getattribute__(self, "_lock")
239
+ async with lock:
240
+ state: CircuitState = object.__getattribute__(self, "_state")
241
+ name: str = object.__getattribute__(self, "_name")
242
+
243
+ if state is CircuitState.HALF_OPEN:
244
+ object.__setattr__(self, "_state", CircuitState.OPEN)
245
+ object.__setattr__(self, "_opened_at", time.monotonic())
246
+ _log.warning(
247
+ "CircuitBreakerProxy %r re-opened — probe %r failed", name, method_name
248
+ )
249
+ return
250
+
251
+ failures = object.__getattribute__(self, "_consecutive_failures") + 1
252
+ object.__setattr__(self, "_consecutive_failures", failures)
253
+ threshold: int = object.__getattribute__(self, "_failure_threshold")
254
+ if failures >= threshold:
255
+ object.__setattr__(self, "_state", CircuitState.OPEN)
256
+ object.__setattr__(self, "_opened_at", time.monotonic())
257
+ _log.warning(
258
+ "CircuitBreakerProxy %r opened — %d consecutive failures (last: %r)",
259
+ name, failures, method_name,
260
+ )
@@ -12,7 +12,10 @@ See ``openframe.core.plugins.PluginRegistry`` if you need fine-grained
12
12
  control over initialization order or want to manage the registry lifecycle
13
13
  directly without the composition abstraction.
14
14
 
15
- .. stability: experimental
15
+ Stable as of v3.3.0 — see ``ApplicationBootstrap``'s own module docstring
16
+ (``openframe.core.runtime.bootstrap``) for the graduation rationale.
17
+
18
+ .. stability: stable
16
19
  """
17
20
  from __future__ import annotations
18
21
 
@@ -26,10 +26,14 @@ intentionally no separate ``PluginRegistry``-direct or ``deps.py``+``lru_cache``
26
26
  like when hand-rolled, minus the correct shutdown ordering tier 1-3 give you
27
27
  for free.
28
28
 
29
- All symbols are **experimental** in v3.0.
29
+ Stable as of v3.3.0 — graduated from experimental after all 7
30
+ ``openframe-local-validation-framework`` services proved it in
31
+ production-shaped code across multiple releases with no breaking change,
32
+ and the v3.3.0 ``compose()``/``get_all()``/``registry`` additions landed
33
+ as pure additive extensions on top of this contract, confirming the shape
34
+ was already right.
30
35
 
31
- .. stability: experimental
32
- Experimental — API may change or be removed in any version.
36
+ .. stability: stable
33
37
 
34
38
  Dependency order:
35
39
  ports → (apex)
@@ -49,7 +53,7 @@ if TYPE_CHECKING:
49
53
 
50
54
  __all__ = ["ApplicationBootstrap"]
51
55
 
52
- __stability__ = "experimental"
56
+ __stability__ = "stable"
53
57
 
54
58
 
55
59
  class ApplicationBootstrap:
@@ -63,7 +67,7 @@ class ApplicationBootstrap:
63
67
  the OTel SDK is flushed via ``shutdown_telemetry()`` so no spans are
64
68
  silently dropped at process exit.
65
69
 
66
- .. stability: experimental
70
+ .. stability: stable
67
71
 
68
72
  **Tier 1 — no subclass, one or a few ports**::
69
73
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openframe-core"
7
- version = "3.3.0"
7
+ version = "3.4.0"
8
8
  description = "OpenFrame Microservice Suite - platform kernel. Port contracts, testing utilities, plugin registry, and composition root."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"