Skip to content

Commit

Permalink
Add specific gRPC client errors (#53)
Browse files Browse the repository at this point in the history
This makes error handling more pythonic, as one can now just catch the
exception type one is interested in, without having to do a second-level
matching using the status.

It also helps avoiding to expose the grpclib classes to the user.
  • Loading branch information
llucax committed May 29, 2024
2 parents f410571 + 642c62b commit 01a741e
Show file tree
Hide file tree
Showing 6 changed files with 933 additions and 71 deletions.
28 changes: 26 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,37 @@
## Upgrading

- The client now uses a string URL to connect to the server, the `grpc_channel` and `target` arguments are now replaced by `server_url`. The current accepted format is `grpc://hostname[:<port:int=9090>][?ssl=<ssl:bool=false>]`, meaning that the `port` and `ssl` are optional and default to 9090 and `false` respectively. You will have to adapt the way you connect to the server in your code.

- The client is now using [`grpclib`](https://pypi.org/project/grpclib/) to connect to the server instead of [`grpcio`](https://pypi.org/project/grpcio/). You might need to adapt your code if you are using `grpcio` directly.
- The client now doesn't raise `grpc.aio.RpcError` exceptions anymore. Instead, it raises `ClientError` exceptions that have the `grpclib.GRPCError` as their `__cause__`. You might need to adapt your error handling code to catch `ClientError` exceptions instead of `grpc.aio.RpcError` exceptions.

- The client now doesn't raise `grpc.aio.RpcError` exceptions anymore. Instead, it raises its own exceptions, one per gRPC error status code, all inheriting from `GrpcError`, which in turn inherits from `ClientError` (as any other exception raised by this library in the future). `GrpcError`s have the `grpclib.GRPCError` as their `__cause__`. You might need to adapt your error handling code to catch these specific exceptions instead of `grpc.aio.RpcError`.

You can also access the underlying `grpclib.GRPCError` using the `grpc_error` attribute for `GrpStatusError` exceptions, but it is discouraged because it makes downstream projects dependant on `grpclib` too

- The client now uses protobuf/grpc bindings generated [betterproto](https://github.com/danielgtaylor/python-betterproto) ([frequenz-microgrid-betterproto](https://github.com/frequenz-floss/frequenz-microgrid-betterproto-python)) instead of [grpcio](https://pypi.org/project/grpcio/) ([frequenz-api-microgrid](https://github.com/frequenz-floss/frequenz-api-microgrid)). If you were using the bindings directly, you might need to do some minor adjustments to your code.

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->
- The client now raises more specific exceptions based on the gRPC status code, so you can more easily handle different types of errors.

For example:

```python
try:
connections = await client.connections()
except OperationTimedOut:
...
```

instead of:

```python
try:
connections = await client.connections()
except grpc.aio.RpcError as e:
if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
...
```

## Bug Fixes

Expand Down
40 changes: 39 additions & 1 deletion src/frequenz/client/microgrid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,27 @@
)
from ._component_states import EVChargerCableState, EVChargerComponentState
from ._connection import Connection
from ._exception import ClientError
from ._exception import (
ClientError,
DataLoss,
EntityAlreadyExists,
EntityNotFound,
GrpcError,
InternalError,
InvalidArgument,
OperationAborted,
OperationCancelled,
OperationNotImplemented,
OperationOutOfRange,
OperationPreconditionFailed,
OperationTimedOut,
OperationUnauthenticated,
PermissionDenied,
ResourceExhausted,
ServiceUnavailable,
UnknownError,
UnrecognizedGrpcStatus,
)
from ._metadata import Location, Metadata

__all__ = [
Expand All @@ -41,14 +61,32 @@
"ComponentMetricId",
"ComponentType",
"Connection",
"DataLoss",
"EVChargerCableState",
"EVChargerComponentState",
"EVChargerData",
"EntityAlreadyExists",
"EntityNotFound",
"Fuse",
"GridMetadata",
"GrpcError",
"InternalError",
"InvalidArgument",
"InverterData",
"InverterType",
"Location",
"Metadata",
"MeterData",
"OperationAborted",
"OperationCancelled",
"OperationNotImplemented",
"OperationOutOfRange",
"OperationPreconditionFailed",
"OperationTimedOut",
"OperationUnauthenticated",
"PermissionDenied",
"ResourceExhausted",
"ServiceUnavailable",
"UnknownError",
"UnrecognizedGrpcStatus",
]
70 changes: 36 additions & 34 deletions src/frequenz/client/microgrid/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,21 @@ async def components(self) -> Iterable[Component]:
Iterator whose elements are all the components in the microgrid.
Raises:
ClientError: If the connection to the Microgrid API cannot be established or
when the api call exceeded the timeout.
ClientError: If the are any errors communicating with the Microgrid API,
most likely a subclass of
[GrpcError][frequenz.client.microgrid.GrpcError].
"""
try:
component_list = await self.api.list_components(
pb_microgrid.ComponentFilter(),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
)

except grpclib.GRPCError as err:
raise ClientError(
f"Failed to list components. Microgrid API: {self._server_url}. Err: {err}"
) from err
except grpclib.GRPCError as grpc_error:
raise ClientError.from_grpc_error(
server_url=self._server_url,
operation="list_components",
grpc_error=grpc_error,
) from grpc_error

components_only = filter(
lambda c: c.category
Expand Down Expand Up @@ -168,8 +170,9 @@ async def connections(
Microgrid connections matching the provided start and end filters.
Raises:
ClientError: If the connection to the Microgrid API cannot be established or
when the api call exceeded the timeout.
ClientError: If the are any errors communicating with the Microgrid API,
most likely a subclass of
[GrpcError][frequenz.client.microgrid.GrpcError].
"""
connection_filter = pb_microgrid.ConnectionFilter(
starts=list(starts), ends=list(ends)
Expand All @@ -182,10 +185,12 @@ async def connections(
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
),
)
except grpclib.GRPCError as err:
raise ClientError(
f"Failed to list connections. Microgrid API: {self._server_url}. Err: {err}"
) from err
except grpclib.GRPCError as grpc_error:
raise ClientError.from_grpc_error(
server_url=self._server_url,
operation="list_connections",
grpc_error=grpc_error,
) from grpc_error
# Filter out the components filtered in `components` method.
# id=0 is an exception indicating grid component.
valid_ids = {c.component_id for c in valid_components}
Expand Down Expand Up @@ -384,8 +389,9 @@ async def set_power(self, component_id: int, power_w: float) -> None:
power_w: power to set for the component.
Raises:
ClientError: If the connection to the Microgrid API cannot be established or
when the api call exceeded the timeout.
ClientError: If the are any errors communicating with the Microgrid API,
most likely a subclass of
[GrpcError][frequenz.client.microgrid.GrpcError].
"""
try:
await self.api.set_power_active(
Expand All @@ -394,10 +400,12 @@ async def set_power(self, component_id: int, power_w: float) -> None:
),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
)
except grpclib.GRPCError as err:
raise ClientError(
f"Failed to set power. Microgrid API: {self._server_url}. Err: {err}"
) from err
except grpclib.GRPCError as grpc_error:
raise ClientError.from_grpc_error(
server_url=self._server_url,
operation="set_power_active",
grpc_error=grpc_error,
) from grpc_error

async def set_bounds(
self,
Expand All @@ -415,10 +423,10 @@ async def set_bounds(
Raises:
ValueError: when upper bound is less than 0, or when lower bound is
greater than 0.
ClientError: If the connection to the Microgrid API cannot be established or
when the api call exceeded the timeout.
ClientError: If the are any errors communicating with the Microgrid API,
most likely a subclass of
[GrpcError][frequenz.client.microgrid.GrpcError].
"""
api_details = f"Microgrid API: {self._server_url}."
if upper < 0:
raise ValueError(f"Upper bound {upper} must be greater than or equal to 0.")
if lower > 0:
Expand All @@ -436,15 +444,9 @@ async def set_bounds(
),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
)
except grpclib.GRPCError as err:
_logger.error(
"set_bounds write failed: %s, for message: %s, api: %s. Err: %s",
err,
next,
api_details,
err,
)
raise ClientError(
f"Failed to set inclusion bounds. Microgrid API: {self._server_url}. "
f"Err: {err}"
) from err
except grpclib.GRPCError as grpc_error:
raise ClientError.from_grpc_error(
server_url=self._server_url,
operation="add_inclusion_bounds",
grpc_error=grpc_error,
) from grpc_error
Loading

0 comments on commit 01a741e

Please sign in to comment.