Skip to content

Commit

Permalink
Add client side health feature
Browse files Browse the repository at this point in the history
This commit adds health auto-configuration to the client.

See #66

Signed-off-by: Chris Bono <chris.bono@gmail.com>
  • Loading branch information
onobc authored and dsyer committed Nov 28, 2024
1 parent 9374bdf commit e0199dd
Show file tree
Hide file tree
Showing 7 changed files with 550 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

package org.springframework.grpc.sample;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;

import java.time.Duration;

import org.awaitility.Awaitility;
Expand All @@ -30,20 +33,81 @@
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.grpc.client.GrpcChannelFactory;
import org.springframework.grpc.sample.proto.HelloReply;
import org.springframework.grpc.sample.proto.HelloRequest;
import org.springframework.grpc.sample.proto.SimpleGrpc;
import org.springframework.test.annotation.DirtiesContext;

import io.grpc.ManagedChannel;
import io.grpc.StatusRuntimeException;
import io.grpc.health.v1.HealthCheckRequest;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.health.v1.HealthGrpc;
import io.grpc.health.v1.HealthGrpc.HealthBlockingStub;
import static org.assertj.core.api.Assertions.assertThat;
import io.grpc.protobuf.services.HealthStatusManager;

/**
* Integration tests for gRPC server health feature.
*/
class GrpcServerHealthIntegrationTests {

@Nested
@SpringBootTest(properties = { "spring.grpc.inprocess.enabled=false", "spring.grpc.server.port=0",
"spring.grpc.client.channels.health-test.address=static://0.0.0.0:${local.grpc.port}",
"spring.grpc.client.channels.health-test.health.enabled=true",
"spring.grpc.client.channels.health-test.health.service-name=my-service" })
@DirtiesContext
class WithClientHealthEnabled {

@Test
void loadBalancerRespectsServerHealth(@Autowired GrpcChannelFactory channels,
@Autowired HealthStatusManager healthStatusManager) {
ManagedChannel channel = channels.createChannel("health-test").build();
SimpleGrpc.SimpleBlockingStub client = SimpleGrpc.newBlockingStub(channel);

// put the service up (SERVING) and give load balancer time to update
updateHealthStatusAndWait("my-service", ServingStatus.SERVING, healthStatusManager);

// initially the status should be SERVING
assertThatResponseIsServedToChannel(client);

// put the service down (NOT_SERVING) and give load balancer time to update
updateHealthStatusAndWait("my-service", ServingStatus.NOT_SERVING, healthStatusManager);

// now the request should fail
assertThatResponseIsNotServedToChannel(client);

// put the service up (SERVING) and give load balancer time to update
updateHealthStatusAndWait("my-service", ServingStatus.SERVING, healthStatusManager);

// now the request should pass
assertThatResponseIsServedToChannel(client);
}

private void updateHealthStatusAndWait(String serviceName, ServingStatus healthStatus,
HealthStatusManager healthStatusManager) {
healthStatusManager.setStatus(serviceName, healthStatus);
try {
Thread.sleep(2000L);
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

private void assertThatResponseIsServedToChannel(SimpleGrpc.SimpleBlockingStub client) {
HelloReply response = client.sayHello(HelloRequest.newBuilder().setName("Alien").build());
assertThat(response.getMessage()).isEqualTo("Hello ==> Alien");
}

private void assertThatResponseIsNotServedToChannel(SimpleGrpc.SimpleBlockingStub client) {
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> client.sayHello(HelloRequest.newBuilder().setName("Alien").build()))
.withMessageContaining("UNAVAILABLE: Health-check service responded NOT_SERVING for 'my-service'");
}

}

@Nested
@SpringBootTest(properties = { "spring.grpc.server.health.actuator.health-indicator-paths=custom",
"spring.grpc.server.health.actuator.update-initial-delay=3s",
Expand All @@ -52,7 +116,7 @@ class GrpcServerHealthIntegrationTests {
class WithActuatorHealthAdapter {

@Test
void healthIndicatorsAdaptedToGprcHealthStatus(@Autowired GrpcChannelFactory channels) {
void healthIndicatorsAdaptedToGrpcHealthStatus(@Autowired GrpcChannelFactory channels) {
var channel = channels.createChannel("0.0.0.0:0").build();
var healthStub = HealthGrpc.newBlockingStub(channel);
var serviceName = "custom";
Expand All @@ -75,6 +139,7 @@ private void assertThatGrpcHealthStatusIs(HealthBlockingStub healthBlockingStub,
var healthRequest = HealthCheckRequest.newBuilder().setService(service).build();
var healthResponse = healthBlockingStub.check(healthRequest);
assertThat(healthResponse.getStatus()).isEqualTo(expectedStatus);
// verify the overall status as well
var overallHealthRequest = HealthCheckRequest.newBuilder().setService("").build();
var overallHealthResponse = healthBlockingStub.check(overallHealthRequest);
assertThat(overallHealthResponse.getStatus()).isEqualTo(expectedStatus);
Expand Down
27 changes: 27 additions & 0 deletions spring-grpc-docs/src/main/antora/modules/ROOT/pages/health.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,30 @@ spring:
NOTE: The items in the `health-indicator-paths` are the identifiers of the indicator which is typically the name of the indicator bean without the `HealthIndicator` suffix.

You can use the xref:appendix.adoc#common-application-properties["spring.grpc.server.health.*"] application properties to further configure the health feature.

== Client-side
Spring gRPC can also autoconfigure the https://grpc.io/docs/guides/health-checking/[client-side] health check feature to your gRPC clients.
To enable health checks on a named channel, simply set the `spring.grpc.client.channels.<channel-name>.health.enabled` application property to `true`.
To enable health checks for all channels, set the `spring.grpc.client.default-channel.enabled` application property to `true`.

By default, the health check will consult the overall status service (i.e. `""`).
To use a specific service, use the `health.service-name` application property on the desired channel.

NOTE: The `default-load-balancing-policy` must be set to `round_robin` to participate in the health checking. This is the default used by Spring gRPC but if you change the setting you will not get health checks

The following example enables health checks for all unknown channels (using the overall server status) and for the channel named `one` (using the service `service-one` health check).

[source,yaml,indent=0,subs="verbatim"]
----
spring:
grpc:
client:
default-channel:
health:
enabled: true
channels:
one:
health:
enabled: true
service-name: service-one
----
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
|spring.grpc.client.default-channel.address | |
|spring.grpc.client.default-channel.default-load-balancing-policy | |
|spring.grpc.client.default-channel.enable-keep-alive | |
|spring.grpc.client.default-channel.health.enabled | | Whether to enable client-side health check for the channel.
|spring.grpc.client.default-channel.health.service-name | | Name of the service to check health on.
|spring.grpc.client.default-channel.idle-timeout | |
|spring.grpc.client.default-channel.keep-alive-time | |
|spring.grpc.client.default-channel.keep-alive-timeout | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package org.springframework.grpc.autoconfigure.client;

import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
Expand Down Expand Up @@ -59,7 +60,7 @@ public NamedChannelCredentialsProvider channelCredentialsProvider(GrpcClientProp
}

@Bean
public GrpcChannelConfigurer sslGrpcChannelConfigurer(GrpcClientProperties channels) {
public GrpcChannelConfigurer baseGrpcChannelConfigurer(GrpcClientProperties channels) {
return (authority, builder) -> {
for (String name : channels.getChannels().keySet()) {
if (authority.equals(name)) {
Expand All @@ -70,6 +71,13 @@ public GrpcChannelConfigurer sslGrpcChannelConfigurer(GrpcClientProperties chann
if (channel.getDefaultLoadBalancingPolicy() != null) {
builder.defaultLoadBalancingPolicy(channel.getDefaultLoadBalancingPolicy());
}
if (channel.getHealth().isEnabled()) {
String serviceNameToCheck = channel.getHealth().getServiceName() != null
? channel.getHealth().getServiceName() : "";
Map<String, ?> healthCheckConfig = Map.of("healthCheckConfig",
Map.of("serviceName", serviceNameToCheck));
builder.defaultServiceConfig(healthCheckConfig);
}
if (channel.getMaxInboundMessageSize() != null) {
builder.maxInboundMessageSize((int) channel.getMaxInboundMessageSize().toBytes());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ public void setDefaultLoadBalancingPolicy(final String defaultLoadBalancingPolic

// --------------------------------------------------

private final Health health = new Health();

public Health getHealth() {
return this.health;
}

/**
* The negotiation type for the channel. Default is
* {@link NegotiationType#PLAINTEXT}.
Expand Down Expand Up @@ -437,6 +443,9 @@ public void copyDefaultsFrom(final NamedChannel config) {
if (this.enableKeepAlive == null) {
this.enableKeepAlive = config.enableKeepAlive;
}
if (this.idleTimeout == null) {
this.idleTimeout = config.idleTimeout;
}
if (this.keepAliveTime == null) {
this.keepAliveTime = config.keepAliveTime;
}
Expand All @@ -455,6 +464,7 @@ public void copyDefaultsFrom(final NamedChannel config) {
if (this.userAgent == null) {
this.userAgent = config.userAgent;
}
this.health.copyDefaultsFrom(config.health);
this.ssl.copyDefaultsFrom(config.ssl);
}

Expand Down Expand Up @@ -523,6 +533,45 @@ public void setBundle(String bundle) {

}

public static class Health {

/**
* Whether to enable client-side health check for the channel.
*/
private Boolean enabled;

/**
* Name of the service to check health on.
*/
private String serviceName;

public boolean isEnabled() {
return this.enabled != null ? this.enabled : false;
}

public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}

public String getServiceName() {
return this.serviceName;
}

public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}

public void copyDefaultsFrom(Health config) {
if (this.enabled == null) {
this.enabled = config.enabled;
}
if (this.serviceName == null) {
this.serviceName = config.serviceName;
}
}

}

}

public SslBundle sslBundle(SslBundles bundles, String path) {
Expand Down
Loading

0 comments on commit e0199dd

Please sign in to comment.