-
Notifications
You must be signed in to change notification settings - Fork 33
fix(flagd): back off before stream reconnect #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -67,10 +67,12 @@ def __init__( | |||||
| logger.debug(self.config.fatal_status_codes) | ||||||
|
|
||||||
| self.retry_grace_period = config.retry_grace_period | ||||||
| self.retry_backoff_max_seconds = config.retry_backoff_max_ms * 0.001 | ||||||
| self.streamline_deadline_seconds = config.stream_deadline_ms * 0.001 | ||||||
| self.deadline = config.deadline_ms * 0.001 | ||||||
| self.connected = False | ||||||
| self._is_fatal = False | ||||||
| self._shutdown_event = threading.Event() | ||||||
| self.channel = self._generate_channel(config) | ||||||
| self.stub = evaluation_pb2_grpc.ServiceStub(self.channel) | ||||||
| self.selector_metadata: tuple[tuple[str, str], ...] | None = ( | ||||||
|
|
@@ -140,6 +142,7 @@ def initialize(self, evaluation_context: EvaluationContext) -> None: | |||||
|
|
||||||
| def shutdown(self) -> None: | ||||||
| self.active = False | ||||||
| self._shutdown_event.set() | ||||||
| self.channel.unsubscribe(self._state_change_callback) | ||||||
| self.channel.close() | ||||||
| if self.timer and self.timer.is_alive(): | ||||||
|
|
@@ -150,6 +153,7 @@ def shutdown(self) -> None: | |||||
|
|
||||||
| def connect(self) -> None: | ||||||
| self.active = True | ||||||
| self._shutdown_event.clear() | ||||||
|
|
||||||
| # Run monitoring in a separate thread | ||||||
| self.monitor_thread = threading.Thread( | ||||||
|
|
@@ -228,6 +232,41 @@ def _stream_call_args(self) -> GrpcMultiCallableArgs: | |||||
| call_args["metadata"] = self.selector_metadata | ||||||
| return call_args | ||||||
|
|
||||||
| def _wait_before_reconnect(self) -> None: | ||||||
| self._shutdown_event.wait(self.retry_backoff_max_seconds) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned in the initialization, using the maximum backoff for every retry significantly impacts availability. Consider using the initial backoff duration instead. Note that you will also need to update the corresponding regression tests in
Suggested change
|
||||||
|
|
||||||
| def _handle_rpc_error(self, e: grpc.RpcError) -> bool: | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the method extractions. |
||||||
| # code() blocks until final status, finalizing the dead RPC before reconnect; keep it, do not inline into logging only | ||||||
| # https://grpc.github.io/grpc/python/grpc.html#grpc.Call.code | ||||||
| code = e.code() | ||||||
| if code.name in self.config.fatal_status_codes: | ||||||
| logger.error(f"EventStream fatal error, {code=} {e.details()=}") | ||||||
| self._is_fatal = True | ||||||
| self.active = False | ||||||
| self.emit_provider_error( | ||||||
| ProviderEventDetails( | ||||||
| message=f"Fatal gRPC status code: {code}", | ||||||
| error_code=ErrorCode.PROVIDER_FATAL, | ||||||
| ) | ||||||
| ) | ||||||
| return True | ||||||
| # non-fatal errors just reconnect; real loss surfaces as a STALE, and eventually, ERROR event | ||||||
| logger.debug(f"EventStream error, reconnecting, {code=} {e.details()=}") | ||||||
| return False | ||||||
|
|
||||||
| def _handle_event_stream_message( | ||||||
| self, message: evaluation_pb2.EventStreamResponse | ||||||
| ) -> None: | ||||||
| if message.type == "provider_ready": | ||||||
| self.emit_provider_ready( | ||||||
| ProviderEventDetails(message="gRPC sync connection established") | ||||||
| ) | ||||||
| self.connected = True | ||||||
| elif message.type == "configuration_change": | ||||||
| msg_dict = MessageToDict(message) | ||||||
| data = msg_dict.get("data", {}) | ||||||
| self.handle_changed_flags(data) | ||||||
|
|
||||||
| def listen(self) -> None: | ||||||
| logger.debug("gRPC starting listener thread") | ||||||
| call_args = self._stream_call_args() | ||||||
|
|
@@ -238,38 +277,20 @@ def listen(self) -> None: | |||||
| try: | ||||||
| logger.debug("Setting up gRPC sync flags connection") | ||||||
| for message in self.stub.EventStream(request, **call_args): | ||||||
| if message.type == "provider_ready": | ||||||
| self.emit_provider_ready( | ||||||
| ProviderEventDetails( | ||||||
| message="gRPC sync connection established" | ||||||
| ) | ||||||
| ) | ||||||
| self.connected = True | ||||||
| elif message.type == "configuration_change": | ||||||
| msg_dict = MessageToDict(message) | ||||||
| data = msg_dict.get("data", {}) | ||||||
| self.handle_changed_flags(data) | ||||||
| self._handle_event_stream_message(message) | ||||||
|
|
||||||
| if not self.active: | ||||||
| logger.info("Terminating gRPC sync thread") | ||||||
| return | ||||||
| except grpc.RpcError as e: # noqa: PERF203 | ||||||
| # although it seems like this error log is not interesting, without it, the retry is not working as expected | ||||||
| logger.debug(f"SyncFlags stream error, {e.code()=} {e.details()=}") | ||||||
| if e.code().name in self.config.fatal_status_codes: | ||||||
| self._is_fatal = True | ||||||
| self.active = False | ||||||
| self.emit_provider_error( | ||||||
| ProviderEventDetails( | ||||||
| message=f"Fatal gRPC status code: {e.code()}", | ||||||
| error_code=ErrorCode.PROVIDER_FATAL, | ||||||
| ) | ||||||
| ) | ||||||
| except grpc.RpcError as e: | ||||||
| if self._handle_rpc_error(e): | ||||||
| return | ||||||
| except ParseError: | ||||||
| logger.exception( | ||||||
| f"Could not parse flag data using flagd syntax: {message=}" | ||||||
| ) | ||||||
| if self.active: | ||||||
| self._wait_before_reconnect() | ||||||
|
|
||||||
| def handle_changed_flags(self, data: typing.Any) -> None: | ||||||
| changed_flags = list(data.get("flags", {}).keys()) | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -43,8 +43,7 @@ def __init__( | |||||
|
|
||||||
| self.channel = self._generate_channel(config) | ||||||
| self.stub = sync_pb2_grpc.FlagSyncServiceStub(self.channel) | ||||||
| self.retry_backoff_seconds = config.retry_backoff_ms * 0.001 | ||||||
| self.retry_backoff_max_seconds = config.retry_backoff_ms * 0.001 | ||||||
| self.retry_backoff_max_seconds = config.retry_backoff_max_ms * 0.001 | ||||||
| self.retry_grace_period = config.retry_grace_period | ||||||
| self.streamline_deadline_seconds = config.stream_deadline_ms * 0.001 | ||||||
| self.deadline = config.deadline_ms * 0.001 | ||||||
|
|
@@ -56,6 +55,7 @@ def __init__( | |||||
|
|
||||||
| self.connected = False | ||||||
| self._is_fatal = False | ||||||
| self._shutdown_event = threading.Event() | ||||||
| self.thread: threading.Thread | None = None | ||||||
| self.timer: threading.Timer | None = None | ||||||
|
|
||||||
|
|
@@ -129,6 +129,7 @@ def initialize(self, context: EvaluationContext) -> None: | |||||
|
|
||||||
| def connect(self) -> None: | ||||||
| self.active = True | ||||||
| self._shutdown_event.clear() | ||||||
|
|
||||||
| # Run monitoring in a separate thread | ||||||
| self.monitor_thread = threading.Thread( | ||||||
|
|
@@ -199,6 +200,7 @@ def emit_error(self) -> None: | |||||
|
|
||||||
| def shutdown(self) -> None: | ||||||
| self.active = False | ||||||
| self._shutdown_event.set() | ||||||
| self.channel.close() | ||||||
|
|
||||||
| def _create_request_args(self) -> dict: | ||||||
|
|
@@ -270,23 +272,27 @@ def _handle_flag_response( | |||||
|
|
||||||
| def _handle_rpc_error(self, e: grpc.RpcError) -> bool: | ||||||
| """Handle a gRPC RpcError. Returns True if the stream loop should stop.""" | ||||||
| if e.code().name in self.config.fatal_status_codes: | ||||||
| logger.error(f"SyncFlags stream fatal error, {e.code()=} {e.details()=}") | ||||||
| # code() blocks until final status, finalizing the dead RPC before reconnect; keep it, do not inline into logging only | ||||||
| # https://grpc.github.io/grpc/python/grpc.html#grpc.Call.code | ||||||
| code = e.code() | ||||||
| if code.name in self.config.fatal_status_codes: | ||||||
| logger.error(f"SyncFlags stream fatal error, {code=} {e.details()=}") | ||||||
| self._is_fatal = True | ||||||
| self.active = False | ||||||
| self.emit_provider_error( | ||||||
| ProviderEventDetails( | ||||||
| message=f"Fatal gRPC status code: {e.code()}", | ||||||
| message=f"Fatal gRPC status code: {code}", | ||||||
| error_code=ErrorCode.PROVIDER_FATAL, | ||||||
| ) | ||||||
| ) | ||||||
| return True | ||||||
| # non-fatal errors just reconnect; real loss surfaces as a STALE event | ||||||
| logger.debug( | ||||||
| f"SyncFlags stream error, reconnecting, {e.code()=} {e.details()=}" | ||||||
| ) | ||||||
| # non-fatal errors just reconnect; real loss surfaces as a STALE, and eventually, ERROR event | ||||||
| logger.debug(f"SyncFlags stream error, reconnecting, {code=} {e.details()=}") | ||||||
| return False | ||||||
|
|
||||||
| def _wait_before_reconnect(self) -> None: | ||||||
| self._shutdown_event.wait(self.retry_backoff_max_seconds) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using the maximum backoff duration (
Suggested change
|
||||||
|
|
||||||
| def listen(self) -> None: | ||||||
| call_args = self.generate_grpc_call_args() | ||||||
| request_args = self._create_request_args() | ||||||
|
|
@@ -299,7 +305,7 @@ def listen(self) -> None: | |||||
| for flag_rsp in self.stub.SyncFlags(request, **call_args): | ||||||
| if self._handle_flag_response(flag_rsp, context_values_response): | ||||||
| return | ||||||
| except grpc.RpcError as e: # noqa: PERF203 | ||||||
| except grpc.RpcError as e: | ||||||
| if self._handle_rpc_error(e): | ||||||
| return | ||||||
| except json.JSONDecodeError: | ||||||
|
|
@@ -308,6 +314,8 @@ def listen(self) -> None: | |||||
| ) | ||||||
| except ParseError: | ||||||
| logger.exception("Could not parse flag data using flagd syntax") | ||||||
| if self.active: | ||||||
| self._wait_before_reconnect() | ||||||
|
|
||||||
| def generate_grpc_call_args(self) -> GrpcMultiCallableArgs: | ||||||
| call_args: GrpcMultiCallableArgs = {"wait_for_ready": True} | ||||||
|
|
||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the maximum backoff duration (
retry_backoff_max_ms) as a constant delay for every reconnect attempt can lead to significant delays in recovery from transient failures. It is generally better to use the initial backoff duration (retry_backoff_ms) or implement an exponential backoff strategy. Since gRPC already handles connection-level backoff internally, a small application-level delay is sufficient to prevent tight loops while allowing faster recovery.