|
| 1 | +# License: MIT |
| 2 | +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH |
| 3 | + |
| 4 | +"""Tests for the microgrid client exceptions.""" |
| 5 | + |
| 6 | +import re |
| 7 | +from typing import Protocol |
| 8 | +from unittest import mock |
| 9 | + |
| 10 | +import grpclib |
| 11 | +import pytest |
| 12 | + |
| 13 | +from frequenz.client.microgrid import ( |
| 14 | + ClientError, |
| 15 | + DataLoss, |
| 16 | + EntityAlreadyExists, |
| 17 | + EntityNotFound, |
| 18 | + GrpcStatusError, |
| 19 | + InternalError, |
| 20 | + InvalidArgument, |
| 21 | + OperationAborted, |
| 22 | + OperationCancelled, |
| 23 | + OperationNotImplemented, |
| 24 | + OperationOutOfRange, |
| 25 | + OperationPreconditionFailed, |
| 26 | + OperationTimedOut, |
| 27 | + OperationUnauthenticated, |
| 28 | + PermissionDenied, |
| 29 | + ResourceExhausted, |
| 30 | + ServiceUnavailable, |
| 31 | + UnknownError, |
| 32 | +) |
| 33 | + |
| 34 | + |
| 35 | +class _GrpcStatusErrorCtor(Protocol): |
| 36 | + """A protocol for the constructor of a subclass of `GrpcStatusError`.""" |
| 37 | + |
| 38 | + def __call__( |
| 39 | + self, *, server_url: str, operation: str, grpc_error: grpclib.GRPCError |
| 40 | + ) -> GrpcStatusError: ... |
| 41 | + |
| 42 | + |
| 43 | +ERROR_TUPLES: list[tuple[type[GrpcStatusError], grpclib.Status, str, bool]] = [ |
| 44 | + ( |
| 45 | + OperationCancelled, |
| 46 | + grpclib.Status.CANCELLED, |
| 47 | + "The operation was cancelled", |
| 48 | + True, |
| 49 | + ), |
| 50 | + ( |
| 51 | + UnknownError, |
| 52 | + grpclib.Status.UNKNOWN, |
| 53 | + "There was an error that can't be described using other statuses", |
| 54 | + True, |
| 55 | + ), |
| 56 | + ( |
| 57 | + InvalidArgument, |
| 58 | + grpclib.Status.INVALID_ARGUMENT, |
| 59 | + "The client specified an invalid argument", |
| 60 | + False, |
| 61 | + ), |
| 62 | + ( |
| 63 | + OperationTimedOut, |
| 64 | + grpclib.Status.DEADLINE_EXCEEDED, |
| 65 | + "The time limit was exceeded while waiting for the operation to complete", |
| 66 | + True, |
| 67 | + ), |
| 68 | + ( |
| 69 | + EntityNotFound, |
| 70 | + grpclib.Status.NOT_FOUND, |
| 71 | + "The requested entity was not found", |
| 72 | + True, |
| 73 | + ), |
| 74 | + ( |
| 75 | + EntityAlreadyExists, |
| 76 | + grpclib.Status.ALREADY_EXISTS, |
| 77 | + "The entity that we attempted to create already exists", |
| 78 | + True, |
| 79 | + ), |
| 80 | + ( |
| 81 | + PermissionDenied, |
| 82 | + grpclib.Status.PERMISSION_DENIED, |
| 83 | + "The caller does not have permission to execute the specified operation", |
| 84 | + True, |
| 85 | + ), |
| 86 | + ( |
| 87 | + ResourceExhausted, |
| 88 | + grpclib.Status.RESOURCE_EXHAUSTED, |
| 89 | + "Some resource has been exhausted (for example per-user quota, disk space, etc.)", |
| 90 | + True, |
| 91 | + ), |
| 92 | + ( |
| 93 | + OperationPreconditionFailed, |
| 94 | + grpclib.Status.FAILED_PRECONDITION, |
| 95 | + "The operation was rejected because the system is not in a required state", |
| 96 | + True, |
| 97 | + ), |
| 98 | + (OperationAborted, grpclib.Status.ABORTED, "The operation was aborted", True), |
| 99 | + ( |
| 100 | + OperationOutOfRange, |
| 101 | + grpclib.Status.OUT_OF_RANGE, |
| 102 | + "The operation was attempted past the valid range", |
| 103 | + True, |
| 104 | + ), |
| 105 | + ( |
| 106 | + OperationNotImplemented, |
| 107 | + grpclib.Status.UNIMPLEMENTED, |
| 108 | + "The operation is not implemented or not supported/enabled in this service", |
| 109 | + False, |
| 110 | + ), |
| 111 | + ( |
| 112 | + InternalError, |
| 113 | + grpclib.Status.INTERNAL, |
| 114 | + "Some invariants expected by the underlying system have been broken", |
| 115 | + True, |
| 116 | + ), |
| 117 | + ( |
| 118 | + ServiceUnavailable, |
| 119 | + grpclib.Status.UNAVAILABLE, |
| 120 | + "The service is currently unavailable", |
| 121 | + True, |
| 122 | + ), |
| 123 | + ( |
| 124 | + DataLoss, |
| 125 | + grpclib.Status.DATA_LOSS, |
| 126 | + "Unrecoverable data loss or corruption", |
| 127 | + False, |
| 128 | + ), |
| 129 | + ( |
| 130 | + OperationUnauthenticated, |
| 131 | + grpclib.Status.UNAUTHENTICATED, |
| 132 | + "The request does not have valid authentication credentials for the operation", |
| 133 | + False, |
| 134 | + ), |
| 135 | +] |
| 136 | + |
| 137 | + |
| 138 | +@pytest.mark.parametrize( |
| 139 | + "exception_class, grpc_status, expected_description, retryable", ERROR_TUPLES |
| 140 | +) |
| 141 | +def test_grpc_status_error( |
| 142 | + exception_class: _GrpcStatusErrorCtor, |
| 143 | + grpc_status: grpclib.Status, |
| 144 | + expected_description: str, |
| 145 | + retryable: bool, |
| 146 | +) -> None: |
| 147 | + """Test gRPC status errors are correctly created from gRPC errors.""" |
| 148 | + grpc_error = grpclib.GRPCError( |
| 149 | + grpc_status, "grpc error message", "grpc error details" |
| 150 | + ) |
| 151 | + exception = exception_class( |
| 152 | + server_url="http://testserver", |
| 153 | + operation="test_operation", |
| 154 | + grpc_error=grpc_error, |
| 155 | + ) |
| 156 | + |
| 157 | + assert exception.server_url == "http://testserver" |
| 158 | + assert exception.operation == "test_operation" |
| 159 | + assert expected_description == exception.description |
| 160 | + assert exception.grpc_error == grpc_error |
| 161 | + assert exception.is_retryable == retryable |
| 162 | + |
| 163 | + |
| 164 | +def test_grpc_unknown_status_error() -> None: |
| 165 | + """Test that an UnknownError is created for an unknown gRPC status.""" |
| 166 | + expected_description = "Test error" |
| 167 | + grpc_error = grpclib.GRPCError( |
| 168 | + mock.MagicMock(name="unknown_status"), |
| 169 | + "grpc error message", |
| 170 | + "grpc error details", |
| 171 | + ) |
| 172 | + exception = GrpcStatusError( |
| 173 | + server_url="http://testserver", |
| 174 | + operation="test_operation", |
| 175 | + description=expected_description, |
| 176 | + grpc_error=grpc_error, |
| 177 | + retryable=True, |
| 178 | + ) |
| 179 | + |
| 180 | + assert exception.server_url == "http://testserver" |
| 181 | + assert exception.operation == "test_operation" |
| 182 | + assert expected_description in exception.description |
| 183 | + assert exception.grpc_error == grpc_error |
| 184 | + assert exception.is_retryable is True |
| 185 | + |
| 186 | + |
| 187 | +def test_client_error() -> None: |
| 188 | + """Test the ClientError class.""" |
| 189 | + error = ClientError( |
| 190 | + server_url="http://testserver", |
| 191 | + operation="test_operation", |
| 192 | + description="An error occurred", |
| 193 | + retryable=True, |
| 194 | + ) |
| 195 | + |
| 196 | + assert error.server_url == "http://testserver" |
| 197 | + assert error.operation == "test_operation" |
| 198 | + assert error.description == "An error occurred" |
| 199 | + assert error.is_retryable is True |
| 200 | + |
| 201 | + |
| 202 | +@pytest.mark.parametrize( |
| 203 | + "exception_class, grpc_status, expected_description, retryable", |
| 204 | + ERROR_TUPLES |
| 205 | + + [ |
| 206 | + ( |
| 207 | + GrpcStatusError, |
| 208 | + mock.MagicMock(name="unknown_status"), |
| 209 | + "Got an unrecognized status code", |
| 210 | + True, |
| 211 | + ) |
| 212 | + ], |
| 213 | +) |
| 214 | +def test_from_grpc_error( |
| 215 | + exception_class: type[GrpcStatusError], |
| 216 | + grpc_status: grpclib.Status, |
| 217 | + expected_description: str, |
| 218 | + retryable: bool, |
| 219 | +) -> None: |
| 220 | + """Test that the from_grpc_error method creates the correct exception.""" |
| 221 | + grpc_error = grpclib.GRPCError( |
| 222 | + grpc_status, "grpc error message", "grpc error details" |
| 223 | + ) |
| 224 | + with pytest.raises( |
| 225 | + exception_class, |
| 226 | + match=r"Failed calling 'test_operation' on 'http://testserver': " |
| 227 | + rf"{re.escape(expected_description)} " |
| 228 | + rf"<status={re.escape(str(grpc_status.name))}>: " |
| 229 | + r"grpc error message \(grpc error details\)", |
| 230 | + ) as exc_info: |
| 231 | + raise ClientError.from_grpc_error( |
| 232 | + server_url="http://testserver", |
| 233 | + operation="test_operation", |
| 234 | + grpc_error=grpc_error, |
| 235 | + ) |
| 236 | + |
| 237 | + exception = exc_info.value |
| 238 | + assert isinstance(exception, exception_class) |
| 239 | + assert exception.server_url == "http://testserver" |
| 240 | + assert exception.operation == "test_operation" |
| 241 | + assert exception.grpc_error == grpc_error |
| 242 | + assert expected_description == exception.description |
| 243 | + assert exception.is_retryable == retryable |
0 commit comments