Repository navigation
ApiError crashes on network errors (ECONNRESET) — getters deref this.resp unguarded → 'reading status' #109
Description
Summary
A request that fails with a network error (e.g. ECONNRESET) — where axios rejects with no response — surfaces to callers as:
Cannot read properties of undefined (reading 'status')
instead of the real error. This masks the underlying network failure and makes diagnosis misleading.
Where it's been hit
The FAMIS connector's nightly asset cache (getAllAssetsBatch) has hit this on two consecutive fleet runs (2026-07-09 and 2026-07-10), same tenants each time (udst, udstsso — large asset sets, ~241K), when a mid-stream page fetch is reset. The loader fails with the cryptic reading 'status' message rather than a clear ECONNRESET, so the tenant's build fails and the real cause is hidden. Observed with facility360@1.2.0.
Root cause — ApiError dereferences this.resp unguarded (errors.ts)
export class ApiError extends Error {
private resp: AxiosResponse;
constructor(resp: AxiosResponse) { super(); this.resp = resp; ... }
get isAuthorizationError(): boolean {
return this.resp.status == 401 || this.resp.status === 403; // ← this.resp may be undefined
}
get message(): string {
try { if (this.resp.data.Message) return this.resp.data.Message; } catch (e) {}
return this.resp.statusText; // ← unguarded
}
toString() { ... this.resp.request ... this.resp.config.baseURL ... } // ← unguarded
}On a network error there is no AxiosResponse. When an ApiError is constructed/propagated without a response, reading any getter (isAuthorizationError, message, toString) throws Cannot read properties of undefined (reading 'status'/'statusText'/...). That TypeError then becomes the surfaced error, hiding the ECONNRESET.
(The connector worked around this for the auth path only — reading resp?.status defensively in its #421 change — but the root fix belongs here so all call paths, including getAllAssetsBatch, are covered and the workaround can be retired.)
Proposed fix
1. Make ApiError null-safe (defensive — covers every call path):
get isAuthorizationError(): boolean {
return this.resp?.status === 401 || this.resp?.status === 403;
}
get message(): string {
if (!this.resp) {
return (this as Error).cause instanceof Error
? (this as any).cause.message
: 'FAMIS API error (no HTTP response — network error?)';
}
try { if (this.resp.data?.Message) return this.resp.data.Message; } catch {}
return this.resp.statusText ?? 'FAMIS API error';
}
toString() {
const message = this.message;
if (this.isAuthorizationError && !message) return 'Authorization Failed';
if (this.resp?.request) {
return `${message}\nMETHOD: ${this.resp.request.method}\nHOST: ${this.resp.config?.baseURL}\nURL: ${this.resp.request.path}\n`;
}
return message;
}2. Don't turn a response-less error into an ApiError (the real root): at the point where an axios error is converted, only build ApiError when err.response exists; otherwise rethrow the original AxiosError (it already carries code: 'ECONNRESET' and a meaningful message) or wrap it in a distinct NetworkError. Optionally preserve the cause: super(); (this as any).cause = originalError;.
3. Regression test (mirrors the connector's cache.loader.service.spec regression):
it('does not throw when constructed without a response (network error)', () => {
const e = new ApiError(undefined as any);
expect(() => e.isAuthorizationError).not.toThrow();
expect(() => e.message).not.toThrow();
expect(() => e.toString()).not.toThrow();
expect(e.isAuthorizationError).toBe(false);
});Impact / priority
Medium. Not data-corrupting (the connector preserves the prior cache and retries), but it (a) fails the affected loaders each run for ≥2 tenants, and (b) hides the true cause (ECONNRESET) behind a phantom reading 'status' TypeError, costing debugging time. Recurs every fleet run.
References
- Connector run reports:
docs/operations/famis-full-run-results-2026-07-09.md,-2026-07-10.md(facility360_r7). - Connector workaround for the auth path: PR #421 (
isAuthRejection/safeErrMsg).
Activity
alphahlee commentedon Jul 10, 2026
Update after reading
getAllBatch— exact crash line + a parameterization askTracing the actual code (
famis_client.ts,getAllBatch<T>— the fngetAllAssetsBatchdelegates to) pins this down further.1. Exact crash origin
The parallel-page
.catchpasses a non-null-asserted response intothrowResponseError:On a network reset (pages 2..N of the batch)
e.responseisundefined;e.response!lies to the type system, andthrowResponseErrorthen reads.statusonundefined. Fix: in the.catch, ife.responseis missing, rethrow the originalAxiosError(it carriescode: 'ECONNRESET'+ a real message) or aNetworkError— don't fabricate anApiErrorfromundefined. (The null-safeApiErrorgetters from the original description are still worth doing as defense-in-depth.)2. Page size & concurrency are hardcoded — please make them parameters
getAllBatch(unlikegetAllPaged, which honorscontext.top) ignoresQueryContext.top, so a caller cannot lower the assets page size or fan-out today. The udst/udstssoassetsfailures (~241K assets, 6-way$expand) are almost certainly the heavy per-page query tripping a backend reset — see the connector-side finding: the legacyaccruent-tasksnightly runs all 9 reference loaders for udst/udstsso with zero failures; only the connector's newassetsfetch resets. So the resolution is to dial the assets fetch down for the big tenants, and that needs to be tunable from the caller.Requested change — make both caller-settable, defaults unchanged (backward-compatible):
Why parameters (vs just lowering the defaults): the right values are empirical — we'll need to try e.g.
pageSize500 → 250 andmaxConcurrent4 → 2 against the large tenants. With these exposed, the connector tunes via its own env/config and iterates without an SDK release per attempt, and can apply small pages/low concurrency only to the few huge-asset tenants while leaving everyone else on the fast path. Keeping1000/4as defaults means no impact to other SDK consumers.The two levers do different work, so both matter:
pageSize↓ shrinks each request's$expandpayload (likely the bigger win against the reset);maxConcurrent↓ reduces simultaneous pressure on the FAMIS backend.Suggested scope for this issue
e.response!crash (rethrow the real network error) — correctness.pageSize+maxConcurrentparams togetAllBatch/getAllAssetsBatch, defaults1000/4.retries: 2, retryDelay: () => 2).Items 1+2 together let us actually resolve the udst/udstsso net-failures by tuning the fetch from the connector.
alphahlee commentedon Jul 23, 2026
Fixed in 1.3.0 (PR #110): the response-less network-error path no longer builds an ApiError from an undefined
response—isAuthorizationError/toStringare null-safe and the real error (e.g. ECONNRESET) is rethrown so the true cause surfaces. Published and running in prod (via 1.4.0). Closing.