diff --git a/CLAUDE.md b/CLAUDE.md index 22b1303..9056b28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,12 +66,13 @@ broader than a single file. - `lib/core/responses.ts` — the response bus behind `Lacuna#onResponse`. Every response `Server` produces is published to subscribers with the originating request and a `retry` callback. Responses carrying a blocking error code - (`BLOCKING_ERROR_CODES` in `lib/core/constants.ts` — RPC limit 1010 and - captcha 1016) are dispatched _awaited_, and a response a handler returns - replaces the one the caller gets; everything else is fire-and-forget. Every - handler is called even after a blocking error is resolved (subscribers are - promised _all_ responses) — later handlers just receive the resolved - response, which is what keeps them from retrying redundantly. + (`BLOCKING_ERROR_CODES` in `lib/core/constants.ts` — RPC limit 1010, captcha + 1016, and rejected-session 1006) are dispatched _awaited_, and a response a + handler returns replaces the one the caller gets; everything else is + fire-and-forget. Every handler is called even after a blocking error is + resolved (subscribers are promised _all_ responses) — later handlers just + receive the resolved response, which is what keeps them from retrying + redundantly. - `lib/core/rpc-limit-handler.ts` — the opt-in handler `Lacuna#enableRpcLimitHandler` registers: retries 1010 with progressive backoff. The library logs nothing about errors on its own; that's what subscribers are for. diff --git a/lib/__utils__/get-lacuna.ts b/lib/__utils__/get-lacuna.ts index 480efd0..f1a16a9 100644 --- a/lib/__utils__/get-lacuna.ts +++ b/lib/__utils__/get-lacuna.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; -import { Lacuna } from '../'; +import { Lacuna, SESSION_EXPIRED_ERROR_CODE } from '../'; +import type { ResponseHandler } from '../'; import { tmpfile } from './tmpfile'; // The live server rate-limits to 200 requests/minute per empire. With one @@ -10,6 +11,14 @@ import { tmpfile } from './tmpfile'; const CACHE_FILE = tmpfile('tle-client-test-session.json'); const SESSION_TTL_MS = 4 * 60 * 1000; +const CREDENTIALS = { name: 'nataliethistime', password: '1234qwer' }; + +// After a successful re-login, treat any 1006 that lands within this window as +// "not actually a dead session" (e.g. the v2 layer's arg-mapping quirk that +// rejects a valid session on some named-only methods) and leave it alone +// rather than re-logging in on a loop. +const REFRESH_COOLDOWN_MS = 10 * 1000; + interface CachedSession { sessionId: string; expiresAt: number; @@ -30,17 +39,82 @@ function writeCachedSessionId(sessionId: string) { fs.writeFileSync(CACHE_FILE, JSON.stringify(cached)); } -export const getLacuna = async () => { +function clearCachedSession() { + try { + fs.rmSync(CACHE_FILE, { force: true }); + } catch { + // Nothing to clear, or it's not ours to remove - the re-login below will + // overwrite it anyway. + } +} + +async function authenticate(lacuna: Lacuna): Promise { + await lacuna.authenticate(CREDENTIALS.name, CREDENTIALS.password); + const sessionId = lacuna.session.get(); + if (sessionId) writeCachedSessionId(sessionId); + return sessionId || undefined; +} + +// A dead cached session hands the same 1006 to every test that uses it. The +// first one to hit it does the re-login; concurrent calls (a `Promise.all` of +// endpoint calls) await that same attempt instead of each firing their own. +let reloginInFlight: Promise | undefined; +let lastRefreshAt = 0; + +async function refreshSession(lacuna: Lacuna): Promise { + clearCachedSession(); + const sessionId = await authenticate(lacuna); + if (sessionId) lastRefreshAt = Date.now(); + return sessionId; +} + +/** + * Recovers from a rejected-session error (1006): clears the on-disk session + * cache, logs in again, stores the new session, and retries the failing call + * so the test that triggered it carries on. 1006 is a blocking error code, so + * the retry's response is the one the original caller receives. + */ +const sessionExpiredHandler = + (lacuna: Lacuna): ResponseHandler => + async (event) => { + if (event.response.error?.code !== SESSION_EXPIRED_ERROR_CODE) return; + if (event.attempt >= 1) return; // already retried once on a fresh session - don't loop + if (Date.now() - lastRefreshAt < REFRESH_COOLDOWN_MS) return; // just re-logged in; not a dead session + + reloginInFlight ??= refreshSession(lacuna).finally(() => { + reloginInFlight = undefined; + }); + + const sessionId = await reloginInFlight; + if (!sessionId) return; // re-login failed - surface the original 1006 + + return event.retry(); + }; + +interface GetLacunaOptions { + /** + * Turn on the built-in RPC-limit (1010) backoff/retry handler. Default true. + * Pass false in a test that deliberately asserts a raw 1010 response - the + * backend overloads that code for some "can't do that right now" refusals, + * and the retry loop would otherwise swallow it (and blow the test timeout). + */ + handleRpcLimit?: boolean; +} + +export const getLacuna = async ({ handleRpcLimit = true }: GetLacunaOptions = {}) => { const lacuna = new Lacuna(); + // Ride out the server's per-minute request limit with a progressive backoff, + // and recover transparently if the cached session has gone stale. + if (handleRpcLimit) lacuna.enableRpcLimitHandler(); + lacuna.onResponse(sessionExpiredHandler(lacuna)); + const cachedSessionId = readCachedSessionId(); if (cachedSessionId) { lacuna.session.set(cachedSessionId); return lacuna; } - await lacuna.authenticate('nataliethistime', '1234qwer'); - const sessionId = lacuna.session.get(); - if (sessionId) writeCachedSessionId(sessionId); + await authenticate(lacuna); return lacuna; }; diff --git a/lib/core/constants.ts b/lib/core/constants.ts index 183ccae..8b416d1 100644 --- a/lib/core/constants.ts +++ b/lib/core/constants.ts @@ -9,6 +9,12 @@ export const RPC_LIMIT_ERROR_CODE = 1010; /** Server wants a captcha solved before it will honour the call. */ export const CAPTCHA_ERROR_CODE = 1016; +/** + * Server rejected the session id (missing, expired, or invalid). Recoverable + * by re-authenticating and retrying. + */ +export const SESSION_EXPIRED_ERROR_CODE = 1006; + /** * Error codes the transport blocks on: rather than returning these straight * away it awaits every response subscriber first, so a handler gets the @@ -16,10 +22,16 @@ export const CAPTCHA_ERROR_CODE = 1016; * * Note the spec overloads 1016 - on the trade endpoints it means "the trade * is no longer valid" rather than "solve a captcha" (see - * lib/types/schema.ts's `trade/accept_from_market` description). Those errors - * will block on handlers too, which is harmless: with nothing subscribed the - * dispatch is a no-op and the error is returned unchanged. + * lib/types/schema.ts's `trade/accept_from_market` description). 1006 is + * likewise overloaded: the v2 layer can raise it for a valid session on + * methods it argument-maps wrong, not only for a genuinely dead session. + * Those errors will block on handlers too, which is harmless: with nothing + * subscribed the dispatch is a no-op and the error is returned unchanged. */ -export const BLOCKING_ERROR_CODES = [RPC_LIMIT_ERROR_CODE, CAPTCHA_ERROR_CODE]; +export const BLOCKING_ERROR_CODES = [ + RPC_LIMIT_ERROR_CODE, + CAPTCHA_ERROR_CODE, + SESSION_EXPIRED_ERROR_CODE, +]; export default constants; diff --git a/lib/core/server.ts b/lib/core/server.ts index 2b06bfd..24ae13d 100644 --- a/lib/core/server.ts +++ b/lib/core/server.ts @@ -135,11 +135,11 @@ class Server { /** * Hands every response to the subscribers registered via Lacuna#onResponse. * - * Responses carrying a blocking error code (an RPC limit or captcha prompt) - * wait for those handlers to finish, and a response a handler returns - from - * `retry()`, typically - replaces this one. That's what lets a script ride - * out an RPC limit error instead of being stopped by it. Everything else is - * published without waiting. + * Responses carrying a blocking error code (an RPC limit, a captcha prompt, + * or a rejected session) wait for those handlers to finish, and a response a + * handler returns - from `retry()`, typically - replaces this one. That's + * what lets a script ride out an RPC limit error instead of being stopped by + * it. Everything else is published without waiting. */ private publish( request: ServerRequest, diff --git a/lib/endpoints/empire.test.ts b/lib/endpoints/empire.test.ts index c7092e6..c86bcd3 100644 --- a/lib/endpoints/empire.test.ts +++ b/lib/endpoints/empire.test.ts @@ -267,7 +267,10 @@ describe('methods checked without being carried out', () => { }); test('updateSpecies', async () => { - const lacuna = await getLacuna(); + // The backend refuses this for a founded empire with a raw 1010, the same + // code it uses for the RPC limit - so opt out of the retry handler that + // would otherwise keep retrying it until the test times out. + const lacuna = await getLacuna({ handleRpcLimit: false }); const { result: status } = await lacuna.empire.getStatus(); // Founded empires are exactly what update_species refuses to touch, so the diff --git a/lib/index.ts b/lib/index.ts index 800c341..64b2e5d 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -4,7 +4,12 @@ import * as types from './types'; export { Lacuna, util, types }; -export { RPC_LIMIT_ERROR_CODE, CAPTCHA_ERROR_CODE, BLOCKING_ERROR_CODES } from './core/constants'; +export { + RPC_LIMIT_ERROR_CODE, + CAPTCHA_ERROR_CODE, + SESSION_EXPIRED_ERROR_CODE, + BLOCKING_ERROR_CODES, +} from './core/constants'; export { rpcLimitHandler } from './core/rpc-limit-handler'; diff --git a/lib/lacuna.ts b/lib/lacuna.ts index 4b8015d..29c2386 100644 --- a/lib/lacuna.ts +++ b/lib/lacuna.ts @@ -233,10 +233,11 @@ class Lacuna { * The handler is given the request that produced it, the resolved * ServerResponse, and a `retry` callback that re-issues the request. * - * For an RPC limit ("slow down") error or a captcha prompt, the originating - * call waits for every handler to finish before returning, and adopts a - * response a handler returns - so returning `await event.retry()` makes the - * caller see the retry's result instead of the error. + * For a blocking error - an RPC limit ("slow down"), a captcha prompt, or a + * rejected session (1006) - the originating call waits for every handler to + * finish before returning, and adopts a response a handler returns - so + * returning `await event.retry()` makes the caller see the retry's result + * instead of the error. * * Returns a function that unsubscribes the handler. */