Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]
### Added
- `Evnex(timeout=...)` sets the timeout, in seconds, for the cloud API's own
endpoints (default 15). It is passed per request, so it always overrides the
httpx client's timeout; a shared client such as Home Assistant's cannot
change it. Commands relayed to the charge point, and methods that take a
`timeout` argument, keep their own.
### Changed
- Read timeouts are no longer retried. A timed-out request reached the API and
went unanswered; retrying it five times with backoff could occupy several
minutes, longer than a typical poller's whole interval. Callers now see the
`httpx.ReadTimeout` immediately and decide when to ask again.
- The default request timeout is 15 seconds, down from 30. Most cloud reads
answer in under 0.2s, but `get_org_charge_points` measures 3.8s and
`get_charge_point_sessions` 2.9s, so the old httpx default of 5s left those
two barely any headroom -- the likely source of the intermittent
`ReadTimeout` on `get_org_charge_points` under Home Assistant, which polls
them 288 times a day. The `commands/*` endpoints relay to the charge point
over OCPP and now share a 30s `COMMAND_TIMEOUT`: `get-override` was capped at
15s, inside the 15-20s round trip a healthy charger normally takes, so it
reported ONLINE chargers as offline.
- Restored the `timeout` parameter on `get_charge_point_energy_meter_reading`,
dropped in 0.7.1 when the blanket 30s default replaced it.
### Removed

## 0.4.5 - 2024-08-21
Expand Down
71 changes: 57 additions & 14 deletions evnex/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,21 @@
EVNEX_VERSION = "unknown"


# POST /charge-points/{id}/commands/* are not cloud reads: the API relays the
# command to the charge point over OCPP and holds the HTTP response open until
# the hardware answers. Measured round trips on an idle, ONLINE E2 run 15-20s
# (#128), so a tighter timeout reports a perfectly healthy charger as offline.
COMMAND_TIMEOUT = 30

# Default for the cloud API's own endpoints; see Evnex._request for the
# measurements behind it. Override per client with Evnex(timeout=...).
DEFAULT_TIMEOUT = 15

NON_RETRYABLE_EXCEPTIONS = (
ValidationError,
EvnexAuthError,
EvnexConfigurationError,
ReadTimeout,
)


Expand All @@ -75,6 +86,12 @@ def api_retry(*extra_non_retryable: type[BaseException]):
Authentication failures and validation errors are never retried, nor are
any exception types passed as arguments. Authentication recovery happens
in the transport layer (EvnexHttpxAuth), independent of this policy.

Read timeouts are never retried either: the request reached the API and
went unanswered, so retrying here only multiplies a delay the caller has
already waited out — five attempts with backoff can occupy minutes, which
is longer than a poller's whole interval. Failing fast leaves the decision
of when to ask again with the caller.
"""
return retry(
wait=wait_random_exponential(multiplier=1, max=60),
Expand All @@ -94,13 +111,18 @@ def __init__(
auth: EvnexAuth,
httpx_client: AsyncClient | None = None,
config: EvnexConfig | None = None,
timeout: float = DEFAULT_TIMEOUT,
):
"""
Create an Evnex API client.

:param auth: the authentication component owning the session tokens
:param httpx_client: optionally share an httpx AsyncClient
:param config: override API endpoints or the default org
:param timeout: seconds to wait on the cloud API's own endpoints. This
always overrides the httpx client's timeout, so a shared client's
default does not apply. Commands relayed to the charge point use
COMMAND_TIMEOUT, and methods that take a timeout argument use it.
"""
self.httpx_client = httpx_client or AsyncClient()
if config is None:
Expand All @@ -110,6 +132,7 @@ def __init__(
self.version = EVNEX_VERSION
self._base_url = config.EVNEX_BASE_URL.rstrip("/")
self._httpx_auth = EvnexHttpxAuth(auth)
self.timeout = timeout

@property
def _common_headers(self):
Expand All @@ -120,8 +143,25 @@ def _common_headers(self):
"User-Agent": f"python-evnex/{self.version}",
}

async def _request(self, method: str, path: str, timeout=30, **kwargs) -> Response:
"""Single request path: base URL, headers, auth, and 401 recovery."""
async def _request(
self, method: str, path: str, timeout: float | None = None, **kwargs
) -> Response:
"""Single request path: base URL, headers, auth, and 401 recovery.

The default covers the cloud API's own endpoints. Most answer in under
0.2s, but the list endpoints are far slower -- measured at 3.8s for
organisation charge points and 2.9s for charge point sessions -- so a
tighter default turns their ordinary variance into a ReadTimeout. This
leaves roughly 4x headroom over the slowest measured read -- room for
the event loop contention a polling host adds on top of server time.

Endpoints that relay a command to the charge point over OCPP wait on
hardware and pass COMMAND_TIMEOUT of their own.

:param timeout: seconds to wait; None uses the client's ``timeout``
"""
if timeout is None:
timeout = self.timeout
return await self.httpx_client.request(
method,
f"{self._base_url}{path}",
Expand Down Expand Up @@ -285,7 +325,7 @@ async def get_charge_point_detail_v3(

return EvnexV3APIResponse[EvnexChargePointDetailV3].model_validate(json_data)

@api_retry(ReadTimeout)
@api_retry()
async def get_charge_point_solar_config(
self, charge_point_id: str
) -> EvnexChargePointSolarConfig:
Expand All @@ -296,36 +336,37 @@ async def get_charge_point_solar_config(
r = await self._request(
"POST",
f"/charge-points/{charge_point_id}/commands/get-solar",
timeout=COMMAND_TIMEOUT,
)
json_data = await self._check_api_response(r)

return EvnexChargePointSolarConfig.model_validate(json_data)

@api_retry(ReadTimeout)
@api_retry()
async def get_charge_point_override(
self, charge_point_id: str
self, charge_point_id: str, timeout: float = COMMAND_TIMEOUT
) -> EvnexChargePointOverrideConfig:
"""

:param charge_point_id:
:param timeout: seconds to wait for the charge point to answer
:raises: ReadTimeout if the charge point is offline.
"""
r = await self._request(
"POST",
f"/charge-points/{charge_point_id}/commands/get-override",
timeout=15,
timeout=timeout,
)
json_data = await self._check_api_response(r)
return EvnexChargePointOverrideConfig.model_validate(json_data)

@api_retry(HTTPStatusError, ReadTimeout)
@api_retry(HTTPStatusError)
async def set_charge_point_override(
self, charge_point_id: str, charge_now: bool, connector_id: int = 1
):
# A ReadTimeout means the charge point did not acknowledge the command
# in time (typically offline or not responding); fail fast rather than
# retrying, which only prolongs the hang and could resubmit the command.
# Matches stop_charge_point's policy for the same reason.
# in time (typically offline or not responding). The no-retry policy for
# read timeouts matters doubly here: a retry would resubmit the command.
r = await self._request(
"POST",
f"/charge-points/{charge_point_id}/commands/set-override",
Expand All @@ -335,7 +376,7 @@ async def set_charge_point_override(
self._ensure_success(r)
return True

@api_retry(ReadTimeout)
@api_retry()
async def get_charge_point_status(
self, charge_point_id: str
) -> EvnexChargePointStatusResponse:
Expand All @@ -346,14 +387,15 @@ async def get_charge_point_status(
r = await self._request(
"POST",
f"/charge-points/{charge_point_id}/commands/get-status",
timeout=COMMAND_TIMEOUT,
)
json_data = await self._check_api_response(r)

return EvnexChargePointStatusResponse.model_validate(json_data)

@api_retry(ReadTimeout)
@api_retry()
async def get_charge_point_energy_meter_reading(
self, charge_point_id: str
self, charge_point_id: str, timeout: float = COMMAND_TIMEOUT
) -> EvnexChargePointEnergyMeterReadingResponse:
"""
:param charge_point_id:
Expand All @@ -362,6 +404,7 @@ async def get_charge_point_energy_meter_reading(
r = await self._request(
"POST",
f"/charge-points/{charge_point_id}/commands/get-energy-meter-reading",
timeout=timeout,
)
json_data = await self._check_api_response(r)

Expand Down Expand Up @@ -397,7 +440,7 @@ async def get_charge_point_sessions(
json_data = await self._check_api_response(r)
return EvnexGetChargePointSessionsResponse.model_validate(json_data).data

@api_retry(HTTPStatusError, ReadTimeout)
@api_retry(HTTPStatusError)
async def stop_charge_point(
self,
charge_point_id: str,
Expand Down
Loading
Loading