TRADEIT-TRADEBOT-SERVER · RESILIENCE REVIEW · 2026-06-18

Steam Web-Session Loss: Map & Recovery

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.

15
Web call sites
1
That auto-recover
2
sessionExpired events unused
~11s
Best-case recovery lag

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.

Three connections, only one expires

"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

The call-site map

Transport = what dies on cookie expiry. WEB breaks · GC/CM survive

#Call siteindex.tsTransportOn session lossRe-login?
1manager internal poll (10s)407 (ctor)WEBsentOfferChanged / newOffer / pollData stop firing → trade-state stallsno listener
2offer.send (trade send)3419, 1970WEB"Not Logged In" → user sees "Steam is down", offer canceledno
3offer.getUserDetails (escrow/guard gate)896, 3143WEBme/them null → trade canceled "failed to get account details"; also false-escrow bugno
4offer.accept (incoming)1991WEBretries 3×/10s then dropsblind retry
5offer.getExchangeDetails1708, 1746, 3644WEBsets retry flag / requeuesno
6manager.getOffer ×31051, 1647, 3635WEBlogs, writes errors:{id}, returnsno
7manager.getOffers (cancelExpired, 60s)3758WEBlogs, returns — guarded by client.steamID which is still true!no
8manager.getInventoryContents (refreshInv, 30s)2533WEBlogs, returns undefined → sinv stops updatingno
9community.getConfirmations (11s)3547WEBdetects "Not Logged In" → doClientLogin()YES — only one
10conf.respond3573WEB"Not Logged In" → return (next 11s tick recovers)indirect
11community.acceptConfirmationForObject1978, 2005WEBretry 3×/10s or logno
12axios.get .../partnerinventory903WEB*catch → caches empty partner inv 10ssilent
13axios.get .../inventory/{id}/7302493WEB*returns [] → floats/stickers silently missingsilent
14csgo.inspectItem642GCsurvives cookie loss; 10s timeout → {}n/a
15csgo casket: contents / add / remove788, 820, 2079, 2230GCsurvives cookie loss; err → [] / try-catchn/a
axios.post(channelUrl) (Slack)169non-Steamn/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.


What breaks, ranked by user impact

1 · Trade sends fail

#2, #3 — customer gets "Steam is down, try again." Highest visible impact; every send during the recovery window bounces.

2 · Inventory stops refreshing

#8 — sinv:{botid} goes stale; site shows wrong stock until a session returns.

3 · Floats / stickers vanish

#13 — items show without float data after a refresh. Silent: only logger.error noise.

4 · Partner inventory empty

#12 — withdrawal / trade-URL flows see nothing to trade. Cached empty for 10s on each failure.

5 · Trade-state updates stall

#1 — completed trades don't propagate to the socket-server until the poll resumes.

What does NOT break

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.


How recovery works today (and where it doesn't)

Two safety nets — they cover different failures, and neither covers everything

Cookie expiry (the real case)

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.

CM disconnect (different failure)

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

Proof — anchored to source

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

A · The sessionExpired event exists — and we don't listen to it

steamcommunity 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.

B · webLogOn() is the documented recovery primitive

steam-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).

B2 · The event fires in a loop — the library author says "limit your logins"

"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).

C · The single recovery hook + the false guard

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).

External references


Recommendations — re-login & retry without failing

Ranked. #1 + #2 together deliver "pause ~2s and retry" instead of "fail to the customer"

1 · Listen for 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.

2 · A withWebSession retry wrapper

On 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."

3 · Replace the client.steamID guards with a real session flag

client.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).

4 · Emit a steam.session_expired counter

Today 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.


Rate-limit-safe recovery — don't get the fleet banned

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.

G1 · webLogOn, not 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.

G2 · Debounce the webLogOn path

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.

G3 · Don't double-retry

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.

G4 · Stop nuking the refresh token

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.

G5 · Fleet jitter

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.

G6 · Honour EResult 84

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."

Net behaviour we want

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