Skip to content

Fix Android file uploads failing when OkHttp retries the request - #58817

Open
sepeterson wants to merge 2 commits into
react:mainfrom
sepeterson:fix-android-multipart-retry
Open

sepeterson wants to merge 2 commits into
react:mainfrom
sepeterson:fix-android-multipart-retry

Conversation

@sepeterson

Copy link
Copy Markdown

Summary:

On Android, a fetch() or XMLHttpRequest file upload fails with TypeError: Network request failed (native error IOException: Stream Closed) when OkHttp retries the request, for example after a server or proxy closes a pooled keep-alive connection just as the upload is sent. This matches #32966, where the first upload fails and a second attempt succeeds.

NetworkingModule builds the body for a uri request body or a FormData file part from a single InputStream, which writeTo() reads to the end and closes. With retryOnConnectionFailure (OkHttp's default), OkHttp retries on a new connection and calls writeTo() again, which hits the closed stream. The body does report isOneShot(), but OkHttp only checks the top-level body, and neither MultipartBody nor ProgressRequestBody passes it up.

This adds an internal UriRequestBody that opens a new stream for each write, and RequestBodyUtil.create(context, mediaType, uri), which returns one for every URI NetworkingModule accepts. For content:// and file:// URIs the file is still opened up front, so a missing file fails the request before it is sent, and the first write reuses that stream, so a request that isn't retried opens the file once, as before. The DevTools request preview treats UriRequestBody like a one-shot body, so it never reads the file.

There's no public API change: UriRequestBody and RequestBodyUtil are internal.

At iNaturalist, we had some cases where our prod API was closing idle keep-alive connections after a couple of seconds which resulted in elevated photo upload failures on Android only.

Changelog:

[ANDROID] [FIXED] - File uploads no longer fail with "Network request failed" when OkHttp retries the request on a stale connection

Test Plan:

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.modules.network.*"

96 tests, 0 failures. yarn format-check-kotlin passes. New tests write each URI body twice, as a retry would, and cover reopening on retry, IOException when the file can't be reopened, and DevTools never opening the body.

End to end: RNTester debug build on a Pixel 9 emulator (API 35). A small Node server (below) answers the first request on each connection and keeps it alive; on the second request, it reads the whole request and then destroys the socket without responding. The JS sends a warm-up GET so OkHttp pools the connection, then a FormData POST with a 256 KB file that reuses it. Same app, emulator and file in both runs; only the files in this PR differ.

On main, the app gets upload POST failed after 71 ms: Stream Closed. OkHttp opens a new connection for the retry but closes it without sending anything:

19:35:10.784 conn #5 opened
19:35:10.786 conn #5 request #1: GET /warmup - received 0 bytes - 200
19:35:10.872 conn #5 request #2: POST /upload - received 262563 bytes (Content-Length 262563), file part 262144 bytes - destroying the connection without a response
19:35:10.872 conn #5 closed
19:35:10.876 conn #6 opened
19:35:10.877 conn #6 closed

With this PR, the retry sends the full body and gets 200 {"connection":8,"bodyBytes":262563,"fileBytes":262144}:

19:36:04.476 conn #7 opened
19:36:04.478 conn #7 request #1: GET /warmup - received 0 bytes - 200
19:36:04.540 conn #7 request #2: POST /upload - received 262563 bytes (Content-Length 262563), file part 262144 bytes - destroying the connection without a response
19:36:04.540 conn #7 closed
19:36:04.544 conn #8 opened
19:36:04.561 conn #8 request #1: POST /upload - received 262563 bytes (Content-Length 262563), file part 262144 bytes - 200

Not tested: a physical device, and http(s):// URIs (downloaded to a temp file before uploading), which have no end-to-end or unit test coverage.

Repro: JS (RNTester Playground)
const SERVER = 'http://10.0.2.2:8099';

// Opens a connection that OkHttp pools
await fetch(`${SERVER}/warmup`);

// Reuses the pooled connection, which the server kills after reading the request
const form = new FormData();
form.append('description', 'stale connection repro');
form.append('file', {
  uri: 'file:///data/data/com.facebook.react.uiapp/files/upload-test.bin',
  name: 'upload-test.bin',
  type: 'application/octet-stream',
});
const xhr = new XMLHttpRequest();
// On failure, RN puts the native error message in responseText
xhr.onload = () => console.log(xhr.status, xhr.responseText);
xhr.onerror = () => console.log('failed:', xhr.responseText);
xhr.open('POST', `${SERVER}/upload`);
xhr.send(form);

The test file was placed with adb shell run-as com.facebook.react.uiapp.

Repro: server (node stale-connection-server.js 8099)
// Repro server for OkHttp's retry on a stale pooled connection.
//
// Each TCP connection answers its first request normally and stays open (keep-alive), so OkHttp
// pools it. On the second request on that connection, the server reads the whole request and then
// destroys the socket without responding, the way a server or proxy that dropped an idle
// keep-alive connection looks to the client. OkHttp sees "unexpected end of stream" after the
// request was sent, and only retries on a new connection if the request body is not one-shot.
//
// Usage: node stale-connection-server.js [port]

const http = require('http');

const port = Number(process.argv[2] ?? 8099);
let nextConnectionId = 1;

function log(message) {
  console.log(`${new Date().toISOString().slice(11, 23)} ${message}`);
}

/** Returns the size of the multipart part with a filename, or null if there isn't one. */
function filePartSize(body, contentType) {
  const match = /boundary=("?)([^";]+)\1/.exec(contentType ?? '');
  if (match == null) {
    return null;
  }
  const delimiter = Buffer.from(`--${match[2]}`);
  let start = body.indexOf(delimiter);
  while (start !== -1) {
    const next = body.indexOf(delimiter, start + delimiter.length);
    if (next === -1) {
      break;
    }
    const part = body.subarray(start + delimiter.length, next);
    const headerEnd = part.indexOf('\r\n\r\n');
    if (headerEnd !== -1 && part.subarray(0, headerEnd).includes('filename=')) {
      // Content runs from after the headers to the CRLF before the next delimiter
      return part.length - (headerEnd + 4) - 2;
    }
    start = next;
  }
  return null;
}

const server = http.createServer((req, res) => {
  const socket = req.socket;
  socket.requestCount = (socket.requestCount ?? 0) + 1;
  const label = `conn #${socket.connectionId} request #${socket.requestCount}: ${req.method} ${req.url}`;

  const chunks = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    const body = Buffer.concat(chunks);
    const declared = req.headers['content-length'];
    const fileBytes = filePartSize(body, req.headers['content-type']);
    const received =
      `received ${body.length} bytes` +
      (declared != null ? ` (Content-Length ${declared})` : '') +
      (fileBytes != null ? `, file part ${fileBytes} bytes` : '');

    if (socket.requestCount >= 2) {
      log(`${label} - ${received} - destroying the connection without a response`);
      socket.destroy();
      return;
    }

    log(`${label} - ${received} - 200`);
    res.writeHead(200, {'Content-Type': 'application/json'});
    res.end(
      JSON.stringify({
        connection: socket.connectionId,
        bodyBytes: body.length,
        fileBytes,
      }),
    );
  });
});

server.on('connection', socket => {
  socket.connectionId = nextConnectionId++;
  log(`conn #${socket.connectionId} opened`);
  socket.on('close', () => log(`conn #${socket.connectionId} closed`));
});

// Keep idle connections open long enough for the client to reuse them
server.keepAliveTimeout = 60_000;

server.listen(port, () => log(`listening on port ${port}`));

🤖 Generated with Claude Code
and reviewed/revised by Seth Peterson

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 2, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 2, 2026

This branch has not been deployed

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant