Skip to content

NettyNioAsyncHttpClient:NPE in HandlerSubscriber.maybeRequestMore (null subscription) when channelWritabilityChanged fires during the Expect: 100-continue window #7271

Description

@Adiiigo

Describe the bug

HandlerSubscriber.maybeRequestMore() calls subscription.request(...) with no null check, and channelWritabilityChanged() calls it unconditionally (unlike channelActive(), which is state-gated). subscription is assigned only in onSubscribe() and never reset to null.

For Expect: 100-continue requests, HttpStreamsClientHandler.subscribeSubscriberToStream() deliberately defers the body subscription until the 100 Continue response arrives. The HandlerSubscriber is nonetheless already in the pipeline (added by HttpStreamsHandler.unbufferedWrite()), so it keeps receiving channel events while subscription == null — for a full server round-trip. If channelWritabilityChanged fires in that window, maybeRequestMore() NPEs.

The S3 client sets Expect: 100-continue on every PutObject/UploadPart (StreamingRequestInterceptor), so every async S3 upload takes this path.

Regression Issue

  • Select this option if this issue appears to be a regression.

Expected Behavior

The async client should stream the body and complete the upload. During the Expect: 100-continue wait, channelWritabilityChanged must not dereference a not-yet-assigned subscription; it should no-op until onSubscribe runs, then request demand normally.

Current Behavior

Non-retryable failure of the upload:

  Caused by: java.lang.NullPointerException: Cannot invoke "org.reactivestreams.Subscription.request(long)" because "this.subscription" is null
      at ...netty.internal.nrs.HandlerSubscriber.maybeRequestMore(HandlerSubscriber.java:303)
      at ...netty.internal.nrs.HandlerSubscriber.channelWritabilityChanged(HandlerSubscriber.java:157)
      at io.netty.channel.AbstractChannelHandlerContext.invokeChannelWritabilityChanged(...)
      ...

Wrapped as SdkClientException: Unable to execute HTTP request: ... "this.subscription" is null and classified non-retryable, so the operation fails.

Reproduction Steps

No standalone repro attached yet. Deterministic conditions: S3AsyncClient PutObject/UploadPart (so Expect: 100-continue is set) against a server that delays the 100 Continue, with a channel writability transition forced during the wait (small write-buffer high-water mark / backpressured body). Happy to contribute a minimal repro.

Possible Solution

Null-guard the deref. (Illustrative — not yet compiled or covered by a test.) When subscription == null the method simply returns; demand is requested when onSubscribe later runs (provideSubscriptionmaybeStartmaybeRequestMore):

  private void maybeRequestMore() {
      if (subscription != null
              && outstandingDemand <= demandLowWatermark
              && ctx.channel().isWritable()) {
          long toRequest = demandHighWatermark - outstandingDemand;
          outstandingDemand = demandHighWatermark;
          subscription.request(toRequest);
      }
  }
  

Alternatively (or additionally), gate channelWritabilityChanged on state == RUNNING, mirroring channelActive.

Note: S3Configuration.expectContinueEnabled(false) (newer SDK versions) suppresses the header and hides the symptom, but that's a workaround — the unguarded deref in HandlerSubscriber is the actual defect and stays reachable by any path that defers the body subscription past the current event-loop task.

Additional Information/Context

Root cause (line numbers from tag 2.30.31; master has identical channelWritabilityChanged / maybeRequestMore / channelActive bodies)

  1. HandlerSubscriber#maybeRequestMoresubscription.request(toRequest) guarded only by isWritable(), no null/state check.
  2. HandlerSubscriber#channelWritabilityChanged — calls maybeRequestMore() unguarded (contrast channelActive, which checks state).
  3. HttpStreamsHandler#unbufferedWrite — adds the HandlerSubscriber to the pipeline, then calls subscribeSubscriberToStream(...).
  4. HttpStreamsClientHandler#subscribeSubscriberToStream — when is100ContinueExpected(msg), stashes the subscriber and does not subscribe; the subscribe (→ onSubscribe, which sets subscription) happens later in channelRead() on the 100 Continue.

Since subscription is set only in onSubscribe and never nulled: subscription == nullonSubscribe hasn't run ⇔ (for these requests) still awaiting 100 Continue. Any channelWritabilityChanged in that window hits the unguarded deref.

Why this isn't seen more widely

  • Only PutObject/UploadPart carry Expect: 100-continue, so only uploads defer the subscription; reads never reach this state. (In our workload, 100% of occurrences are UploadPart, none on reads.)
  • It also requires a writability change (channel becoming writable) inside that round-trip window — most plausibly the request-head/TLS write draining back below the low-water mark; we haven't traced the exact per-occurrence trigger. That's rare on an idle connection but frequent under high-concurrency uploads with backpressured sockets, so low-volume callers effectively never hit it.
  • The same defect was reported upstream long ago (playframework/netty-reactive-streams#30) and never fixed; the code was vendored here and is still unguarded on master.

AWS Java SDK version used

2.30.31 (also on master); client: netty-nio-client; Netty 4.1.135.Final

JDK version used

21

Operating System and version

Linux


Additional context (caller-side use case, domain load, upload code path, observed signature): see comment below (#7271 (comment))

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThis issue is a bug.needs-triageThis issue or PR still needs to be triaged.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions