Skip to content

Allow overriding the default ClientFactory via FlagsProvider#6671

Draft
JAEKWANG97 wants to merge 4 commits intoline:mainfrom
JAEKWANG97:feature/override-default-client-factory-minimal
Draft

Allow overriding the default ClientFactory via FlagsProvider#6671
JAEKWANG97 wants to merge 4 commits intoline:mainfrom
JAEKWANG97:feature/override-default-client-factory-minimal

Conversation

@JAEKWANG97
Copy link
Copy Markdown
Contributor

Motivation:

The default ClientFactory is currently fixed to the built-in default, so applications cannot override it for default client entry points.

This change introduces an SPI-based path for supplying the default ClientFactory via FlagsProvider.

Modifications:

  • Add FlagsProvider.defaultClientFactory() so a provider can supply a custom default ClientFactory.
  • Add Flags.defaultClientFactory().
  • Update ClientFactory.ofDefault() to resolve the default factory via Flags.defaultClientFactory().
  • Add SPI resolution coverage in FlagsProviderTest.
  • Update FlagsTest accordingly.

Result:

  • Users can now override the default ClientFactory via FlagsProvider.
  • Default client entry points that use ClientFactory.ofDefault() now pick up the overridden default factory.
  • Closes Override the default ClientFactory #6425.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

ClientFactory.ofDefault() now resolves the canonical default ClientFactory lazily via Flags.defaultClientFactory() supplied by FlagsProvider. closeDefault() and DefaultClientFactory.checkDefault() were adjusted to operate on the resolved factory type. DefaultFlagsProvider and tests were added to support and verify the SPI-based override.

Changes

Cohort / File(s) Summary
Flags core & SPI
core/src/main/java/com/linecorp/armeria/common/Flags.java, core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java
Added Flags.defaultClientFactory() with lazy holder and a new SPI method defaultClientFactorySupplier() on FlagsProvider to allow providing a Supplier<ClientFactory>; includes null-safety, resolution rules, and caching per classloader.
Default provider
core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java
Implements defaultClientFactorySupplier() returning a Supplier<ClientFactory> that builds the default ClientFactory via ClientFactory.builder().build().
ClientFactory behavior
core/src/main/java/com/linecorp/armeria/client/ClientFactory.java, core/src/main/java/com/linecorp/armeria/client/DefaultClientFactory.java
ofDefault() delegates to Flags.defaultClientFactory(); closeDefault() now closes the resolved factory with type-dependent calls; removed the fixed DEFAULT singleton constant and updated checkDefault() to treat INSECURE and the resolved default as non-closable.
Integration tests / test helpers
it/flags-provider/src/test/java/.../BaseFlagsProvider.java, it/flags-provider/src/test/java/.../FlagsProviderTest.java, core/src/test/java/.../ClientFactoryBuilderTest.java, core/src/test/java/.../FlagsTest.java
Added test SPI provider counting supplier invocations; tests for overriding default ClientFactory, supplier invocation behavior, factory options/lifecycle, ofDefault() identity, and API-consistency adjustments in Flags tests.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant ClientFactoryAPI as "ClientFactory.ofDefault()"
    participant FlagsAPI as "Flags.defaultClientFactory()"
    participant FlagsProviderSPI as "FlagsProvider"
    participant Supplier as "Supplier<ClientFactory>"
    participant Factory as "ClientFactory"

    Caller->>ClientFactoryAPI: request default factory
    ClientFactoryAPI->>FlagsAPI: resolve defaultClientFactory()
    FlagsAPI->>FlagsProviderSPI: consult provider chain
    FlagsProviderSPI-->>Supplier: return Supplier<ClientFactory> (or null)
    FlagsAPI->>Supplier: invoke supplier (lazily)
    Supplier-->>Factory: build / return ClientFactory
    FlagsAPI-->>ClientFactoryAPI: cache & return Factory
    ClientFactoryAPI-->>Caller: provide default ClientFactory instance
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nudged a flag and watched it sprout,
A lazy factory hopped out from the route.
One supplier called, one instance kept,
Close obeys the type — no secrets left. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: allowing overriding the default ClientFactory via FlagsProvider, which is the primary objective of the PR.
Description check ✅ Passed The PR description is well-structured and clearly relates to the changeset, explaining motivation, modifications, and expected results.
Linked Issues check ✅ Passed The PR implements the core requirement from issue #6425: allowing customization of the default ClientFactory via Flags.defaultClientFactory() exposed through FlagsProvider SPI.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the override mechanism for default ClientFactory via FlagsProvider; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java`:
- Around line 378-381: The current defaultClientFactory() creates a new
DefaultClientFactory each call causing the static DefaultClientFactory.DEFAULT
to never be closed; change DefaultFlagsProvider.defaultClientFactory() to return
the existing static instance (DefaultClientFactory.DEFAULT or
DefaultClientFactory.INSECURE as appropriate) instead of
ClientFactory.builder().build(), or alternatively ensure
DefaultClientFactory.DEFAULT is closed from ClientFactory.closeDefault();
specifically update the DefaultFlagsProvider.defaultClientFactory() method to
return the shared DefaultClientFactory.DEFAULT/INSECURE instances and remove
per-call builder() usage so the shutdown hook (ClientFactory.closeDefault())
will actually close the static DEFAULT.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2090aaec-6248-45f1-88ec-336f039e0060

📥 Commits

Reviewing files that changed from the base of the PR and between 1c00618 and b431fa6.

📒 Files selected for processing (7)
  • core/src/main/java/com/linecorp/armeria/client/ClientFactory.java
  • core/src/main/java/com/linecorp/armeria/client/DefaultClientFactory.java
  • core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java
  • core/src/main/java/com/linecorp/armeria/common/Flags.java
  • core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java
  • it/flags-provider/src/test/java/com/linecorp/armeria/common/BaseFlagsProvider.java
  • it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java

@JAEKWANG97
Copy link
Copy Markdown
Contributor Author

JAEKWANG97 commented Mar 17, 2026

While working on this, I also explored a broader draft that touched follow-up lifecycle-related behaviors such as closeDefault(), direct close(), shutdown hook handling, and the interaction with insecure().

Since those expanded the scope significantly, I narrowed this PR to focus on the default override mechanism itself and am opening it as a draft first.

That broader exploration also suggested there may be room for structural cleanup around the ownership boundary between Flags and ClientFactory, especially if those lifecycle-related behaviors are revisited.

For reference, the earlier broader draft is here:
#6666

I'd appreciate feedback on whether those lifecycle-related behaviors should be handled in follow-up changes, and whether that kind of structural cleanup would be preferred before expanding the scope.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 17, 2026

🔍 Build Scan® (commit: e6b1b9e)

Job name Status Build Scan®
build-ubicloud-standard-16-jdk-8 https://ge.armeria.dev/s/aydrrbi5a63sm

@ikhoon
Copy link
Copy Markdown
Contributor

ikhoon commented Mar 25, 2026

That broader exploration also suggested there may be room for structural cleanup around the ownership boundary between Flags and ClientFactory, especially if those lifecycle-related behaviors are revisited.

The overall changes look good. Could you explain in more detail what issues there are with the lifecycle of Flags and ClientFactory?

@jrhee17
Copy link
Copy Markdown
Contributor

jrhee17 commented Mar 27, 2026

I'm unsure if Flags#clientFactory won't have race conditions with existing flags.
e.g. Assume defaultResponseTimeoutMillis, defaultClientFactory is set via flags. Will Flags.defaultResponseTimeoutMillis be applied to defaultClientFactory? I'm unsure if this will be set properly - this may also depend on the location of the property within the Flags java file as we rely on java initialization logic to initialize flags at the moment.

Perhaps a saner approach may be to define a defaultClientFactoryProvider which is a Supplier type to Flags. At the very least, this would ensure decoupling between Flags initialization and default ClientFactory initialization.

Long term, we'll probably need a separate mechanism for flag loading that doesn't rely on class initialization ordering (e.g. dependency injection)

@JAEKWANG97
Copy link
Copy Markdown
Contributor Author

JAEKWANG97 commented Mar 29, 2026

The overall changes look good. Could you explain in more detail what issues there are with the lifecycle of Flags and ClientFactory?

@ikhoon What I had in mind there was that the ownership of the default ClientFactory is now split across Flags, ClientFactory, and DefaultClientFactory.

Concretely:

  • ClientFactory.ofDefault() now delegates to Flags.defaultClientFactory()
  • Flags.defaultClientFactory() uses a lazy holder that resolves FlagsProvider::defaultClientFactory
  • if no user provider returns a value, DefaultFlagsProvider.defaultClientFactory() currently falls back to ClientFactory.builder().build()

The main follow-up issues I ran into in the draft PR I closed were:

  1. Fallback identity
  • When no FlagsProvider returns a value, the fallback creates a new ClientFactory via ClientFactory.builder().build(), so ClientFactory.ofDefault() and DefaultClientFactory.DEFAULT can end up referring to different objects.
  • So the effective default can diverge from the built-in default identity, which makes the ownership of the default lifecycle less clear.
  1. Split lifecycle ownership
  • ClientFactory exposes lifecycle entry points such as ofDefault() and closeDefault(),
  • while built-in singleton semantics, direct-close protection, and shutdown-hook registration still live in DefaultClientFactory.
  • Once the effective default is no longer guaranteed to be the built-in one, that ownership boundary becomes less obvious.
  1. Shutdown hook ownership
  • The shutdown hook is currently registered from DefaultClientFactory static initialization.
  • If a custom FlagsProvider.defaultClientFactory() supplies the effective default and DefaultClientFactory is never otherwise initialized, the hook registration path may not be exercised even though the effective default has been overridden.
  1. Direct-close / lazy-init side effects
  • DefaultClientFactory.checkDefault() now checks ClientFactory.ofDefault() as well.
  • A close-related code path should not be responsible for resolving or initializing the effective default. That inversion itself is the problem.

So the concern I had was less that any one class is individually wrong, and more that once the effective default is resolved through Flags, the responsibilities around lookup, fallback identity, close semantics, and shutdown ownership are spread across multiple places.

That was the context behind my comment about possible structural cleanup between Flags and ClientFactory.

@JAEKWANG97
Copy link
Copy Markdown
Contributor Author

JAEKWANG97 commented Mar 30, 2026

@jrhee17 Agreed. The root issue is that Flags not only does a lookup but also owns factory construction in the fallback path, and ClientFactory directly references DefaultClientFactory in multiple places.

Rather than the Supplier approach, I think the right fix is to make Flags a pure lookup layer, and introduce an abstract class between ClientFactory and DefaultClientFactory to own the effective default and lifecycle logic. This would decouple Flags initialization from factory construction entirely.

What do you think?

@jrhee17
Copy link
Copy Markdown
Contributor

jrhee17 commented Apr 1, 2026

I think I need to understand exactly what the issue is:

So you are saying even if Supplier<ClientFactory> defaultClientFactory is provided via Flags and ClientFactory.DEFAULT = Flags.defaultClientFactory.get(), there is still a initialization error.
Am I understanding correctly?

@JAEKWANG97
Copy link
Copy Markdown
Contributor Author

@jrhee17 I think I focused more on the lifecycle/ownership concern than on your initialization-order point, so let me address that first.

I have not identified a concrete initialization failure in this draft so far. With DefaultClientFactoryHolder, Flags.defaultClientFactory() is resolved only after the existing static fields in Flags have been initialized.

That said, I agree with the broader concern that the current design still depends on Java class-initialization ordering, since Flags is still part of the fallback path that constructs the default ClientFactory.

So a Supplier<ClientFactory> seems like a cleaner direction for the initialization concern.

Would it make sense to proceed in that direction?

@jrhee17
Copy link
Copy Markdown
Contributor

jrhee17 commented Apr 3, 2026

ClientFactory.ofDefault() and DefaultClientFactory.DEFAULT can end up referring to different objects.

I think this is an issue though - they should refer to the same object.

I don't think changing the lifecycle of flags is trivial, we may want to design this separately if needed.
Hence, for this issue, what do you think of first trying DefaultClientFactory DEFAULT = Flags.defaultClientFactory()?

If the CI fails, we can analyze together and talk about whether/how this can be fixed.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java (1)

225-229: Minor: setAccessible(true) is unnecessary for public methods.

The setAccessible(true) call is redundant when invoking public methods via getMethod(). However, this is a harmless nit.

♻️ Optional simplification
     private static Object invokePublic(Object target, String methodName) throws Throwable {
         final Method method = target.getClass().getMethod(methodName);
-        method.setAccessible(true);
         return method.invoke(target);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java`
around lines 225 - 229, The helper method invokePublic unnecessarily calls
method.setAccessible(true) for a public method retrieved via getMethod(); remove
the setAccessible(true) invocation from invokePublic (the Method method
variable) so the method simply obtains the Method via
target.getClass().getMethod(methodName) and invokes it, keeping behavior
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java`:
- Around line 225-229: The helper method invokePublic unnecessarily calls
method.setAccessible(true) for a public method retrieved via getMethod(); remove
the setAccessible(true) invocation from invokePublic (the Method method
variable) so the method simply obtains the Method via
target.getClass().getMethod(methodName) and invokes it, keeping behavior
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 556444f3-960b-4e2c-964f-597d629059e7

📥 Commits

Reviewing files that changed from the base of the PR and between b431fa6 and c3b8765.

📒 Files selected for processing (8)
  • core/src/main/java/com/linecorp/armeria/client/ClientFactory.java
  • core/src/main/java/com/linecorp/armeria/client/DefaultClientFactory.java
  • core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java
  • core/src/main/java/com/linecorp/armeria/common/Flags.java
  • core/src/main/java/com/linecorp/armeria/common/FlagsProvider.java
  • core/src/test/java/com/linecorp/armeria/client/ClientFactoryBuilderTest.java
  • it/flags-provider/src/test/java/com/linecorp/armeria/common/BaseFlagsProvider.java
  • it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/src/main/java/com/linecorp/armeria/common/DefaultFlagsProvider.java
  • core/src/main/java/com/linecorp/armeria/client/DefaultClientFactory.java
  • core/src/main/java/com/linecorp/armeria/client/ClientFactory.java

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 4, 2026

Codecov Report

❌ Patch coverage is 70.58824% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.95%. Comparing base (8150425) to head (e6b1b9e).
⚠️ Report is 395 commits behind head on main.

Files with missing lines Patch % Lines
...ava/com/linecorp/armeria/client/ClientFactory.java 60.00% 1 Missing and 1 partial ⚠️
...c/main/java/com/linecorp/armeria/common/Flags.java 77.77% 2 Missing ⚠️
.../linecorp/armeria/client/DefaultClientFactory.java 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6671      +/-   ##
============================================
- Coverage     74.46%   73.95%   -0.51%     
- Complexity    22234    24044    +1810     
============================================
  Files          1963     2171     +208     
  Lines         82437    90196    +7759     
  Branches      10764    11828    +1064     
============================================
+ Hits          61385    66707    +5322     
- Misses        15918    17893    +1975     
- Partials       5134     5596     +462     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java (1)

228-246: Consider adding cleanup protection for test robustness.

Unlike overrideDefaultClientFactory(), this test lacks a try-finally to ensure closeDefault() is called if an assertion fails before line 243. This could leave resources open in failure scenarios, potentially affecting subsequent tests.

♻️ Suggested fix to add try-finally for cleanup
     `@Test`
     `@SetSystemProperty`(key = "com.linecorp.armeria.test.decoratingDefaultClientFactory", value = "true")
     void closeDefaultClosesDecoratingClientFactory() throws Throwable {
         final Method defaultClientFactoryMethod = flags.getDeclaredMethod("defaultClientFactory");
         final Object clientFactory = defaultClientFactoryMethod.invoke(null);
         final Class<?> clientFactoryClass = defaultClientFactoryMethod.getReturnType();

         assertThat(clientFactory.getClass().getSimpleName()).isEqualTo("TestDecoratingClientFactory");
         final CompletableFuture<?> whenClosed =
                 (CompletableFuture<?>) invokePublic(clientFactory, "whenClosed");

-        assertThat(invokePublic(clientFactory, "isClosed")).isEqualTo(false);
-        invokePublic(clientFactory, "close");
-        assertThat(invokePublic(clientFactory, "isClosed")).isEqualTo(false);
-
-        invokeDeclaredStatic(clientFactoryClass, "closeDefault");
-        whenClosed.join();
-        assertThat(invokePublic(clientFactory, "isClosed")).isEqualTo(true);
+        try {
+            assertThat(invokePublic(clientFactory, "isClosed")).isEqualTo(false);
+            invokePublic(clientFactory, "close");
+            assertThat(invokePublic(clientFactory, "isClosed")).isEqualTo(false);
+
+            invokeDeclaredStatic(clientFactoryClass, "closeDefault");
+            whenClosed.join();
+            assertThat(invokePublic(clientFactory, "isClosed")).isEqualTo(true);
+        } finally {
+            if (!whenClosed.isDone()) {
+                invokeDeclaredStatic(clientFactoryClass, "closeDefault");
+                whenClosed.join();
+            }
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java`
around lines 228 - 246, Wrap the body of
closeDefaultClosesDecoratingClientFactory (the code that invokes
defaultClientFactory, checks isClosed, calls close, and asserts) in a
try-finally so that invokeDeclaredStatic(clientFactoryClass, "closeDefault") is
always executed in the finally block (and ensure whenClosed.join() is called to
wait for closure), preserving any thrown assertion exception (i.e., do not
swallow exceptions in the finally).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java`:
- Around line 228-246: Wrap the body of
closeDefaultClosesDecoratingClientFactory (the code that invokes
defaultClientFactory, checks isClosed, calls close, and asserts) in a
try-finally so that invokeDeclaredStatic(clientFactoryClass, "closeDefault") is
always executed in the finally block (and ensure whenClosed.join() is called to
wait for closure), preserving any thrown assertion exception (i.e., do not
swallow exceptions in the finally).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c250394a-1edd-4751-95de-c285d5d2a3e8

📥 Commits

Reviewing files that changed from the base of the PR and between 41c8537 and e6b1b9e.

📒 Files selected for processing (3)
  • core/src/test/java/com/linecorp/armeria/client/ClientFactoryBuilderTest.java
  • it/flags-provider/src/test/java/com/linecorp/armeria/common/BaseFlagsProvider.java
  • it/flags-provider/src/test/java/com/linecorp/armeria/common/FlagsProviderTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/src/test/java/com/linecorp/armeria/client/ClientFactoryBuilderTest.java

@ikhoon
Copy link
Copy Markdown
Contributor

ikhoon commented Apr 6, 2026

what do you think of first trying DefaultClientFactory DEFAULT = Flags.defaultClientFactory()?

I thought DefaultClientFactory.DEFAULT should be removed and Flags.defaultClientFactory() replaces it.

I'm not sure whether it is technically impossible to handle the side effects caused by the initialization order. If you are concerned about it, what do you think about returning ClientFactoryConfigurer from Flags instead of an instance? The Flags.defaultClientFactoryConfigurer() could be applied to DefaultClientFactory.DEFAULT and DefaultClientFactory.INSECURE, which could be another way to solve the original issue.

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.

Override the default ClientFactory

3 participants