Skip to content

Reject futures that graphql-java can't wait on - #839

Merged
oryan-block merged 6 commits into
masterfrom
bugfix/203
Oct 8, 2026
Merged

oryan-block merged 6 commits into
masterfrom
bugfix/203

Conversation

@oryan-block

@oryan-block oryan-block commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #203

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

Stacked on #836, merge that first.

In #203 a resolver declared as Future<Organization> returns RxJava's Single#toFuture(), and its child fields fail with Expected source object to be an instance of '...Organization' but instead got 'io.reactivex.internal.observers.FutureSingleObserver'. The default GenericWrapper(Future::class, 0) is an identity transformer, so it only unwraps the type at scan time. At runtime graphql-java only waits on a CompletionStage, so any other Future became the field value:

  • object fields fail on their children with a source type mismatch;
  • list fields fail with type mismatch error, expected type LIST;
  • scalar fields silently serialize the future's toString, e.g. java.util.concurrent.FutureTask@1b2c3d[Completed normally].

A suspend function that returns any future, CompletableFuture included, has the same problem, because graphql-java doesn't flatten a future that completes with another future.

Now the field fails with an error that says what to return instead:

  • Resolver method 'Query.organization' resolved to a io.reactivex.internal.observers.FutureSingleObserver, which graphql-java can't wait on. Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead.
  • Suspend function 'Query.organization' resolved to a java.util.concurrent.CompletableFuture, which graphql-java can't wait on. Return its value instead.

The future is never waited on, even if it's already done. Blocking the fetching thread would delay sibling fields, since graphql-java calls fetchers one at a time on that thread, and it would hang on a plain future that only completes after graphql-java dispatches DataLoaders. Reading only futures that are already done would make the result depend on timing: it would work in tests and fail under load.

The check runs after the generic wrapper transformer, so a transformer can still unwrap a plain future (GenericWrapper.withTransformer(FutureTask::class, 0, { it.get() })). A value is only rejected if it isn't an instance of the class the schema type was matched to at scan time. Because of that:

  • a value that implements Future but is the schema type itself (fun job(): Job with class Job : Future<String>) still resolves;
  • a Future that isn't unwrapped because useDefaultGenericWrappers(false) is set still resolves;
  • a return type that's a type variable bound to Future is checked;
  • a custom transformer that returns a plain future is checked. That's tfadsilva's comment on Help with Default Generic Wrappers #203: Flowable.toList().toFuture() in a transformer.
  • a union field declared Future<Object> (or Future<Any>) is checked. Every value is an Object, so when the type unwraps to Object any future from a method declared to return Future is checked. On master a plain future there made graphql-java throw a TypeResolverError out of execute.

Not covered, same behaviour as master:

  • a Java field or @JvmField typed Future<T>. Those go through PropertyFieldResolver, which doesn't apply generic wrapper transformers at all;
  • Optional<Future<T>> and DataFetcherResult<Future<T>>, where the future is nested inside another container;
  • Future<out R>, a wildcard over a class type variable;
  • a declared FutureTask<String> or raw Future on a scalar field, which no wrapper unwraps.

Behaviour change, so this should go out in a minor release:

  • Downstream workarounds now get the error. Anyone who worked around Help with Default Generic Wrappers #203 after the data fetcher, with a ValueUnboxer, an Instrumentation that wraps data fetchers, a SchemaDirectiveWiring, or a custom scalar Coercing that unwraps the future, now gets the error, because the data fetcher throws before their code sees the value. The fix is to move the unwrapping into a generic wrapper transformer, or return a CompletionStage.
  • Nulls spread one level higher. The error is now on the field itself instead of its children. On a non-null field, the null spreads one level further up, so sibling data that used to come back can be lost.
  • Suspend functions under a regular Job. For suspend functions, the error is thrown inside the coroutine like any other resolver exception. If the request's context is a CoroutineScope with a regular Job, sibling suspend fields are cancelled.
  • The docs. The schema parser options docs list Future as a well-known wrapper without saying the runtime value has to be a CompletionStage. That needs a follow-up there.

🤖 Generated with Claude Code

oryan-block and others added 3 commits October 7, 2026 08:57
The default Future wrapper only unwraps the type at scan time. At
runtime graphql-java only waits on a CompletionStage, so any other
Future (a FutureTask, or the observer returned by RxJava's toFuture())
reached graphql-java as the field value. Object fields then failed on
their children with a source type mismatch, list fields failed with a
type mismatch, and scalar fields silently serialized the future's
toString. A suspend function that returned any future, CompletableFuture
included, hit the same problem, because graphql-java doesn't flatten a
future that completes with another future.

When a resolver resolves to such a future, after its generic wrapper
transformer ran, the field now fails with an error that says what to
return instead. The future is never waited on, because blocking the
fetching thread delays sibling fields and hangs on a future that only
completes after graphql-java dispatches DataLoaders.

The value is only rejected if it isn't an instance of the class the
schema type was matched to at scan time. A value that implements Future
but is the schema type itself, or a Future that isn't unwrapped because
the default wrappers are disabled, is left alone. Because it compares
against the matched class rather than the declared return type, the
check also covers a return type that is a type variable and a custom
transformer that returns a plain future.

Fixes #203

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A method declared to return Future<Object> unwraps to Object, which
every value is an instance of, so a plain future returned for a union
field slipped through and graphql-java threw a TypeResolverError out of
execute. When the return type unwraps to Object, every future returned
by a method declared to return Future is now checked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Base automatically changed from bugfix/371 to master October 7, 2026 13:52
@sonarqubecloud

sonarqubecloud Bot commented Oct 8, 2026

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit f203700 into master Oct 8, 2026
6 checks passed
@oryan-block
oryan-block deleted the bugfix/203 branch October 8, 2026 21:47
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.

Help with Default Generic Wrappers

1 participant