Skip to content

fix(core): bound AxiosError.toJSON by labeling agents instead of serializing them - #11362

Open
princejain756 wants to merge 7 commits into
axios:v1.xfrom
princejain756:fix-axioserror-tojson-agent-oom
Open

princejain756 wants to merge 7 commits into
axios:v1.xfrom
princejain756:fix-axioserror-tojson-agent-oom

Conversation

@princejain756

@princejain756 princejain756 commented Oct 22, 2026 •

Copy link
Copy Markdown

Summary

Bounds AxiosError.toJSON() so logging a failed request cannot balloon into the live Node agent socket pool and OOM the process. Fixes #11145.

httpAgent/httpsAgent pin live socket and request maps whose shared subgraphs get re-serialized once per reference path. A 30-entry keep-alive pool reproduced the failure locally: toJSON() exhausted a 512 MB heap inside toJSONObject.

Implementation

toJSON() snapshots config into a thawed, prototype-preserving clone before either serializer (toJSONObject or redactConfig) walks it:

  • Class instances stored under httpAgent/httpsAgent keys are replaced by a '[Name]' label at any depth; plain values (functions, strings, booleans) pass through.
  • Every enumerable string-keyed property is kept; accessors evaluate once against the live target so getters observe the real agent.
  • Frozen configs serialize without mutation or Proxy invariant issues; circular references memoize through a WeakMap so the existing per-path visited check keeps working.
  • Builtin instances (Buffer, streams, Fetch types, forms, typed arrays, native classes) and toJSON-bearing objects pass through untouched since their internals cannot survive a clone.

Earlier designs

Round 1 used a shallow config copy; a config.self = config circle reached the unsanitized original and the copy lost inherited toJSON hooks. Round 2 used a lazy proxy chain; locked slots violate the Proxy invariant and bound reads through forEach/class-held references escaped the chain. The snapshot clone covers both classes of failure.

Validation

  • npm run test:vitest:unit: 1710/1711 (one pre-existing env failure in fetch.test.js unrelated to this change)
  • npx eslint lib/core/AxiosError.js: clean
  • Regression tests cover: pooled-agent OOM, third-party agent names, non-agent values, inherited accessors, circular configs, frozen configs (with and without agents), getter-derived values, array/class-held back-references, redaction + agent combined.

@princejain756

Copy link
Copy Markdown
Author

@jasonsaayman I don't have permission to set labels. Could a maintainer apply ai-assisted here? Third attempt at #11145 after #11146 and #11148 stalled, narrower than both: agents get a [ClassName] label at the toJSON boundary so neither serializer walks the live socket pool, and utils.toJSONObject itself is untouched.

Node http/https agents pin live socket, request, and response maps whose
shared subgraphs were re-serialized once per reference path, so
error.toJSON() could grow without bound and OOM the process for
keep-alive pools (axios#11145).

Swap class-instance values under httpAgent/httpsAgent for a bounded
[ClassName] label before either the default or redact serializer walks
the config. Only own properties are read, so inherited accessors are
never invoked, non-agent values pass through, and the original config
is not mutated.
@princejain756
princejain756 force-pushed the fix-axioserror-tojson-agent-oom branch from 81389d0 to 5898063 Compare October 22, 2026 11:36
@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] Safe to merge based on the reviewed changes. The previously reported array-getter issue is addressed, and no blocking concerns remain.

Summary

Bounds agent data in AxiosError.toJSON() with class-name labels without changing the caller’s config.

  • Error snapshots label HTTP agents instead of expanding their live socket pools.

Reviews (7) · Last reviewed commit: "fix: skip named array properties in toJS..." · Reviewed by Greptile

Comment thread lib/core/AxiosError.js Outdated
Comment thread lib/core/AxiosError.js Outdated
The shallow-copy sanitize left a hole: a config.self = config
back-reference (interceptors can create it) still pointed at the
original object, so the serializer walked the unsanitized agent pool
through that path. Spreading also dropped inherited and non-enumerable
toJSON hooks whenever an agent was present, changing snapshot output.

Define the [ClassName] label on the config's own agent slots and
restore them in a finally block. Every path into the config sees the
label, toJSON-bearing sources keep their identity semantics, and
accessor or non-configurable slots are never touched.
Comment thread lib/core/AxiosError.js Outdated
Comment thread lib/core/AxiosError.js Outdated
…tay bounded

The in-place swap still leaked live agents in two cases: a frozen config
rejects defineProperty so the slot kept the real agent, and a getter on
the config observed the '[Agent]' label mid-serialization so derived
values disappeared or crashed. Substitution now happens on read through
a memoized proxy chain: no mutation at all, circular references resolve
to the same proxy at any depth, getters and toJSON hooks run against the
real config, and a level with a non-configurable non-writable agent slot
gets a one-level descriptor clone so the label can be baked in without
tripping proxy invariants.
@princejain756

Copy link
Copy Markdown
Author

Addressed in 633a977 by replacing the in-place swap entirely. Serialization now reads through a memoized proxy chain that substitutes on read, so nothing is ever written to the caller's config:

  • Frozen configs: Object.defineProperty is no longer attempted. A get trap cannot report a different value for a non-configurable non-writable slot either (the runtime enforces it), so a level holding a frozen agent slot gets a one-level Object.create descriptor clone with the label baked in. Verified: a frozen config with a populated keep-alive pool serializes to 415 bytes and stays frozen.
  • Getter observation: Reflect.get(t, key, t) runs accessors with the raw target as receiver, so get mode() { return this.httpAgent.keepAlive ? 'ka' : 'x' } resolves to 'ka' in the snapshot while httpAgent itself still serializes as '[Agent]'. An own get httpAgent() accessor also labels correctly now (the swap skipped non-data slots outright).
  • Circular at any depth: config.inner.cfg = config resolves back through the same memoized proxy, so the visited check still drops it rather than walking the raw config.
  • toJSON hooks: functions are bound to the raw target, so inherited and non-enumerable hooks keep their exact this and output.

Validation: 66/66 AxiosError tests, 1710/1711 unit suite (the one failure is the pre-existing Symbol.iterator environment issue), eslint clean.

Comment thread lib/core/AxiosError.js Outdated
Comment thread lib/core/AxiosError.js Outdated
Comment thread lib/core/AxiosError.js Outdated
The proxy chain hit two structural limits. A proxy get trap cannot
report a substituted value for a non-writable non-configurable
property, so any frozen object slot (agent or not) threw at serialize
time. And bound reads through forEach or class-held references left
the proxy chain and reached the raw config, re-exposing the live
agent pool.

Materialize the bounded view instead: clone each reached object level
with its prototype, substitute '[Name]' labels for class instances
under httpAgent/httpsAgent keys, evaluate accessors once against the
live target so getters see the real agent, and pass through builtins
and toJSON-bearing objects that need their internal slots. No proxies,
no mutation, no bind.
@princejain756

Copy link
Copy Markdown
Author

Reworked again on a73f1b4. The proxy chain had a structural ceiling: a get trap cannot substitute a locked slot (frozen objects threw even without an agent), and bound reads through forEach or class-held references left the chain and reached the raw config. There is no bind call left either.

The new pass materializes a thawed, prototype-preserving snapshot clone instead. Locked slots no longer matter since nothing is proxied; accessors evaluate once on the live target so getters still observe the real agent; builtins and toJSON-bearing objects pass through so internal slots stay intact; back-references through arrays or class instances memoize to the same clone, so the visited set drops them before the live agent is reached.

New regression tests cover the three findings: frozen config with no agent, config.items = [config], and a class instance holding the config.

Comment thread lib/core/AxiosError.js Outdated
Comment thread lib/core/AxiosError.js Outdated
Comment thread lib/core/AxiosError.js
…s in toJSON snapshot

toJSON-bearing objects now pass through so custom hooks run against the
real config instead of a clone missing agent internals and symbol state.
Only enumerable slots are materialized so hidden accessors are never
invoked, and keys matched by the redact list skip evaluation entirely
since redactConfig replaces them wholesale. Set/Map/Date/RegExp/
Promise/Error subclasses join the pass-through list so their internal
slots survive; Array and Object constructors are excluded from the
native check so plain values still snapshot.
@princejain756

Copy link
Copy Markdown
Author

Round addressed on c3ba282.

  1. Custom hooks: toJSON-bearing objects now pass the snapshot unchanged, so config.toJSON and inherited hooks run against the real config and keep access to the live agent. A plain config carrying an own toJSON goes raw for the same reason.
  2. Hidden and redacted getters: only enumerable slots are materialized now, matching what Object.entries and forEach would walk, so hidden accessors are never invoked. Keys matched by the redact list are also never evaluated or descended into since redactConfig replaces them wholesale; the snapshot receives redactKeys up front.
  3. Builtin subclasses: instanceof checks for Set, Map, WeakSet, WeakMap, Date, RegExp, Promise, and Error join the pass-through list, so class Tags extends Set keeps its internal slots. Array and plain-object constructors are excluded from the native check so their values still snapshot.

New tests cover each finding: hook receiver fidelity, hidden accessor skip, redact-matched accessor skip, and the Set subclass. 72/72 AxiosError tests, 1716/1717 unit suite (same pre-existing env failure), eslint clean.

Comment thread lib/core/AxiosError.js Outdated
@princejain756

Copy link
Copy Markdown
Author

Fixed on 84503ab. The early stop now only applies to keys on non-array objects, matching redactConfig, which never treats array indices as redactable. A numeric redact entry like '0' no longer leaves a raw element unbounded; the items[0].httpAgent case serializes to '[Agent]'. Also folded in a tightening from self review: arrays stored under httpAgent/httpsAgent no longer qualify as labelable agents, so structural values such as httpAgent: [] stay untouched. Regression test covers the indexed redact path; 73/73 focused tests pass.

Comment thread lib/core/AxiosError.js
@princejain756

Copy link
Copy Markdown
Author

Fixed on 39c20bb. Named properties on array targets are now skipped before any value is read, since forEach-based serializers only visit index elements. A throwing secret getter on config.items no longer fires, while elements still copy and get bounded. Regression test covers it; 74/74 focused tests pass.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AxiosError.prototype.toJSON() can OOM when config includes an httpAgent/httpsAgent

1 participant