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.swift — Error.toCpp() |
String(describing: error) |
| 3 |
RuntimeError.hpp — makeException() |
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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 Owner
Current Issue Owner: @mallenexpensify
Sentry
https://expensify.sentry.io/issues/APP-ECR
Impact (snapshot at filing)
hybrid_appbuildsonunhandledrejection(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.fetchwith 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:HybridNitroFetchClient.swift:75NSError, domain + codeRuntimeError.swift—Error.toCpp()String(describing: error)RuntimeError.hpp—makeException()std::runtime_error(message)JSIConverter+Promise.hpp:89JSIConverter+Exception.hpp:38-47catch (const std::exception&)misses,catch (...)winsUnknown St13runtime_error error.Stage 5 is the defect:
Note the contradiction:
catch (...)reports the type asSt13runtime_error, which isstd::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_infoby pointer, so two copies of the same type never match.Why the name is mangled
TypeInfo::demangleName()only demangles whenNITRO_DEBUGis defined, and no podspec in this project ever defines it — not in Debug, not in Release. So the string readsSt13runtime_errorrather thanstd::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.tsleaves the platformfetchin 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.tsclassifies errors by message text, and an unrecognized message falls through every branch. Three things follow: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.FailureTracking.tscounts onlyFAILED_TO_FETCHandEXPENSIFY_SERVICE_INTERRUPTEDas connectivity failures, so the tracker stays at zero and the queue keeps running as if the network were fine.SequentialQueueburns all ten retries, gives up, appliesfailureDataand 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:
Your_ride_with_Michelle_on_August_13.pdfthat burned every retry at 05:02 succeeded cleanly 16 hours later.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.processHTTPRequestthat rethrows them under a dedicated error name while preserving the original text intitle:NATIVE_FETCH_FAILEDis then added to the three places that classify connectivity failures —Logging,FailureTrackingandReauthentication. 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_FETCHdeliberately:Failed to fetchis 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
NSURLErrorwhose code we have never once been able to read —-1005(connection lost),-1001(timeout) and-1009(offline) each imply a different remedy.NSURLErroroff the Xcode console.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, includingUnknown errorandUnknown host error occurred while resolving the address.; non-Error values rejected.tests/unit/HttpUtilsTest.ts— the mapping and its preservedtitle; abort and already-recognized network errors left untouched; the failure now incrementsFailureTracker; three failures across the window fire the offline hard stop;Log.alertis 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 Owner
Current Issue Owner: @mallenexpensify