Skip to content

Add registry decorators for deadlines and service-keyed circuit breakers - #94

Open
suresh-prakash wants to merge 9 commits into
mainfrom
grpc-registry-decorators
Open

suresh-prakash wants to merge 9 commits into
mainfrom
grpc-registry-decorators

Conversation

@suresh-prakash

Copy link
Copy Markdown
Contributor

Summary

  • Add InterceptingChannelRegistry so an already-built GrpcChannelRegistry can carry one client interceptor, and TimeoutChannelRegistry so grpc.client.timeout applies a default deadline when a call has none.
  • Add a service-keyed circuit breaker: CircuitBreakerInterceptorFactory.forService and CircuitBreakerChannelRegistry.wrap. The breaker name is the protobuf service name. failureStatusCodes defaults to DEADLINE_EXCEEDED, UNAVAILABLE, and RESOURCE_EXHAUSTED.
  • Leave the existing request-keyed interceptor unchanged. Callers compose the timeout and circuit-breaker decorators themselves.

Test plan

  • ./gradlew -Pversion=0.13.23 :grpc-client-utils:test :grpc-circuitbreaker-utils:test
  • Confirm an absent or disabled config returns the original registry
  • Confirm an enabled service opens on the configured failure statuses and rejects with RESOURCE_EXHAUSTED before the downstream call
  • Confirm a quoted dotted service name parses, and an unknown status name fails startup

Made with Cursor

suresh-prakash and others added 7 commits October 23, 2026 15:26
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…gistry.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Test Results

132 tests  +33   132 ✅ +33   23s ⏱️ ±0s
 22 suites + 5     0 💤 ± 0 
 22 files   + 5     0 ❌ ± 0 

Results for commit 68ee8a5. ± Comparison against base commit 48511e9.

♻️ This comment has been updated with latest results.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gitignore "docs/superpowers".


private CircuitBreakerChannelRegistry() {}

public static GrpcChannelRegistry wrap(GrpcChannelRegistry registry, Config config) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer to use final wherever feasible (to avoid errors like accidentally assigning a local variable instead of the field). Let's use that throughout this PR.


static final String CIRCUIT_BREAKER_CONFIG = "circuit.breaker.config";

private CircuitBreakerChannelRegistry() {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can also use lombok?

for (String serviceName : config.root().keySet()) {
if (CircuitBreakerConfigParser.ENABLED.equals(serviceName)
|| CircuitBreakerConfigParser.DEFAULT_THRESHOLDS.equals(serviceName)) {
continue;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be helpful to have "debug" logs at these early exist points so it can come handy for debugging.

.map(Status.Code::name)
.collect(Collectors.joining(", "));
return ConfigFactory.parseString(
"failureRateThreshold = "

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be better if these configs were built more first-class instead of string concatenation. It's okay if that's not possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, once we have the first-class model, the defaults can be specified directly in the model.

Objects.requireNonNull(registry), Objects.requireNonNull(interceptor));
}

@Delegate(excludes = ForAddress.class)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the excludes specifically? Or, can lombok automatically skip methods that are explicitly overridden?

}

private static final class InterceptedManagedChannel extends ManagedChannel {
@Delegate(excludes = InterceptedCall.class)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.


Config timeoutConfig = config.getConfig(TIMEOUT_PATH);
if (!timeoutConfig.hasPath(ENABLED_KEY) || !timeoutConfig.getBoolean(ENABLED_KEY)) {
return registry;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a debug log at all early exist points, especially since the would be invoked just once per restart.

return InterceptingChannelRegistry.wrap(registry, new DefaultTimeoutClientInterceptor(timeout));
}

private TimeoutChannelRegistry() {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can be replaced with Lombok?

Comment thread .gitignore

.idea

.superpowers/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs/superpowers can also be ignored.

Parse service breakers into CircuitBreakerThresholds instead of rebuilding defaults as HOCON, and log when a decorator is skipped or a call is rejected. Lombok still excludes forAddress because that method is inherited, not overridden.

Co-authored-by: Cursor <cursoragent@cursor.com>

static final String CIRCUIT_BREAKER_CONFIG = CircuitBreakerConfigParser.CIRCUIT_BREAKER_CONFIG;

public static GrpcChannelRegistry wrap(final GrpcChannelRegistry registry, final Config config) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not take in the first-class object here instead of typesafe?

return new ServiceCircuitBreakerInterceptor(Map.of(), Map.of());
}

// Resilience4j records onError as a success when recordException returns false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment seems off place still.

Callers can install the breaker from CircuitBreakerThresholds without passing Typesafe Config. Parameters in the new code are final, and a breaker logs when it changes state.

Co-authored-by: Cursor <cursoragent@cursor.com>
@suresh-prakash
suresh-prakash marked this pull request as ready for review November 2, 2026 08:28
@suresh-prakash
suresh-prakash requested a review from a team as a code owner November 2, 2026 08:28
@aaron-steinfeld

Copy link
Copy Markdown
Contributor

question: Could we clarify why the registry is wrapped (TimeoutChannelRegistry.wrap, CircuitBreakerChannelRegistry.wrap) rather than adding the interceptor through the registry's existing config?

The goal makes sense: opt in via config, and apply a circuit breaker (and a default timeout) to all outgoing calls in the Kafka Streams apps. As the registry is currently structured, GrpcRegistryConfig.defaultInterceptors is applied to every channel it builds, so the app would construct the registry once with the interceptor:

GrpcChannelRegistry registry =
    new GrpcChannelRegistry(
        GrpcRegistryConfig.builder()
            .defaultInterceptor(CircuitBreakerInterceptorFactory.forService(config))
            .build());

forService (added in this PR) already returns a plain ClientInterceptor and is a no-op when circuit.breaker.config is absent or disabled, so opt-in by config is preserved without needing to wrap the registry.

What does wrapping give us over that? For example:

  • Is the registry constructed somewhere the apps can't pass a GrpcRegistryConfig (a shared framework or bootstrap)?
  • Does the layering of timeout and breaker need to be controlled by the caller beyond interceptor order?
  • Is there something else I'm missing?

Knowing the constraint will help us decide whether wrapping is the right shape or whether a config-based route covers it.

🤖 Drafted with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants