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
17 changes: 7 additions & 10 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,20 @@

## Summary

Require `frequenz-client-base` 0.11.3 so configured HTTP/2 keepalive settings
work, and clarify deprecations in the API reference.
Dispatch streams now have no deadline by default and rely on gRPC keepalive to
detect dead connections.

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
- `DispatchApiClient` no longer sets a five-minute stream deadline by default.
Pass a `timedelta` as `stream_timeout` to keep a finite deadline. The public
`stream_timeout` property now returns `None` when no deadline is configured.

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->

## Bug Fixes

- Require `frequenz-client-base` 0.11.3 or newer so gRPC applies the configured
HTTP/2 keepalive intervals.

## Documentation

- The API reference now highlights the deprecated `DispatchApiClient` `key`
argument and `InverterType.SOLAR` member, including their replacements.
- Remove the default stream deadline that forced healthy subscriptions to reconnect
every five minutes. Explicit stream timeouts remain supported.
13 changes: 9 additions & 4 deletions src/frequenz/client/dispatch/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def __init__(
sign_secret: str | None = None,
connect: bool = True,
call_timeout: timedelta = timedelta(seconds=60),
stream_timeout: timedelta = timedelta(minutes=5),
stream_timeout: timedelta | None = None,
) -> None:
"""Initialize the client.

Expand All @@ -80,7 +80,8 @@ def __init__(
sign_secret: Optional secret for signing requests.
connect: Whether to connect to the service immediately.
call_timeout: Timeout for gRPC calls, default is 60 seconds.
stream_timeout: Timeout for gRPC streams, default is 5 minutes.
stream_timeout: Optional timeout for gRPC streams. By default, streams
have no deadline and dead connections are detected by gRPC keepalive.

Raises:
TypeError: If neither `auth_key` nor `key` is provided.
Expand Down Expand Up @@ -115,16 +116,20 @@ def __init__(
"""A dictionary of streamers, keyed by microgrid_id."""

self._call_timeout_seconds = call_timeout.total_seconds()
self._stream_timeout_seconds = stream_timeout.total_seconds()
self._stream_timeout_seconds = (
stream_timeout.total_seconds() if stream_timeout is not None else None
)

@property
def call_timeout(self) -> timedelta:
"""Get the call timeout."""
return timedelta(seconds=self._call_timeout_seconds)

@property
def stream_timeout(self) -> timedelta:
def stream_timeout(self) -> timedelta | None:
"""Get the stream timeout."""
if self._stream_timeout_seconds is None:
return None
return timedelta(seconds=self._stream_timeout_seconds)

@property
Expand Down
123 changes: 123 additions & 0 deletions tests/test_client_timeouts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Tests for Dispatch API client timeouts."""

import asyncio
from datetime import timedelta
from typing import AsyncIterator
from unittest.mock import AsyncMock, MagicMock, PropertyMock

import grpc
import pytest
from frequenz.api.dispatch.v1.dispatch_pb2 import (
ListMicrogridDispatchesResponse,
StreamMicrogridDispatchesResponse,
)
from pytest_mock import MockerFixture

from frequenz.client.common.microgrid import MicrogridId
from frequenz.client.dispatch import DispatchApiClient


@pytest.mark.parametrize(
"stream_timeout, expected_timeout",
[
(None, None),
(timedelta(seconds=12), 12.0),
],
)
def test_stream_timeout_reaches_grpc(
mocker: MockerFixture,
stream_timeout: timedelta | None,
expected_timeout: float | None,
) -> None:
"""Test that omitted and explicit stream timeouts reach gRPC unchanged."""
grpc_stream = object()
stub = MagicMock()
stub.StreamMicrogridDispatches.return_value = grpc_stream
mocker.patch.object(
DispatchApiClient, "stub", new_callable=PropertyMock, return_value=stub
)
broadcaster_class = mocker.patch(
"frequenz.client.dispatch._client.GrpcStreamBroadcaster"
)
client = DispatchApiClient(
server_url="grpc://example.com",
auth_key="key",
connect=False,
stream_timeout=stream_timeout,
)

client.stream(MicrogridId(1))
stream_method = broadcaster_class.call_args.kwargs["stream_method"]

assert client.stream_timeout == stream_timeout
assert stream_method() is grpc_stream
stub.StreamMicrogridDispatches.assert_called_once()
assert (
stub.StreamMicrogridDispatches.call_args.kwargs["timeout"] == expected_timeout
)


async def test_unary_timeout_is_unchanged(mocker: MockerFixture) -> None:
"""Test that the stream default does not alter unary call deadlines."""
stub = MagicMock()
stub.ListMicrogridDispatches = AsyncMock(
return_value=ListMicrogridDispatchesResponse()
)
mocker.patch.object(
DispatchApiClient, "stub", new_callable=PropertyMock, return_value=stub
)
client = DispatchApiClient(
server_url="grpc://example.com", auth_key="key", connect=False
)

await anext(client.list(MicrogridId(1)))

assert client.call_timeout == timedelta(seconds=60)
assert stub.ListMicrogridDispatches.call_args.kwargs["timeout"] == 60.0


async def test_transient_stream_failure_retries(mocker: MockerFixture) -> None:
"""Test that a transient gRPC stream failure starts a new subscription."""
retry_started = asyncio.Event()
keep_open = asyncio.Event()

async def failing_stream() -> AsyncIterator[StreamMicrogridDispatchesResponse]:
raise grpc.aio.AioRpcError(
grpc.StatusCode.UNAVAILABLE,
grpc.aio.Metadata(),
grpc.aio.Metadata(),
"transient failure",
None,
)
yield StreamMicrogridDispatchesResponse() # pragma: no cover

async def open_stream() -> AsyncIterator[StreamMicrogridDispatchesResponse]:
retry_started.set()
await keep_open.wait()
yield StreamMicrogridDispatchesResponse() # pragma: no cover

stub = MagicMock()
stub.StreamMicrogridDispatches.side_effect = [failing_stream(), open_stream()]
mocker.patch.object(
DispatchApiClient, "stub", new_callable=PropertyMock, return_value=stub
)
mocker.patch("frequenz.client.base.streaming.asyncio.sleep", new_callable=AsyncMock)
client = DispatchApiClient(
server_url="grpc://example.com", auth_key="key", connect=False
)

client.stream(MicrogridId(1))
await asyncio.wait_for(retry_started.wait(), timeout=1)

assert client.stream_timeout is None
assert stub.StreamMicrogridDispatches.call_count == 2
assert all(
call.kwargs["timeout"] is None
for call in stub.StreamMicrogridDispatches.call_args_list
)

broadcaster = client._streams[MicrogridId(1)] # pylint: disable=protected-access
await broadcaster.stop()