python-sendparcel-inpost 0.1.2__tar.gz → 0.3.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.
- python_sendparcel_inpost-0.3.0/.github/workflows/ci.yml +28 -0
- python_sendparcel_inpost-0.3.0/.github/workflows/release.yml +23 -0
- python_sendparcel_inpost-0.3.0/CHANGELOG.md +63 -0
- python_sendparcel_inpost-0.3.0/LICENSE +10 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/PKG-INFO +8 -2
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/pyproject.toml +10 -2
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/__init__.py +5 -1
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/circuit_breaker.py +155 -0
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/client.py +425 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/exceptions.py +29 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/__init__.py +6 -1
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/providers/base.py +414 -0
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/providers/courier.py +85 -0
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/providers/locker.py +95 -0
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/rate_limiter.py +57 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/status_mapping.py +23 -13
- python_sendparcel_inpost-0.3.0/tests/__init__.py +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/conftest.py +6 -1
- python_sendparcel_inpost-0.3.0/tests/test_client.py +567 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_config_schema.py +16 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_courier_provider.py +48 -64
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_locker_provider.py +201 -88
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_status_mapping.py +13 -4
- python_sendparcel_inpost-0.3.0/tests/test_transport.py +54 -0
- python_sendparcel_inpost-0.1.2/CHANGELOG.md +0 -23
- python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/client.py +0 -177
- python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/enums.py +0 -18
- python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/providers/courier.py +0 -308
- python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/providers/locker.py +0 -322
- python_sendparcel_inpost-0.1.2/tests/test_client.py +0 -233
- python_sendparcel_inpost-0.1.2/tests/test_enums.py +0 -31
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/.gitignore +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/CONTRIBUTING.md +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/README.md +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/api.md +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/configuration.md +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/index.md +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/quickstart.md +0 -0
- /python_sendparcel_inpost-0.1.2/tests/__init__.py → /python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/py.typed +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/types.py +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_entry_points.py +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_exceptions.py +0 -0
- {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_types.py +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
lint:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
with:
|
|
15
|
+
enable-cache: true
|
|
16
|
+
- run: uv sync --extra dev
|
|
17
|
+
- run: uv run ruff check .
|
|
18
|
+
- run: uv run ruff format --check .
|
|
19
|
+
|
|
20
|
+
test:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: astral-sh/setup-uv@v5
|
|
25
|
+
with:
|
|
26
|
+
enable-cache: true
|
|
27
|
+
- run: uv sync --extra dev
|
|
28
|
+
- run: uv run pytest tests/ --cov=sendparcel_inpost --cov-report=xml -v
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: release
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
with:
|
|
19
|
+
enable-cache: true
|
|
20
|
+
- name: Build package
|
|
21
|
+
run: uv build
|
|
22
|
+
- name: Publish to PyPI
|
|
23
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [0.3.0] - 2026-07-04
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Transport factory now returns a shared, cached `ShipXClient` per
|
|
13
|
+
configuration instead of building (and leaking) a fresh
|
|
14
|
+
`httpx.AsyncClient` on every operation. Connections are reused and
|
|
15
|
+
the circuit breaker, rate limiter, and metrics keep their state
|
|
16
|
+
across operations.
|
|
17
|
+
- HTTP 429 responses are retried (also for non-idempotent operations —
|
|
18
|
+
a rate-limited request was never processed), honoring the
|
|
19
|
+
`Retry-After` header when present.
|
|
20
|
+
- Retry log messages now name the operation being retried.
|
|
21
|
+
- Removed an unreachable `httpx.HTTPStatusError` retry branch.
|
|
22
|
+
- Replaced deprecated `asyncio.get_event_loop().time()` with
|
|
23
|
+
`time.monotonic()`; rate limiter now uses anyio primitives (works
|
|
24
|
+
under trio).
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `sendparcel_inpost.aclose_transports()` — closes and evicts all
|
|
29
|
+
shared ShipX clients; call on application shutdown.
|
|
30
|
+
- Resilience settings exposed in `config_schema`: `rate_limit`,
|
|
31
|
+
`burst`, `max_retries`, `retry_base_delay`, `circuit_threshold`,
|
|
32
|
+
`circuit_cooldown`.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Requires `python-sendparcel>=0.3.0` (unified `BaseProvider`,
|
|
37
|
+
`CallbackContext`, transport injection).
|
|
38
|
+
|
|
39
|
+
## [0.2.0] - 2025-06-05
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Adapted to the python-sendparcel 0.2.x provider API:
|
|
44
|
+
`CallbackContext`-based callbacks and transport injection via
|
|
45
|
+
`transport_factory`.
|
|
46
|
+
- Removed dead enums (`ShipXService`, `ShipXParcelTemplate`).
|
|
47
|
+
|
|
48
|
+
## [0.1.0] - 2026-02-16
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- InPost ShipX provider for python-sendparcel
|
|
53
|
+
- `InPostLockerProvider` for Paczkomat locker deliveries
|
|
54
|
+
- `InPostCourierProvider` for door-to-door courier deliveries
|
|
55
|
+
- `ShipXClient` standalone async HTTP client for the ShipX API
|
|
56
|
+
- ShipX exception hierarchy (`ShipXAPIError`, `ShipXAuthenticationError`, `ShipXValidationError`)
|
|
57
|
+
- ShipX-specific enums (`ShipXService`, `ShipXParcelTemplate`)
|
|
58
|
+
- ShipX-specific TypedDicts (`ShipXAddress`, `ShipXPeer`, `ShipXParcel`, `ShipXShipmentPayload`)
|
|
59
|
+
- Status mapping from 24 ShipX statuses to 8 sendparcel statuses
|
|
60
|
+
- Webhook verification by InPost source IP range (`91.216.25.0/24`)
|
|
61
|
+
- Address conversion with legacy name-splitting fallback
|
|
62
|
+
- Entry-point registration for auto-discovery
|
|
63
|
+
- Full test suite (93 tests)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2025, Dominik Kozaczko
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel-inpost
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: InPost ShipX provider for python-sendparcel.
|
|
5
|
+
Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel-inpost
|
|
6
|
+
Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel-inpost
|
|
7
|
+
Project-URL: Changelog, https://github.com/python-sendparcel/python-sendparcel-inpost/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issue Tracker, https://github.com/python-sendparcel/python-sendparcel-inpost/issues
|
|
5
9
|
Author-email: Dominik Kozaczko <dominik@kozaczko.info>
|
|
6
10
|
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
7
12
|
Keywords: inpost,parcel,sendparcel,shipping,shipx
|
|
8
13
|
Classifier: Development Status :: 3 - Alpha
|
|
9
14
|
Classifier: Intended Audience :: Developers
|
|
@@ -17,13 +22,14 @@ Classifier: Typing :: Typed
|
|
|
17
22
|
Requires-Python: >=3.12
|
|
18
23
|
Requires-Dist: anyio>=4.0
|
|
19
24
|
Requires-Dist: httpx>=0.27.0
|
|
20
|
-
Requires-Dist: python-sendparcel>=0.
|
|
25
|
+
Requires-Dist: python-sendparcel>=0.3.0
|
|
21
26
|
Provides-Extra: dev
|
|
22
27
|
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
23
28
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
24
29
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
30
|
Requires-Dist: respx>=0.22.0; extra == 'dev'
|
|
26
31
|
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ty>=0.0.1a11; extra == 'dev'
|
|
27
33
|
Description-Content-Type: text/markdown
|
|
28
34
|
|
|
29
35
|
# python-sendparcel-inpost
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-sendparcel-inpost"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "InPost ShipX provider for python-sendparcel."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
19
|
"Typing :: Typed",
|
|
20
20
|
]
|
|
21
|
-
dependencies = ["python-sendparcel>=0.
|
|
21
|
+
dependencies = ["python-sendparcel>=0.3.0", "httpx>=0.27.0", "anyio>=4.0"]
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
24
|
dev = [
|
|
@@ -27,8 +27,16 @@ dev = [
|
|
|
27
27
|
"pytest-cov>=5.0",
|
|
28
28
|
"respx>=0.22.0",
|
|
29
29
|
"ruff>=0.9.0",
|
|
30
|
+
"ty>=0.0.1a11",
|
|
30
31
|
]
|
|
31
32
|
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/python-sendparcel/python-sendparcel-inpost"
|
|
35
|
+
# Documentation = "https://python-sendparcel-inpost.readthedocs.io/"
|
|
36
|
+
Repository = "https://github.com/python-sendparcel/python-sendparcel-inpost"
|
|
37
|
+
Changelog = "https://github.com/python-sendparcel/python-sendparcel-inpost/blob/main/CHANGELOG.md"
|
|
38
|
+
"Issue Tracker" = "https://github.com/python-sendparcel/python-sendparcel-inpost/issues"
|
|
39
|
+
|
|
32
40
|
[project.entry-points."sendparcel.providers"]
|
|
33
41
|
inpost_locker = "sendparcel_inpost.providers.locker:InPostLockerProvider"
|
|
34
42
|
inpost_courier = "sendparcel_inpost.providers.courier:InPostCourierProvider"
|
{python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/__init__.py
RENAMED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""InPost ShipX provider for python-sendparcel."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
4
|
|
|
5
5
|
from sendparcel_inpost.client import ShipXClient
|
|
6
|
+
from sendparcel_inpost.exceptions import CircuitBreakerError
|
|
7
|
+
from sendparcel_inpost.providers.base import aclose_transports
|
|
6
8
|
from sendparcel_inpost.providers.courier import InPostCourierProvider
|
|
7
9
|
from sendparcel_inpost.providers.locker import InPostLockerProvider
|
|
8
10
|
|
|
9
11
|
__all__ = [
|
|
12
|
+
"CircuitBreakerError",
|
|
10
13
|
"InPostCourierProvider",
|
|
11
14
|
"InPostLockerProvider",
|
|
12
15
|
"ShipXClient",
|
|
13
16
|
"__version__",
|
|
17
|
+
"aclose_transports",
|
|
14
18
|
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Circuit breaker and metrics for external API resilience."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ShipXMetrics:
|
|
13
|
+
"""Collects metrics for a single ShipXClient instance.
|
|
14
|
+
|
|
15
|
+
Thread-safe. Use ``snapshot()`` to get a point-in-time view.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
19
|
+
_request_count: int = 0
|
|
20
|
+
_error_count: int = 0
|
|
21
|
+
_circuit_trip_count: int = 0
|
|
22
|
+
_retry_count: int = 0
|
|
23
|
+
_total_latency_ms: float = 0.0
|
|
24
|
+
_max_latency_ms: float = 0.0
|
|
25
|
+
_last_error: str | None = None
|
|
26
|
+
_last_error_at: float | None = None
|
|
27
|
+
_circuit_state: str = "closed"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def circuit_state(self) -> str:
|
|
31
|
+
with self._lock:
|
|
32
|
+
return self._circuit_state
|
|
33
|
+
|
|
34
|
+
def set_circuit_state(self, state: str) -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._circuit_state = state
|
|
37
|
+
|
|
38
|
+
def record_request(
|
|
39
|
+
self,
|
|
40
|
+
latency_ms: float,
|
|
41
|
+
success: bool,
|
|
42
|
+
error: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._request_count += 1
|
|
46
|
+
self._total_latency_ms += latency_ms
|
|
47
|
+
if latency_ms > self._max_latency_ms:
|
|
48
|
+
self._max_latency_ms = latency_ms
|
|
49
|
+
if not success:
|
|
50
|
+
self._error_count += 1
|
|
51
|
+
self._last_error = error
|
|
52
|
+
self._last_error_at = time.time()
|
|
53
|
+
|
|
54
|
+
def record_circuit_trip(self) -> None:
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._circuit_trip_count += 1
|
|
57
|
+
|
|
58
|
+
def record_retry(self) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._retry_count += 1
|
|
61
|
+
|
|
62
|
+
def snapshot(self) -> dict[str, Any]:
|
|
63
|
+
"""Return a point-in-time copy of all metrics."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
avg_latency = (
|
|
66
|
+
self._total_latency_ms / self._request_count
|
|
67
|
+
if self._request_count > 0
|
|
68
|
+
else 0.0
|
|
69
|
+
)
|
|
70
|
+
return {
|
|
71
|
+
"request_count": self._request_count,
|
|
72
|
+
"error_count": self._error_count,
|
|
73
|
+
"retry_count": self._retry_count,
|
|
74
|
+
"circuit_trip_count": self._circuit_trip_count,
|
|
75
|
+
"circuit_state": self._circuit_state,
|
|
76
|
+
"avg_latency_ms": round(avg_latency, 2),
|
|
77
|
+
"max_latency_ms": round(self._max_latency_ms, 2),
|
|
78
|
+
"last_error": self._last_error,
|
|
79
|
+
"last_error_at": self._last_error_at,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _CircuitBreaker:
|
|
84
|
+
"""Simple circuit breaker for the ShipX API.
|
|
85
|
+
|
|
86
|
+
States:
|
|
87
|
+
- CLOSED: normal operation, requests pass through
|
|
88
|
+
- OPEN: circuit tripped, requests fail fast
|
|
89
|
+
- HALF_OPEN: one probe request allowed after cooldown
|
|
90
|
+
|
|
91
|
+
Transitions:
|
|
92
|
+
- CLOSED → OPEN: after `threshold` consecutive failures
|
|
93
|
+
- OPEN → HALF_OPEN: after `cooldown` seconds
|
|
94
|
+
- HALF_OPEN → CLOSED: probe request succeeds
|
|
95
|
+
- HALF_OPEN → OPEN: probe request fails
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
class State:
|
|
99
|
+
CLOSED = "closed"
|
|
100
|
+
OPEN = "open"
|
|
101
|
+
HALF_OPEN = "half_open"
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
threshold: int = 5,
|
|
106
|
+
cooldown: float = 30.0,
|
|
107
|
+
) -> None:
|
|
108
|
+
self._threshold = threshold
|
|
109
|
+
self._cooldown = cooldown
|
|
110
|
+
self._state = self.State.CLOSED
|
|
111
|
+
self._failures = 0
|
|
112
|
+
self._opened_at: float | None = None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def state(self) -> str:
|
|
116
|
+
"""Current circuit state, transitioning OPEN → HALF_OPEN on cooldown."""
|
|
117
|
+
if self._state == self.State.OPEN and self._opened_at is not None:
|
|
118
|
+
elapsed = time.monotonic() - self._opened_at
|
|
119
|
+
if elapsed >= self._cooldown:
|
|
120
|
+
self._state = self.State.HALF_OPEN
|
|
121
|
+
return self._state
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def failures(self) -> int:
|
|
125
|
+
return self._failures
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def cooldown_remaining(self) -> float:
|
|
129
|
+
if self._state != self.State.OPEN or self._opened_at is None:
|
|
130
|
+
return 0.0
|
|
131
|
+
elapsed = time.monotonic() - self._opened_at
|
|
132
|
+
return max(0.0, self._cooldown - elapsed)
|
|
133
|
+
|
|
134
|
+
def allow_request(self) -> bool:
|
|
135
|
+
"""Check if a request should be allowed through."""
|
|
136
|
+
current = self.state # triggers OPEN → HALF_OPEN transition
|
|
137
|
+
return current != self.State.OPEN
|
|
138
|
+
|
|
139
|
+
def record_success(self) -> None:
|
|
140
|
+
"""Record a successful request."""
|
|
141
|
+
self._failures = 0
|
|
142
|
+
self._state = self.State.CLOSED
|
|
143
|
+
self._opened_at = None
|
|
144
|
+
|
|
145
|
+
def record_failure(self) -> None:
|
|
146
|
+
"""Record a failed request."""
|
|
147
|
+
self._failures += 1
|
|
148
|
+
if self.state == self.State.HALF_OPEN:
|
|
149
|
+
# Probe failed — reopen circuit
|
|
150
|
+
self._state = self.State.OPEN
|
|
151
|
+
self._opened_at = time.monotonic()
|
|
152
|
+
elif self._failures >= self._threshold:
|
|
153
|
+
# Threshold reached — open circuit
|
|
154
|
+
self._state = self.State.OPEN
|
|
155
|
+
self._opened_at = time.monotonic()
|