TRADEIT-TRADEBOT-SERVER · RESILIENCE REVIEW · 2026-06-18
Every Steam web call in src/index.ts, what each does when our cookies expire,
and how to detect · re-login · retry without failing. Master 41b1e33.
Every claim anchored to source.
The one-line finding
Of ~15 web-session call sites, only one
(community.getConfirmations, every 11s) detects an expired session and re-logs in.
Both libraries already emit a sessionExpired event —
we listen to neither.
"Losing our Steam web session" breaks exactly one of the bot's three live connections
The bot holds three independent Steam connections with different lifecycles. Conflating them is the
root of most confusion. Cookie expiry breaks ② only — the GC connection (caskets, float
inspection) keeps working, and the CM socket stays up (which is why client.steamID is a false
"are we healthy?" signal).
graph TB LOGIN["client.logOn / client.webLogOn()
src/index.ts:459-490"] subgraph CM["① CM socket — steam-user (client)"] SID["client.steamID truthy = connected"] end subgraph WEB["② WEB SESSION — cookies (THE THING THAT EXPIRES)"] MGR["manager.* — getOffer / getOffers /
send / getUserDetails / getInventoryContents"] COMM["community.* — getConfirmations /
acceptConfirmationForObject"] AX["axios — partnerinventory / inventory/730
(manual loginCookies + loginSessionID)"] end subgraph GC["③ GC session — globaloffensive (csgo)"] CASKET["inspectItem / casket add / remove / contents"] end LOGIN -->|"loggedOn"| SID SID -->|"webSession event → setCookies()
src/index.ts:556-584"| WEB SID -->|"gamesPlayed([730])"| GC class WEB gap class GC ok class CM hi classDef gap fill:#2a1218,stroke:#f43f5e,stroke-width:2px,color:#fecdd3 classDef ok fill:#0e2a1e,stroke:#10b981,stroke-width:2px,color:#a7f3d0 classDef hi fill:#1e2438,stroke:#a855f7,stroke-width:2px,color:#e9d5ff
Transport = what dies on cookie expiry. WEB breaks · GC/CM survive
| # | Call site | index.ts | Transport | On session loss | Re-login? |
|---|---|---|---|---|---|
| 1 | manager internal poll (10s) | 407 (ctor) | WEB | sentOfferChanged / newOffer / pollData stop firing → trade-state stalls | no listener |
| 2 | offer.send (trade send) | 3419, 1970 | WEB | "Not Logged In" → user sees "Steam is down", offer canceled | no |
| 3 | offer.getUserDetails (escrow/guard gate) | 896, 3143 | WEB | me/them null → trade canceled "failed to get account details"; also false-escrow bug | no |
| 4 | offer.accept (incoming) | 1991 | WEB | retries 3×/10s then drops | blind retry |
| 5 | offer.getExchangeDetails | 1708, 1746, 3644 | WEB | sets retry flag / requeues | no |
| 6 | manager.getOffer ×3 | 1051, 1647, 3635 | WEB | logs, writes errors:{id}, returns | no |
| 7 | manager.getOffers (cancelExpired, 60s) | 3758 | WEB | logs, returns — guarded by client.steamID which is still true! | no |
| 8 | manager.getInventoryContents (refreshInv, 30s) | 2533 | WEB | logs, returns undefined → sinv stops updating | no |
| 9 | community.getConfirmations (11s) | 3547 | WEB | detects "Not Logged In" → doClientLogin() | YES — only one |
| 10 | conf.respond | 3573 | WEB | "Not Logged In" → return (next 11s tick recovers) | indirect |
| 11 | community.acceptConfirmationForObject | 1978, 2005 | WEB | retry 3×/10s or log | no |
| 12 | axios.get .../partnerinventory | 903 | WEB* | catch → caches empty partner inv 10s | silent |
| 13 | axios.get .../inventory/{id}/730 | 2493 | WEB* | returns [] → floats/stickers silently missing | silent |
| 14 | csgo.inspectItem | 642 | GC | survives cookie loss; 10s timeout → {} | n/a |
| 15 | csgo casket: contents / add / remove | 788, 820, 2079, 2230 | GC | survives cookie loss; err → [] / try-catch | n/a |
| — | axios.post(channelUrl) (Slack) | 169 | non-Steam | — | n/a |
* WEB = manual cookie attach via loginCookies / loginSessionID set in the
webSession handler — no library to detect expiry for these; they fail with HTTP 401/403 or
success:false.
#2, #3 — customer gets "Steam is down, try again." Highest visible impact; every send during the recovery window bounces.
#8 — sinv:{botid} goes stale; site shows wrong stock until a session returns.
#13 — items show without float data after a refresh. Silent: only logger.error noise.
#12 — withdrawal / trade-URL flows see nothing to trade. Cached empty for 10s on each failure.
#1 — completed trades don't propagate to the socket-server until the poll resumes.
GC paths (#14, #15) — float inspection & caskets ride the CM socket, not cookies. They break only on a full CM disconnect, which is a separate failure.
Two safety nets — they cover different failures, and neither covers everything
acceptConfirmations() every 11s → community.getConfirmations err
contains "Not Logged In" → doClientLogin().
Proof: src/index.ts:3553-3555
This is the de-facto web-session health check — the only path that detects cookie death. Recovery lag = up to 11s + relogin.
refreshInv() every 30s → if (!client.steamID) for 2 ticks →
doClientLogin().
Proof: src/index.ts:2022-2028
Catches socket death only. Does nothing for cookie expiry, because
client.steamID stays truthy while cookies are dead.
graph LR EXP["Cookies expire
(server-side or ~24h)"] GETCONF["getConfirmations tick
every 11s"] REST["All other 13 web calls
(send, getOffers, axios, ...)"] DETECT["err includes
'Not Logged In'?"] RELOGIN["doClientLogin()
isTryingToLogin + 30s guard"] WEBLOGON["client.webLogOn()
steam-user 07-web.js:10"] WS["webSession event
→ setCookies on manager+community
+ refresh loginCookies"] FAILUSER["Fail now:
'Steam is down' to customer /
silent empty inventory"] EXP --> GETCONF EXP --> REST GETCONF --> DETECT DETECT -->|yes| RELOGIN RELOGIN --> WEBLOGON --> WS REST -->|no recovery hook| FAILUSER FAILUSER -.->|waits for next 11s tick| GETCONF class FAILUSER gap class REST gap class GETCONF ok class WS ok class RELOGIN hi classDef gap fill:#2a1218,stroke:#f43f5e,stroke-width:2px,color:#fecdd3 classDef ok fill:#0e2a1e,stroke:#10b981,stroke-width:2px,color:#a7f3d0 classDef hi fill:#1e2438,stroke:#a855f7,stroke-width:2px,color:#e9d5ff
Verified against the exact installed library versions, not docs alone
Installed: steam-user 5.3.0 · steam-tradeoffer-manager 2.13.0 · steamcommunity 3.50.0 · globaloffensive 3.3.0 · axios 1.15.1
sessionExpired event exists — and we don't listen to itsteamcommunity components/http.js:96-97 — _notifySessionExpired() runs
this.emit('sessionExpired', err); _checkHttpError (:107, :140)
mints new Error("Not Logged In") and calls _notifySessionExpired on the same 401/403.
steam-tradeoffer-manager lib/index.js:653-655 —
_notifySessionExpired does this.emit('sessionExpired', err) and
this._community._notifySessionExpired(err).
steam-tradeoffer-manager lib/classes/TradeOffer.js:413 / 510 / 585 — on HTTP 401/403 the
manager calls _notifySessionExpired(...) before calling back with "Not Logged In".
So offer.send / accept failures already fire the event.
Our code: zero occurrences of sessionExpired in src/index.ts
— confirmed by grep. Every web-loss path we currently string-match already emits a structured event we ignore.
webLogOn() is the documented recovery primitivesteam-user README:
"you can call webLogOn() to create a new session if your old one expires or becomes invalid.
Listen for the webSession event to get your cookies."
steam-user components/07-web.js:10-59 — on success emits webSession
(line 55); on failure it self-retries with exponential backoff 1s→60s (line 59). Requires client.steamID
+ an access token. This is exactly what doClientLogin() already calls on the
client.steamID branch (src/index.ts:467-470).
"Emitted when an HTTP request is made which requires a login, and Steam redirects us to the login page (i.e. we aren't logged in). You should re-login when you get this event. Note that this will be emitted continuously until you log back in. This event being emitted doesn't stop the module from attempting further requests (as a result of method calls, timers, etc) so you should ensure that you limit your logins."
— node-steamcommunity wiki, sessionExpired event (v3.19.0+). The same author warns on
acceptConfirmationForObject: use it "as needed … so you aren't sending unnecessary requests to Steam,
and getting yourself rate-limited." Conclusion: wiring sessionExpired straight to a re-login is
unsafe — it must be debounced and single-flighted (see "Rate-limit-safe recovery" below).
Recovery: src/index.ts:3553-3555 — getConfirmations "Not Logged In" → doClientLogin().
False guard: src/index.ts:3756 (cancelExpiredOffers) and :2022
(refreshInv) gate on client.steamID, which stays truthy while cookies are dead — so these
run, hit the web, and fail silently.
Poll cadence: src/index.ts:411 sets pollInterval: 10000 (library default is 30000,
lib/index.js:96).
Ranked. #1 + #2 together deliver "pause ~2s and retry" instead of "fail to the customer"
sessionExpired — the idiomatic hook (PROVEN)One central handler replaces all the scattered indexOf("Not Logged In") matching and
detects expiry proactively (the 10s poller hits it), not reactively on the next confirmation.
community.on('sessionExpired', () => doClientLogin()) // community re-issues cookies
manager.on('sessionExpired', () => doClientLogin()) // manager re-emits on its internal session
Caveat — must be debounced.
doClientLogin's 30s rate-limit only guards the full-logon path; the webLogOn()
branch is gated only by isTryingToLogin (which resets instantly). Since sessionExpired
fires continuously, an undebounced listener is a cookie-request storm. See the guardrails below.
withWebSession retry wrapperOn a session error: trigger doClientLogin(), await the next
webSession (with timeout), retry once. Apply to the calls that currently hard-fail:
offer.send (#2), getUserDetails (#3), and the two raw axios calls (#12, #13). Turns
"trade failed, tell the user" into "trade paused ~2s, then sent."
client.steamID guards with a real session flagclient.steamID is not a web-session check. Set a webSessionReady flag in the
webSession handler, clear it on sessionExpired, and gate web calls on it
(src/index.ts:3756, :2022).
steam.session_expired counterToday detection is string-grep over Datadog logs (@context.operation:steam.getConfirmations
"web session expired"). A counter lets us alert/dashboard on session-expiry rate instead.
Hard constraint on every fix above: Steam throttles & locks accounts on abusive login behaviour
The trap: sessionExpired fires continuously while logged out, and every failing
web call wants to recover. Wire that straight to a re-login and one Steam blip turns into a self-inflicted
RateLimitExceeded (EResult 84) — or, worse, an account lock requiring email/SteamGuard re-verification.
Across 534 co-located bots the blast radius is fleet-wide. Every fix above must obey these six rules.
The golden rule
Refresh the cookie, don't re-authenticate.
A live CM socket + a valid refresh token means recovery is just webLogOn() minting fresh cookies — cheap,
unscrutinised. A full client.logOn() with password + 2FA is the path Steam rate-limits hardest and the one
that risks lockouts. A mere cookie expiry must NEVER escalate to a credentialed logon.
webLogOn() reuses the existing access token to fetch new cookies (steam-user
07-web.js) — no credentials, no 2FA spend. doClientLogin() already branches this way when
client.steamID is truthy (src/index.ts:467-470). Preserve that branch — it's
the single most important anti-ban property in the file.
Today the 30s rate-limit only guards the full-logon path; the webLogOn() branch is gated
only by isTryingToLogin, which resets instantly. Add a botLastWebLogOn:{botid} min-interval
(30–60s) + single-flight so the continuously-firing event can't spam cookie requests.
steam-user's webLogOn already self-retries with exponential backoff 1s→60s
(07-web.js:59). The withWebSession wrapper must retry once then surface —
and the sessionExpired listener must be debounced — so we never stack our retries on the library's.
client.on('error') deletes botRefreshToken on every error
(src/index.ts:599), forcing a full password+2FA logon next time — the most-throttled path. The code's own
comment admits this "likely causes the rate limit." Only clear it on genuinely auth-invalid EResults
(InvalidPassword / AccessDenied / expired); keep it for session expiry so we stay on webLogOn.
534 co-located bots + one Steam blip = synchronized sessionExpired = a login storm from a
handful of IPs. The deploy playbook already treats this as real (routine = 5 hosts "login-storm-safe"; immediate adds
~60s jitter). Add randomized 0–N s jitter before re-login so the fleet desynchronises.
On RateLimitExceeded, back off minutes (exponential), never the 30s
cadence — tight-looping logon deepens the lockout. Bonus: consider easing the 11s confirmation poll; the library author
warns the auto-checker over-polls Steam "getting yourself rate-limited."
A web-session expiry → one debounced, jittered webLogOn() (refresh token reused,
no 2FA) → webSession re-arms cookies → the in-flight call retries once and succeeds. The customer sees a
~1–3s pause, not a "Steam is down" bounce; Steam sees one cookie fetch, not a login storm. Full credentialed
logOn happens only when the CM socket is actually down or the refresh token is genuinely invalid.
Generated 2026-06-18 · tradeit.gg · source-anchored against master 41b1e33 + node_modules