Summary
Both AuthProvider.content(...) and WorkflowDecorator.decorate(...) (parent of HttpRequestDecorator) have synchronous contracts:
AuthProvider.content() returns String
WorkflowDecorator.decorate() returns void
This forces any implementation that needs I/O (e.g., calling an OIDC token endpoint) to block the calling thread. While AuthProvider.content() currently runs inside CompletableFuture.supplyAsync on a worker thread (safe today), HttpRequestDecorator.decorate() runs on the calling thread before the async dispatch — on a Vert.x event-loop thread this throws VertxException: Thread blocked.
Problem
HttpRequestDecorator (critical)
In HttpExecutor.apply() (line 78), decorators run synchronously on the calling thread:
Builder request = target.request();
requestDecorators.forEach(d -> d.decorate(request, workflow, taskContext)); // <-- calling thread
headersMap.ifPresent(...);
return CompletableFuture.supplyAsync(
() -> requestFunction.apply(request, uri, workflow, taskContext, input),
workflow.definition().application().executorService());
If a decorator calls a reactive/async API (e.g., Quarkus OidcClient.getTokens() which returns Uni<Tokens>), it must block with .await().indefinitely(). On a Vert.x event-loop thread (@NonBlocking), this throws VertxException: Thread blocked.
AuthProvider (safe today, fragile long-term)
AuthProvider.content() runs inside AbstractRequestExecutor.apply() (line 52), which is called from CompletableFuture.supplyAsync on the executor service thread — so blocking I/O is safe as long as the executor provides worker threads. However:
- The API contract (
String content(...)) is inherently synchronous — there's no way for an implementation to signal it needs async execution
- The worker-thread guarantee depends on the injected
ExecutorService — if the executor were changed to run on event-loop threads, implementations would break silently
Current Execution Flow
HttpExecutor.apply() — CALLING THREAD (may be Vert.x event loop)
│
├─ 1. Build WebTarget (URI + query params) [line 72-76]
├─ 2. Create Invocation.Builder [line 77]
├─ 3. Run HttpRequestDecorators (SYNC, on calling thread) [line 78] ← PROBLEM
├─ 4. Apply task headers [line 79]
│
└─ 5. CompletableFuture.supplyAsync(...) — WORKER THREAD
│
└─ AbstractRequestExecutor.apply()
├─ 6a. AuthProvider.content() (SYNC, on worker) [line 52] ← safe today
└─ 6b. invokeRequest() [line 53]
Proposal
Add default async methods to both interfaces, with the synchronous methods as fallback. This is fully backward compatible — existing implementations continue to work unchanged.
AuthProvider — add contentAsync
// impl/core/src/main/java/io/serverlessworkflow/impl/auth/AuthProvider.java
public interface AuthProvider {
String scheme();
String content(WorkflowContext workflow, TaskContext task, WorkflowModel model, URI uri);
/**
* Async variant of {@link #content}. Override this method when the token
* acquisition involves I/O (e.g., calling an OIDC token endpoint).
* <p>Default implementation delegates to the synchronous {@link #content} method.
*/
default CompletableFuture<String> contentAsync(
WorkflowContext workflow, TaskContext task, WorkflowModel model, URI uri) {
return CompletableFuture.completedFuture(content(workflow, task, model, uri));
}
}
WorkflowDecorator — add decorateAsync
// impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDecorator.java
public interface WorkflowDecorator<T> extends ServicePriority {
void decorate(T decorated, WorkflowContext workflowContext, TaskContext taskContext);
/**
* Async variant of {@link #decorate}. Override this method when the
* decoration involves I/O (e.g., fetching a token from an external provider).
* <p>Default implementation delegates to the synchronous {@link #decorate} method.
*/
default CompletableFuture<Void> decorateAsync(
T decorated, WorkflowContext workflowContext, TaskContext taskContext) {
decorate(decorated, workflowContext, taskContext);
return CompletableFuture.completedFuture(null);
}
}
Callsite Changes
HttpExecutor.apply() — chain decorators as async
// Before (line 78, sync, blocks calling thread):
requestDecorators.forEach(d -> d.decorate(request, workflow, taskContext));
return CompletableFuture.supplyAsync(
() -> requestFunction.apply(request, uri, workflow, taskContext, input),
workflow.definition().application().executorService());
// After (async, safe on any thread):
CompletableFuture<Void> decorateChain = CompletableFuture.completedFuture(null);
for (HttpRequestDecorator d : requestDecorators) {
decorateChain = decorateChain.thenCompose(
v -> d.decorateAsync(request, workflow, taskContext));
}
return decorateChain.thenComposeAsync(
v -> {
headersMap.ifPresent(h -> h.apply(workflow, taskContext, input).forEach(request::header));
return CompletableFuture.supplyAsync(
() -> requestFunction.apply(request, uri, workflow, taskContext, input),
workflow.definition().application().executorService());
},
workflow.definition().application().executorService());
Note: The headers application (line 79) should also move inside the thenComposeAsync to maintain the correct ordering: decorators → headers → auth → request.
AbstractRequestExecutor.apply() — use contentAsync
// Before (line 52):
authProvider.ifPresent(auth -> addAuthHeader(auth, uri, request, workflow, task, model));
// After (already on worker thread, so .join() is safe):
authProvider.ifPresent(auth -> {
String parameter = auth.contentAsync(workflow, task, model, uri).join();
String scheme = auth.scheme();
task.authorization(scheme, parameter);
request.header(AuthUtils.AUTH_HEADER_NAME, AuthUtils.authHeaderValue(scheme, parameter));
});
Files to Modify
| File |
Change |
impl/core/.../auth/AuthProvider.java |
Add contentAsync() default method |
impl/core/.../WorkflowDecorator.java |
Add decorateAsync() default method |
impl/http/.../HttpExecutor.java |
Rewrite apply() to chain decorators via decorateAsync() |
impl/http/.../AbstractRequestExecutor.java |
Use contentAsync().join() in addAuthHeader() |
Backward Compatibility
- Fully backward compatible: default methods delegate to the existing sync methods
- Existing
AuthProvider and HttpRequestDecorator implementations require no changes
- New implementations can override
contentAsync()/decorateAsync() to return truly async futures
- The sync methods remain the primary implementation point for simple (non-I/O) cases
- No new dependencies —
CompletableFuture is in java.util.concurrent
Testing Plan
-
Existing tests pass unchanged — default methods delegate to sync, so all current behavior is preserved
-
New test: async decorator does not block calling thread
- Create a decorator that returns a
CompletableFuture completed on a different thread (simulating async I/O)
- Run
HttpExecutor.apply() from a thread that throws on blocking (simulating Vert.x event loop)
- Assert the decorator completes without blocking
-
New test: async AuthProvider returns token via future
- Create an
AuthProvider that overrides contentAsync() with a delayed future
- Assert the token is correctly applied to the
Authorization header
-
New test: mixed sync and async decorators
- Register both sync-only (only overrides
decorate()) and async (overrides decorateAsync()) decorators
- Assert both execute in priority order and all headers are applied
Context
This issue was identified during the Quarkus Flow OIDC integration (Phase 0 validation). Quarkus Flow uses Quarkus OidcClient which returns reactive types (Uni<Tokens>). The current workaround is to use AuthProviderFactory/AuthProvider (which runs on a worker thread via supplyAsync), avoiding HttpRequestDecorator entirely for auth concerns. This works but limits the decorator SPI's usefulness for any I/O-bound decoration.
Related issues
Summary
Both
AuthProvider.content(...)andWorkflowDecorator.decorate(...)(parent ofHttpRequestDecorator) have synchronous contracts:AuthProvider.content()returnsStringWorkflowDecorator.decorate()returnsvoidThis forces any implementation that needs I/O (e.g., calling an OIDC token endpoint) to block the calling thread. While
AuthProvider.content()currently runs insideCompletableFuture.supplyAsyncon a worker thread (safe today),HttpRequestDecorator.decorate()runs on the calling thread before the async dispatch — on a Vert.x event-loop thread this throwsVertxException: Thread blocked.Problem
HttpRequestDecorator (critical)
In
HttpExecutor.apply()(line 78), decorators run synchronously on the calling thread:If a decorator calls a reactive/async API (e.g., Quarkus
OidcClient.getTokens()which returnsUni<Tokens>), it must block with.await().indefinitely(). On a Vert.x event-loop thread (@NonBlocking), this throwsVertxException: Thread blocked.AuthProvider (safe today, fragile long-term)
AuthProvider.content()runs insideAbstractRequestExecutor.apply()(line 52), which is called fromCompletableFuture.supplyAsyncon the executor service thread — so blocking I/O is safe as long as the executor provides worker threads. However:String content(...)) is inherently synchronous — there's no way for an implementation to signal it needs async executionExecutorService— if the executor were changed to run on event-loop threads, implementations would break silentlyCurrent Execution Flow
Proposal
Add default async methods to both interfaces, with the synchronous methods as fallback. This is fully backward compatible — existing implementations continue to work unchanged.
AuthProvider — add
contentAsyncWorkflowDecorator — add
decorateAsyncCallsite Changes
HttpExecutor.apply() — chain decorators as async
Note: The headers application (line 79) should also move inside the
thenComposeAsyncto maintain the correct ordering: decorators → headers → auth → request.AbstractRequestExecutor.apply() — use contentAsync
Files to Modify
impl/core/.../auth/AuthProvider.javacontentAsync()default methodimpl/core/.../WorkflowDecorator.javadecorateAsync()default methodimpl/http/.../HttpExecutor.javaapply()to chain decorators viadecorateAsync()impl/http/.../AbstractRequestExecutor.javacontentAsync().join()inaddAuthHeader()Backward Compatibility
AuthProviderandHttpRequestDecoratorimplementations require no changescontentAsync()/decorateAsync()to return truly async futuresCompletableFutureis injava.util.concurrentTesting Plan
Existing tests pass unchanged — default methods delegate to sync, so all current behavior is preserved
New test: async decorator does not block calling thread
CompletableFuturecompleted on a different thread (simulating async I/O)HttpExecutor.apply()from a thread that throws on blocking (simulating Vert.x event loop)New test: async AuthProvider returns token via future
AuthProviderthat overridescontentAsync()with a delayed futureAuthorizationheaderNew test: mixed sync and async decorators
decorate()) and async (overridesdecorateAsync()) decoratorsContext
This issue was identified during the Quarkus Flow OIDC integration (Phase 0 validation). Quarkus Flow uses Quarkus
OidcClientwhich returns reactive types (Uni<Tokens>). The current workaround is to useAuthProviderFactory/AuthProvider(which runs on a worker thread viasupplyAsync), avoidingHttpRequestDecoratorentirely for auth concerns. This works but limits the decorator SPI's usefulness for any I/O-bound decoration.Related issues
AuthProviderFactory(merged, in 7.24.0.Final)adr/2026-06-03-token-propagation-exchange-design.md(Risk 1)