Skip to content

[Due for payment 2026-09-10] [Sentry: APP-ECR] iOS RequestMoney fails with "Unknown St13runtime_error error." — NitroFetch discards the real error message #98988

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-ECR

Impact (snapshot at filing)

  • Users (last 7d): 14
  • Users (total since first seen): 59
  • Events (last 7d): 1,838
  • Events (total): 4,418
  • First seen: 2026-06-02
  • Last seen: 2026-08-19 (ongoing)
  • Platform: iOS — 100% of events. Zero Android, zero web.
  • App version(s): 9.4.46-10 through 9.4.54-1, App Store hybrid_app builds
  • Mechanism: onunhandledrejection (non-fatal, no stack trace)

What the error actually is

Unknown St13runtime_error error. is not a server rejection and not a PDF problem. It is the name of a C++ type, printed because NitroFetch discards the real error message on its way to JavaScript.

The App replaces globalThis.fetch with NitroFetch on native (src/polyfills/NitroFetch.ts), so every API call travels this chain. The message is intact for four stages and gone for the last two:

# Where Message
1 URLSession → HybridNitroFetchClient.swift:75 real NSError, domain + code
2 RuntimeError.swiftError.toCpp() String(describing: error)
3 RuntimeError.hppmakeException() std::runtime_error(message)
4 JSIConverter+Promise.hpp:89 handed to the exception converter
5 JSIConverter+Exception.hpp:38-47 catch (const std::exception&) misses, catch (...) wins
6 JavaScript Unknown St13runtime_error error.

Stage 5 is the defect:

// react-native-nitro-modules/cpp/jsi/JSIConverter+Exception.hpp
try {
  std::rethrow_exception(exception);
} catch (const std::exception& e) {
  jsi::JSError error(runtime, e.what());     // never reached
  return jsi::Value(runtime, error.value());
} catch (...) {
  std::string errorName = TypeInfo::getCurrentExceptionName();
  jsi::JSError error(runtime, "Unknown " + errorName + " error.");
  return jsi::Value(runtime, error.value());
}

Note the contradiction: catch (...) reports the type as St13runtime_error, which is std::runtime_error — exactly the type the branch above it catches. A type that fails to match its own catch clause means the throw site and the catch site disagree about type identity.

Leading hypothesis (unproven): duplicated RTTI. The exception is constructed inside the Swift module, which compiles Nitro's inline C++ headers against the Swift toolchain's libc++, while the catch site compiles against the Xcode SDK's. On Apple platforms libc++ compares type_info by pointer, so two copies of the same type never match.

Why the name is mangled

TypeInfo::demangleName() only demangles when NITRO_DEBUG is defined, and no podspec in this project ever defines it — not in Debug, not in Release. So the string reads St13runtime_error rather than std::runtime_error. That is why it looked like it came from Auth or Bedrock, which are also C++, and why it went eleven weeks without a root cause.

Why iOS only

Web never loads NitroFetch — src/polyfills/NitroFetch.web.ts leaves the platform fetch in place. Android uses Cronet through a Kotlin implementation, and Kotlin exceptions cross into C++ over JNI rather than the Swift/C++ bridge, so they never reach the stage 5 converter. The Sentry data agrees: 100% iOS across 4,418 events.

What it breaks downstream

Logging.ts classifies errors by message text, and an unrecognized message falls through every branch. Three things follow:

  1. Alert flood. Each failure fires ENSURE_BUGBOT unknown API request error — roughly 32,000 log entries a week — and lands in Sentry as a stack-less rejection nobody can triage.
  2. The app never goes offline. FailureTracking.ts counts only FAILED_TO_FETCH and EXPENSIFY_SERVICE_INTERRUPTED as connectivity failures, so the tracker stays at zero and the queue keeps running as if the network were fine.
  3. The expense is discarded. Because the queue never pauses, SequentialQueue burns all ten retries, gives up, applies failureData and drops the request. The user lands on a Not Found page — and per the logs, Auth had often already created the transaction.

Two failures, only one of them intermittent

This is what made the bug so hard to reproduce:

  • The upload failure is intermittent. It tracks the network at the moment of a large multipart POST, not the file. The same Your_ride_with_Michelle_on_August_13.pdf that burned every retry at 05:02 succeeded cleanly 16 hours later.
  • The message loss is not intermittent at all. Every NitroFetch rejection is opaque, 100% of the time.

Receipt state (SCANREADY) and MIME (application/pdf) are identical across failures and successes. PDFs dominate the reports because they are the largest receipts, not because the parser dislikes them.

Proposed fix — part 1 (damage control)

A matcher for Nitro's two opaque message shapes, plus a normalization step in HttpUtils.processHTTPRequest that rethrows them under a dedicated error name while preserving the original text in title:

// src/libs/HttpUtils.ts
return fetch(url, fetchParams)
    .catch((error: unknown) => {
        if (isOpaqueNativeFetchError(error)) {
            throw new HttpsError({
                message: CONST.ERROR.NATIVE_FETCH_FAILED,
                title: error.message,
            });
        }
        throw error;
    })

NATIVE_FETCH_FAILED is then added to the three places that classify connectivity failures — Logging, FailureTracking and Reauthentication. That feeds the failure into machinery that already exists: the tracker counts it, NetworkState flips the app offline after three failures in ten seconds, the queue pauses, and the persisted request survives to be retried later.

A dedicated name rather than reusing FAILED_TO_FETCH deliberately: Failed to fetch is already a bucket of 17,000+ events across at least ten Sentry issues, much of it web ad-network noise. Folding APP-ECR into it would destroy the one clean signal we have for measuring this bug.

This does not fix the upload. The request still fails and the user still cannot create that expense on that attempt. What changes is the damage: the expense is no longer thrown away, the app correctly reports itself offline, and the alert channel stops drowning.

Proposed fix — part 2 (the actual upload bug)

Blocked on evidence that has never been logged. The underlying failure is an NSURLError whose code we have never once been able to read — -1005 (connection lost), -1001 (timeout) and -1009 (offline) each imply a different remedy.

  1. Reproduce on an iOS simulator: a large PDF under Network Link Conditioner with loss injected mid-upload, reading the actual NSURLError off the Xcode console.
  2. Confirm or refute the RTTI hypothesis at the repro, then patch Nitro so the message survives. That restores diagnosis for every Nitro call, not just this one.
  3. Fix the upload — retry semantics, chunking or timeout, depending on step 1.

Tests

Nine assertions across two files, written before the fix and confirmed failing against it:

  • tests/unit/isOpaqueNativeFetchErrorTest.ts — three Nitro message shapes recognized; five lookalikes rejected, including Unknown error and Unknown host error occurred while resolving the address.; non-Error values rejected.
  • tests/unit/HttpUtilsTest.ts — the mapping and its preserved title; abort and already-recognized network errors left untouched; the failure now increments FailureTracker; three failures across the window fire the offline hard stop; Log.alert is no longer called.

Note when this ships

APP-ECR will stop receiving events and a new Sentry group will appear under NitroFetch request failed. The bug is renamed, not fixed — part 2 above is what actually fixes it.

Related

Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2InternalRequires API changes or must be handled by Expensify staff

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions