From 12e3acca325769e6189daf891157f779cc0ca8a3 Mon Sep 17 00:00:00 2001 From: James Berry Date: Thu, 10 Sep 2026 11:50:24 +0100 Subject: [PATCH 1/2] feat(golbat): discover instance capabilities from /api/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a GolbatCapabilities service that reads GET /api/status from every configured Golbat endpoint and keeps { features, limits, filters } per instance, keyed by the endpoint base url. Discovery runs from DbManager.getDbContext (startup and config reload), refreshes every five minutes, and rechecks an instance immediately, debounced to once per 30 s, when a scanner call answers 400/422 — the first sign of a downgrade. A 404 or non-JSON body means an older Golbat with no capabilities; any other failure keeps the last good result. Pokestop.getAvailable now decides showcase focus support from supportsFilter(mem, 'showcase_focus') when the status route advertises filters, and only falls back to the deprecated per-response showcase_focus_filter flag for a build that does not. The hard error on an unsupported build is unchanged; only the source of truth moves. The header builder shared by the evaluator and the service is extracted into scannerHeaders.js. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016611GTrQ2W8WcznKrLQnMv --- packages/logger/lib/tags.js | 1 + packages/types/lib/server.d.ts | 32 +- server/src/models/Pokestop.js | 30 +- server/src/models/pokestopAvailableMapper.js | 2 +- server/src/services/DbManager.js | 8 + server/src/services/GolbatCapabilities.js | 252 +++++++++++++ server/src/utils/evalScannerQuery.js | 31 +- server/src/utils/scannerHeaders.js | 27 ++ server/test/golbatCapabilities.test.js | 333 ++++++++++++++++++ server/test/scannerCapabilityRecheck.test.js | 47 +++ .../test/showcaseEndpointAvailability.test.js | 168 ++++++--- 11 files changed, 867 insertions(+), 64 deletions(-) create mode 100644 server/src/services/GolbatCapabilities.js create mode 100644 server/src/utils/scannerHeaders.js create mode 100644 server/test/golbatCapabilities.test.js create mode 100644 server/test/scannerCapabilityRecheck.test.js diff --git a/packages/logger/lib/tags.js b/packages/logger/lib/tags.js index 06191d7ab..bbd3d49eb 100644 --- a/packages/logger/lib/tags.js +++ b/packages/logger/lib/tags.js @@ -29,6 +29,7 @@ const TAGS = /** @type {const} */ ({ geocoder: chalk.hex('#ff5722')('[GEOCODER]'), fetch: chalk.hex('#880e4f')('[FETCH]'), scanner: chalk.hex('#b39ddb')('[SCANNER]'), + golbat: chalk.hex('#ffb300')('[GOLBAT]'), build: chalk.hex('#ef6c00')('[BUILD]'), ReactMap: chalk.hex('#ff3d00')('[ReactMap]'), trial: chalk.hex('#fff320')('[TRIAL]'), diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index a7c42ea27..2b18f73f8 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -109,7 +109,13 @@ export interface AvailablePokestopShowcase { } export interface AvailablePokestops { - showcase_focus_filter: boolean + /** + * @deprecated Golbat advertises showcase focus support via + * GET /api/status `filters.showcase_focus` (see GolbatStatus). This + * per-response flag is only consulted for a Golbat whose status route + * reports no filters block, and will be dropped once such builds are gone. + */ + showcase_focus_filter?: boolean quests: AvailablePokestopQuest[] invasions: AvailablePokestopInvasion[] lures: AvailablePokestopLure[] @@ -125,6 +131,30 @@ export interface Available { tappables: ModelReturn } +/** + * Parsed GET /api/status from one Golbat instance. Every key a build defines + * is true; the signal is the key's presence. `filters` is null when the build + * predates the block (it then advertises no optional DNF filter fields), so + * consumers can fall back to older per-response flags for that build only. + */ +export interface GolbatStatus { + features: Record + limits: Record + filters: Record | null +} + +/** Registry entry kept by GolbatCapabilities, keyed by the endpoint base url. */ +export interface GolbatInstance { + mem: string + secret: string + httpAuth: { username: string; password: string } | null + /** Last good status; null until the instance has answered once. */ + status: GolbatStatus | null + /** Epoch ms of the last refresh start; drives the recheck debounce. */ + lastFetchAt: number + inflight: Promise | null +} + export interface ApiEndpoint { type: string endpoint: string diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 176706395..ea66f4cdc 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -30,6 +30,7 @@ const { getCombinedFortAvailable } = require('../utils/fortAvailable') const { buildPokestopDnfFilters } = require('../filters/fort/pokestop') const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') const { state } = require('../services/state') +const { golbatCapabilities } = require('../services/GolbatCapabilities') const { isDualQuestLayerMode, resolveQuestLayerSelection, @@ -49,6 +50,21 @@ const TEMP_EVOLUTION_RESOURCE_REWARD_TYPES = [ TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, ] +/** + * Whether the Golbat at `mem` applies contest_focus (showcase focus) inside + * its DNF matcher before the result cap. /api/status is the source of truth + * once a build advertises filters; an older build that does not is judged by + * the deprecated per-response `showcase_focus_filter` flag instead. + * @param {string} mem endpoint base url + * @param {{ showcase_focus_filter?: boolean }} payload /api/fort/available pokestops block + */ +function supportsShowcaseFocus(mem, payload) { + if (golbatCapabilities.advertisesFilters(mem)) { + return golbatCapabilities.supportsFilter(mem, 'showcase_focus') + } + return payload.showcase_focus_filter === true +} + /** @typedef {Partial} QuestReward */ const QUEST_REWARD_FILTER_DEFINITIONS = { @@ -1343,10 +1359,16 @@ class Pokestop extends Model { // contract. Do not let an outdated endpoint masquerade as a transient // failure and fall through to SQL. const hasPayload = res && typeof res === 'object' && !Array.isArray(res) - if (hasPayload && res.showcase_focus_filter !== true) { - throw new Error( - 'Golbat lacks the required showcase_focus_filter capability', - ) + if (hasPayload && !supportsShowcaseFocus(mem, res)) { + // The registry can lag a Golbat upgrade that dropped the legacy flag + // (its status was last read before the upgrade). One debounced + // re-read settles it before the verdict becomes a hard error. + await golbatCapabilities.recheck(mem) + if (!supportsShowcaseFocus(mem, res)) { + throw new Error( + 'Golbat lacks the required showcase_focus filter capability', + ) + } } // Transport failures and malformed responses may use the source's // normal SQL fallback. Keep only the mapper call inside this catch so diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index 113ec9757..24b35cf6f 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -45,7 +45,7 @@ * @property {Record|null} showcase_focus * * @typedef {object} AvailablePokestops - * @property {boolean} showcase_focus_filter + * @property {boolean} [showcase_focus_filter] deprecated — see GolbatStatus.filters.showcase_focus * @property {AvailablePokestopQuest[]} quests * @property {AvailablePokestopInvasion[]} invasions * @property {AvailablePokestopLure[]} lures diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index bdafe9bf9..45dab9e15 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -11,6 +11,7 @@ const { collapseRocketPokemonFilterKeys, } = require('../utils/rocketPokemonFiltering') const { getCache } = require('./cache') +const { golbatCapabilities } = require('./GolbatCapabilities') const STATION_BATTLE_REQUIRED_COLUMNS = [ 'station_id', @@ -292,6 +293,12 @@ class DbManager extends Logger { this.connections.length > 1 ? 's' : '' }`, ) + // Endpoint capability discovery is the /api/status counterpart of + // schemaCheck: it runs here so the registry is populated before the first + // availability refresh (startup and config reload both await this). + const discovery = golbatCapabilities + .discover(Object.values(this.endpoints)) + .catch((e) => this.log.error('Golbat capability discovery failed', e)) await Promise.all( this.connections.map(async (schema, i) => { try { @@ -363,6 +370,7 @@ class DbManager extends Logger { } }), ) + await discovery } /** diff --git a/server/src/services/GolbatCapabilities.js b/server/src/services/GolbatCapabilities.js new file mode 100644 index 000000000..322b1b558 --- /dev/null +++ b/server/src/services/GolbatCapabilities.js @@ -0,0 +1,252 @@ +// @ts-check +const { default: fetch } = require('node-fetch') + +const config = require('@rm/config') +const { Logger } = require('@rm/logger') + +const { buildScannerHeaders } = require('../utils/scannerHeaders') +const { setLongTimeout } = require('../utils/setLongTimeout') + +const STATUS_PATH = '/api/status' + +/** @param {unknown} value */ +const isPlainObject = (value) => + !!value && typeof value === 'object' && !Array.isArray(value) + +/** @returns {import('@rm/types').GolbatStatus} */ +const legacyStatus = () => ({ features: {}, limits: {}, filters: null }) + +/** + * Normalises a decoded /api/status body. A body that is not an object (an + * HTML error page that happened to parse, a bare array) counts as legacy. + * `filters` stays null when the block is absent so consumers can tell "this + * build does not advertise filters" from "this build advertises none". + * @param {unknown} body + * @returns {import('@rm/types').GolbatStatus} + */ +function parseStatus(body) { + if (!isPlainObject(body)) return legacyStatus() + const { features, limits, filters } = /** @type {Record} */ ( + body + ) + return { + features: isPlainObject(features) ? { ...features } : {}, + limits: isPlainObject(limits) ? { ...limits } : {}, + filters: isPlainObject(filters) ? { ...filters } : null, + } +} + +/** + * Discovers what each configured Golbat instance supports by reading + * GET /api/status, and keeps the answer per instance so consumers can ask + * `supportsFilter(mem, 'showcase_focus')` instead of probing feature calls. + * + * Instances are keyed by their endpoint base URL — the `mem` every scanner + * model already carries in its DbContext. + */ +class GolbatCapabilities extends Logger { + /** Upgrades are rare and a slow reaction is fine; the recheck fast path covers downgrades. */ + static REFRESH_MS = 5 * 60_000 + + /** Floor between immediate rechecks of one instance, so a burst of 4xx cannot hammer it. */ + static RECHECK_DEBOUNCE_MS = 30_000 + + /** + * @param {{ fetch?: typeof fetch, timeoutMs?: number, now?: () => number }} [options] + */ + constructor(options = {}) { + super('golbat') + this.fetch = options.fetch ?? fetch + this.timeoutMs = options.timeoutMs + this.now = options.now ?? Date.now + /** @type {Map} */ + this.instances = new Map() + /** @type {NodeJS.Timeout | null} */ + this.timer = null + } + + /** + * Replaces the registry with the given endpoints and fetches each status + * once, resolving when every fetch has settled. + * @param {{ endpoint: string, secret?: string, httpAuth?: { username: string, password: string } | null }[]} endpoints + */ + async discover(endpoints) { + const next = new Map() + endpoints.forEach(({ endpoint, secret, httpAuth }) => { + if (!endpoint || next.has(endpoint)) return + next.set(endpoint, { + mem: endpoint, + secret: secret || '', + httpAuth: httpAuth || null, + status: this.instances.get(endpoint)?.status ?? null, + lastFetchAt: 0, + inflight: null, + }) + }) + this.instances = next + this.#ensureTimer() + await this.refreshAll() + } + + /** Refreshes every registered instance, resolving once all have settled. */ + async refreshAll() { + await Promise.allSettled( + [...this.instances.keys()].map((mem) => this.refresh(mem)), + ) + } + + #ensureTimer() { + if (this.timer) return + this.timer = setInterval( + () => this.refreshAll(), + GolbatCapabilities.REFRESH_MS, + ) + this.timer.unref?.() + } + + /** + * Fast path for a failed feature call: a 4xx from a filter field is the first + * sign that a Golbat was downgraded, so re-read its status now rather than + * on the next interval. Debounced per instance and shares any in-flight + * refresh, so it is safe to call from a hot request path. + * @param {string} mem + */ + async recheck(mem) { + const instance = this.instances.get(mem) + if (!instance) return + if (instance.inflight) { + await instance.inflight + return + } + if ( + this.now() - instance.lastFetchAt < + GolbatCapabilities.RECHECK_DEBOUNCE_MS + ) { + return + } + await this.refresh(mem) + } + + /** + * Fetches /api/status for one instance and stores the parsed result. + * + * A 404 or a 2xx whose body is not JSON is an older Golbat and records a + * legacy (no capabilities) result. Any other outcome — a non-2xx such as + * 401/503, a network error, or the fetch timeout — is treated as transient: + * the last good result is kept so a Golbat blip does not churn behaviour. + * @param {string} mem + */ + async refresh(mem) { + const instance = this.instances.get(mem) + if (!instance) return + if (instance.inflight) { + await instance.inflight + return + } + instance.lastFetchAt = this.now() + instance.inflight = this.#fetchStatus(instance).finally(() => { + instance.inflight = null + }) + await instance.inflight + } + + /** @param {import('@rm/types').GolbatInstance} instance */ + async #fetchStatus(instance) { + const { mem } = instance + const controller = new AbortController() + const clearFetchTimeout = setLongTimeout( + () => controller.abort(), + this.timeoutMs ?? config.getSafe('api.fetchTimeoutMs'), + ) + try { + const response = await this.fetch(`${mem}${STATUS_PATH}`, { + method: 'GET', + headers: buildScannerHeaders(instance.secret, instance.httpAuth), + signal: controller.signal, + }) + let status + if (response.status === 404) { + status = legacyStatus() + } else if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } else { + let body + try { + body = await response.json() + } catch { + body = null + } + status = parseStatus(body) + } + this.#store(instance, status) + } catch (e) { + this.log.warn( + `${mem}${STATUS_PATH} unreachable (${e instanceof Error ? e.message : e}) — ${ + instance.status + ? 'keeping the last known capabilities' + : 'capabilities unknown until it answers' + }`, + ) + } finally { + clearFetchTimeout() + } + } + + /** + * Commits a freshly parsed status, logging at info only when it differs from + * the previous answer so the periodic refresh stays quiet. + * @param {import('@rm/types').GolbatInstance} instance + * @param {import('@rm/types').GolbatStatus} status + */ + #store(instance, status) { + const changed = JSON.stringify(instance.status) !== JSON.stringify(status) + instance.status = status + if (!changed) return + const filters = status.filters + ? `filters: ${Object.keys(status.filters).join(', ') || 'none'}` + : 'no filters block (older Golbat)' + const features = Object.keys(status.features).length + ? `features: ${Object.entries(status.features) + .map(([k, v]) => `${k}=${v}`) + .join(', ')}` + : 'no features block' + const limits = Object.keys(status.limits).length + ? `limits: ${Object.entries(status.limits) + .map(([k, v]) => `${k}=${v}`) + .join(', ')}` + : 'no limits block' + this.log.info(`${instance.mem} — ${filters}; ${features}; ${limits}`) + } + + /** + * @param {string} mem + * @returns {import('@rm/types').GolbatStatus | null} + */ + get(mem) { + return this.instances.get(mem)?.status ?? null + } + + /** @param {string} mem */ + advertisesFilters(mem) { + return !!this.get(mem)?.filters + } + + /** + * @param {string} mem + * @param {string} key + */ + supportsFilter(mem, key) { + return this.get(mem)?.filters?.[key] === true + } + + /** Cancels the periodic refresh. Discovery restarts it. */ + stop() { + if (this.timer) clearInterval(this.timer) + this.timer = null + } +} + +/** Process-wide registry; DbManager.getDbContext populates it at startup and on reload. */ +const golbatCapabilities = new GolbatCapabilities() + +module.exports = { GolbatCapabilities, golbatCapabilities } diff --git a/server/src/utils/evalScannerQuery.js b/server/src/utils/evalScannerQuery.js index 61c293a86..4027c4efd 100644 --- a/server/src/utils/evalScannerQuery.js +++ b/server/src/utils/evalScannerQuery.js @@ -5,6 +5,13 @@ const { resolve } = require('path') const config = require('@rm/config') const { log } = require('@rm/logger') const { fetchJson } = require('./fetchJson') +const { buildScannerHeaders } = require('./scannerHeaders') +const { golbatCapabilities } = require('../services/GolbatCapabilities') + +// Validation-class rejections. A Golbat that no longer knows a filter field +// answers one of these, which is the first sign it was downgraded — so the +// capability registry re-reads that instance's /api/status right away. +const CAPABILITY_RECHECK_STATUSES = new Set([400, 422]) /** * Endpoint-or-knex query evaluator shared by Golbat-backed scanner models. @@ -42,21 +49,21 @@ async function evalScannerQuery( const results = await (mem ? fetchJson(mem, { method, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(secret ? { 'X-Golbat-Secret': secret } : {}), - ...(httpAuth - ? { - Authorization: `Basic ${Buffer.from( - `${httpAuth.username}:${httpAuth.password}`, - ).toString('base64')}`, - } - : {}), - }, + headers: buildScannerHeaders(secret, httpAuth), body: query, }) : query) + const apiPathIndex = mem ? mem.indexOf('/api/') : -1 + if ( + apiPathIndex > 0 && + results && + typeof results === 'object' && + CAPABILITY_RECHECK_STATUSES.has(results.status) + ) { + golbatCapabilities + .recheck(mem.slice(0, apiPathIndex)) + .catch((e) => log.warn(tag, 'capability recheck failed', e)) + } log.debug(tag, 'raw result length', results?.length || 0) return results } diff --git a/server/src/utils/scannerHeaders.js b/server/src/utils/scannerHeaders.js new file mode 100644 index 000000000..9ee032131 --- /dev/null +++ b/server/src/utils/scannerHeaders.js @@ -0,0 +1,27 @@ +// @ts-check + +/** + * Request headers for a Golbat HTTP call: JSON content negotiation plus the + * optional API secret and HTTP basic auth a source is configured with. Shared + * by the scanner query evaluator and the capability discovery service so both + * authenticate the same way. + * @param {string} [secret] + * @param {{ username: string, password: string } | null} [httpAuth] + * @returns {Record} + */ +function buildScannerHeaders(secret = '', httpAuth = null) { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(secret ? { 'X-Golbat-Secret': secret } : {}), + ...(httpAuth + ? { + Authorization: `Basic ${Buffer.from( + `${httpAuth.username}:${httpAuth.password}`, + ).toString('base64')}`, + } + : {}), + } +} + +module.exports = { buildScannerHeaders } diff --git a/server/test/golbatCapabilities.test.js b/server/test/golbatCapabilities.test.js new file mode 100644 index 000000000..fb64c82d3 --- /dev/null +++ b/server/test/golbatCapabilities.test.js @@ -0,0 +1,333 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') + +const { GolbatCapabilities } = require('../src/services/GolbatCapabilities') + +const MEM = 'http://golbat-a' + +/** + * Builds a fetch stand-in whose reply can be swapped between calls. `reply` is + * a function of the url so a test can vary the answer per instance. + * @param {(url: string, init: any) => { status: number, body?: any, text?: string }} reply + */ +function fakeFetch(reply) { + const calls = [] + const fetchImpl = async (url, init) => { + calls.push({ url, init }) + const res = reply(url, init) + return { + ok: res.status >= 200 && res.status < 300, + status: res.status, + statusText: '', + json: async () => { + if ('body' in res) return res.body + throw new SyntaxError(`Unexpected token in JSON: ${res.text}`) + }, + } + } + return { fetchImpl, calls } +} + +const STATUS_WITH_FILTERS = { + features: { fort_in_memory: true }, + limits: { max_pokemon_results: 3000, max_fort_results: 9000 }, + filters: { showcase_focus: true, battle_available: true }, +} + +test('an old Golbat with no status route yields a legacy result without filters', async (t) => { + const { fetchImpl, calls } = fakeFetch(() => ({ status: 404 })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM, secret: 's3cret' }]) + + assert.equal(calls.length, 1) + assert.equal(calls[0].url, `${MEM}/api/status`) + assert.deepEqual(caps.get(MEM), { features: {}, limits: {}, filters: null }) + assert.equal(caps.advertisesFilters(MEM), false) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), false) +}) + +test('sends the Golbat secret and basic auth headers on the status request', async (t) => { + const { fetchImpl, calls } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([ + { + endpoint: MEM, + secret: 's3cret', + httpAuth: { username: 'u', password: 'p' }, + }, + ]) + + assert.equal(calls[0].init.method, 'GET') + assert.equal(calls[0].init.headers['X-Golbat-Secret'], 's3cret') + assert.equal( + calls[0].init.headers.Authorization, + `Basic ${Buffer.from('u:p').toString('base64')}`, + ) + assert.equal(calls[0].init.headers.Accept, 'application/json') +}) + +test('a status body without a filters block records features and limits only', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ + status: 200, + body: { + features: { fort_in_memory: true }, + limits: { max_pokemon_results: 3000, max_fort_results: 9000 }, + }, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.deepEqual(caps.get(MEM), { + features: { fort_in_memory: true }, + limits: { max_pokemon_results: 3000, max_fort_results: 9000 }, + filters: null, + }) + assert.equal(caps.advertisesFilters(MEM), false) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), false) +}) + +test('a status body with a filters block advertises the keys it lists', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.deepEqual(caps.get(MEM), STATUS_WITH_FILTERS) + assert.equal(caps.advertisesFilters(MEM), true) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + assert.equal(caps.supportsFilter(MEM, 'battle_available'), true) + assert.equal(caps.supportsFilter(MEM, 'not_a_filter'), false) +}) + +test('a 2xx that is not JSON counts as an old Golbat, not an outage', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ + status: 200, + text: 'not golbat', + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.deepEqual(caps.get(MEM), { features: {}, limits: {}, filters: null }) +}) + +test('a transient failure keeps the last good result', async (t) => { + let reply = { status: 200, body: STATUS_WITH_FILTERS } + const { fetchImpl } = fakeFetch(() => reply) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + + reply = { status: 500, text: 'boom' } + await caps.refresh(MEM) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + + reply = { status: 401, text: 'bad secret' } + await caps.refresh(MEM) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + + const networkError = async () => { + throw new Error('ECONNREFUSED') + } + caps.fetch = networkError + await caps.refresh(MEM) + assert.deepEqual(caps.get(MEM), STATUS_WITH_FILTERS) +}) + +test('a transient failure before any success leaves the instance unknown', async (t) => { + const { fetchImpl } = fakeFetch(() => ({ status: 503, text: 'starting' })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + + assert.equal(caps.get(MEM), null) + assert.equal(caps.advertisesFilters(MEM), false) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), false) +}) + +test('a refresh after a Golbat upgrade picks up the new filters block', async (t) => { + let reply = { status: 404 } + const { fetchImpl } = fakeFetch(() => reply) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(caps.advertisesFilters(MEM), false) + + reply = { status: 200, body: STATUS_WITH_FILTERS } + await caps.refresh(MEM) + assert.equal(caps.advertisesFilters(MEM), true) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) +}) + +test('a request that exceeds the fetch timeout is aborted and keeps the last good result', async (t) => { + const reply = { status: 200, body: STATUS_WITH_FILTERS } + const { fetchImpl } = fakeFetch(() => reply) + const hanging = (url, init) => + new Promise((_, reject) => { + init.signal.addEventListener('abort', () => + reject(new Error('The operation was aborted')), + ) + }) + const caps = new GolbatCapabilities({ fetch: fetchImpl, timeoutMs: 5 }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + caps.fetch = hanging + await caps.refresh(MEM) + + assert.deepEqual(caps.get(MEM), STATUS_WITH_FILTERS) +}) + +test('rediscovery drops instances that are no longer configured and keeps the rest', async (t) => { + const OTHER = 'http://golbat-b' + const { fetchImpl, calls } = fakeFetch((url) => ({ + status: url.startsWith(OTHER) ? 404 : 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([ + { endpoint: MEM }, + { endpoint: OTHER }, + { endpoint: OTHER }, + ]) + assert.equal(calls.length, 2) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) + assert.equal(caps.advertisesFilters(OTHER), false) + + await caps.discover([{ endpoint: OTHER }]) + assert.equal(caps.get(MEM), null) + assert.equal(caps.get(OTHER) !== null, true) +}) + +test('discovery starts a periodic refresh that stop() cancels', async (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }) + const { fetchImpl, calls } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }, { endpoint: 'http://golbat-b' }]) + assert.equal(calls.length, 2) + + t.mock.timers.tick(GolbatCapabilities.REFRESH_MS - 1) + assert.equal(calls.length, 2) + t.mock.timers.tick(1) + assert.equal(calls.length, 4) + + caps.stop() + t.mock.timers.tick(GolbatCapabilities.REFRESH_MS) + assert.equal(calls.length, 4) +}) + +test('concurrent refreshes of one instance share a single request', async (t) => { + let release + const gate = new Promise((resolve) => { + release = resolve + }) + const calls = [] + const fetchImpl = async (url, init) => { + calls.push({ url, init }) + await gate + return { + ok: true, + status: 200, + statusText: '', + json: async () => STATUS_WITH_FILTERS, + } + } + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + caps.instances.set(MEM, { + mem: MEM, + secret: '', + httpAuth: null, + status: null, + }) + + const first = caps.refresh(MEM) + const second = caps.refresh(MEM) + release() + await Promise.all([first, second]) + + assert.equal(calls.length, 1) + assert.equal(caps.supportsFilter(MEM, 'showcase_focus'), true) +}) + +test('recheck refreshes immediately but at most once per debounce window', async (t) => { + let now = 1_000_000 + const { fetchImpl, calls } = fakeFetch(() => ({ + status: 200, + body: STATUS_WITH_FILTERS, + })) + const caps = new GolbatCapabilities({ fetch: fetchImpl, now: () => now }) + t.after(() => caps.stop()) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(calls.length, 1) + + now += GolbatCapabilities.RECHECK_DEBOUNCE_MS + await caps.recheck(MEM) + assert.equal(calls.length, 2) + + await caps.recheck(MEM) + assert.equal(calls.length, 2) + + now += GolbatCapabilities.RECHECK_DEBOUNCE_MS - 1 + await caps.recheck(MEM) + assert.equal(calls.length, 2) + + now += 1 + await caps.recheck(MEM) + assert.equal(calls.length, 3) + + await caps.recheck('http://not-configured') + assert.equal(calls.length, 3) +}) + +test('a changed status is logged at info and an unchanged refresh is not', async (t) => { + let reply = { status: 404 } + const { fetchImpl } = fakeFetch(() => reply) + const caps = new GolbatCapabilities({ fetch: fetchImpl }) + t.after(() => caps.stop()) + const info = t.mock.method(caps.log, 'info', () => {}) + + await caps.discover([{ endpoint: MEM }]) + assert.equal(info.mock.callCount(), 1) + assert.match(String(info.mock.calls[0].arguments[0]), /no filters block/) + + await caps.refresh(MEM) + assert.equal(info.mock.callCount(), 1) + + reply = { status: 200, body: STATUS_WITH_FILTERS } + await caps.refresh(MEM) + assert.equal(info.mock.callCount(), 2) + assert.match( + String(info.mock.calls[1].arguments[0]), + /showcase_focus, battle_available/, + ) + + await caps.refresh(MEM) + assert.equal(info.mock.callCount(), 2) +}) diff --git a/server/test/scannerCapabilityRecheck.test.js b/server/test/scannerCapabilityRecheck.test.js new file mode 100644 index 000000000..6b5cac87f --- /dev/null +++ b/server/test/scannerCapabilityRecheck.test.js @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict') +const { test, mock } = require('node:test') + +// evalScannerQuery reads fetchJson at load, so stub the module before it loads. +let reply +mock.module(require.resolve('../src/utils/fetchJson'), { + cache: true, + namedExports: { fetchJson: async () => reply }, +}) + +const { golbatCapabilities } = require('../src/services/GolbatCapabilities') +const { evalScannerQuery } = require('../src/utils/evalScannerQuery') + +const MEM = 'http://golbat-a' + +test('a 4xx from a Golbat feature call rechecks that instance immediately', async (t) => { + const rechecked = [] + t.mock.method(golbatCapabilities, 'recheck', async (mem) => { + rechecked.push(mem) + }) + + reply = { status: 422, statusText: 'Unprocessable Entity' } + await evalScannerQuery('tag', `${MEM}/api/pokestop/scan`, '{}', 'POST') + assert.deepEqual(rechecked, [MEM]) + + reply = { status: 400, statusText: 'Bad Request' } + await evalScannerQuery('tag', `${MEM}/api/pokemon/v3/scan`, '{}', 'POST') + assert.deepEqual(rechecked, [MEM, MEM]) +}) + +test('other outcomes of a Golbat call do not trigger a recheck', async (t) => { + const rechecked = [] + t.mock.method(golbatCapabilities, 'recheck', async (mem) => { + rechecked.push(mem) + }) + + reply = { pokestops: [] } + await evalScannerQuery('tag', `${MEM}/api/pokestop/scan`, '{}', 'POST') + reply = { status: 404, statusText: 'Not Found' } + await evalScannerQuery('tag', `${MEM}/api/pokestop/id/abc`, undefined, 'GET') + reply = { status: 503, statusText: 'Service Unavailable' } + await evalScannerQuery('tag', `${MEM}/api/fort/available`, undefined, 'GET') + reply = undefined + await evalScannerQuery('tag', `${MEM}/api/pokestop/scan`, '{}', 'POST') + + assert.deepEqual(rechecked, []) +}) diff --git a/server/test/showcaseEndpointAvailability.test.js b/server/test/showcaseEndpointAvailability.test.js index 3df8ba9da..d24c20c7f 100644 --- a/server/test/showcaseEndpointAvailability.test.js +++ b/server/test/showcaseEndpointAvailability.test.js @@ -6,74 +6,150 @@ require('./stateMock') const fortAvailable = require('../src/utils/fortAvailable') -test('endpoint availability uses Golbat Showcase focus without SQL supplementation', async (t) => { - let pokestops = { - showcase_focus_filter: true, - quests: [], - invasions: [{ character: 0, display_type: 9 }], - lures: [], - showcases: [ - { - pokemon_id: null, - form: null, - type_id: null, - showcase_focus: { type: 'buddy', min_level: 3 }, - }, - ], - } +const { golbatCapabilities } = require('../src/services/GolbatCapabilities') + +const MEM = 'http://unused-golbat' + +const CONTEXT = { + hasAltQuests: false, + hasMultiInvasions: true, + multiInvasionMs: false, + hasRewardAmount: true, + hasConfirmed: true, + hasShowcaseData: false, + hasShowcaseForm: false, + hasShowcaseType: false, + hasShowcaseFocus: false, + mem: MEM, + secret: '', + httpAuth: null, +} + +/** A combined-availability pokestop payload carrying one Buddy showcase. */ +const showcasePayload = (extra = {}) => ({ + quests: [], + invasions: [{ character: 0, display_type: 9 }], + lures: [], + showcases: [ + { + pokemon_id: null, + form: null, + type_id: null, + showcase_focus: { type: 'buddy', min_level: 3 }, + }, + ], + ...extra, +}) + +/** + * Loads a fresh Pokestop model whose combined-availability fetch returns + * `getPokestops()` and whose SQL path counts (and rejects) any query. + * @param {import('node:test').TestContext} t + * @param {() => object} getPokestops + */ +function loadPokestop(t, getPokestops) { t.mock.method(fortAvailable, 'getCombinedFortAvailable', async () => ({ - pokestops, + pokestops: getPokestops(), })) - const pokestopModule = require.resolve('../src/models/Pokestop') delete require.cache[pokestopModule] const { Pokestop } = require('../src/models/Pokestop') const originalQuery = Pokestop.query - let sqlExecutions = 0 - + const sql = { executions: 0 } t.after(() => { Pokestop.query = originalQuery delete require.cache[pokestopModule] }) - Pokestop.query = () => { - sqlExecutions += 1 + sql.executions += 1 throw new Error('endpoint availability must not query SQL') } + return { Pokestop, sql } +} + +/** + * Pins the capability registry's answer for MEM. `filters` null = the status + * route reported no filters block (older Golbat). + * @param {import('node:test').TestContext} t + * @param {Record | null} filters + */ +function mockStatus(t, filters) { + t.mock.method(golbatCapabilities, 'advertisesFilters', (mem) => + mem === MEM ? filters !== null : false, + ) + t.mock.method(golbatCapabilities, 'supportsFilter', (mem, key) => + mem === MEM ? filters?.[key] === true : false, + ) + t.mock.method(golbatCapabilities, 'recheck', async () => {}) +} - const context = { - hasAltQuests: false, - hasMultiInvasions: true, - multiInvasionMs: false, - hasRewardAmount: true, - hasConfirmed: true, - hasShowcaseData: false, - hasShowcaseForm: false, - hasShowcaseType: false, - hasShowcaseFocus: false, - mem: 'http://unused-golbat', - secret: '', - httpAuth: null, - } +test('a Golbat that advertises showcase_focus serves availability without the legacy flag', async (t) => { + mockStatus(t, { showcase_focus: true }) + const { Pokestop, sql } = loadPokestop(t, () => showcasePayload()) - const result = await Pokestop.getAvailable(context) + const result = await Pokestop.getAvailable(CONTEXT) + + assert.deepEqual(result.available, ['b9', 'y3']) + assert.equal(sql.executions, 0) +}) + +test('a Golbat that advertises filters without showcase_focus is rejected even if the legacy flag says true', async (t) => { + mockStatus(t, { battle_available: true }) + const { Pokestop, sql } = loadPokestop(t, () => + showcasePayload({ showcase_focus_filter: true }), + ) + + await assert.rejects( + Pokestop.getAvailable(CONTEXT), + /required showcase_focus filter capability/, + ) + assert.equal(sql.executions, 0) +}) + +test('a Golbat without a filters block is judged by the legacy showcase_focus_filter flag', async (t) => { + mockStatus(t, null) + let pokestops = showcasePayload({ showcase_focus_filter: true }) + const { Pokestop, sql } = loadPokestop(t, () => pokestops) + + const result = await Pokestop.getAvailable(CONTEXT) assert.deepEqual(result.available, ['b9', 'y3']) - assert.equal(sqlExecutions, 0) - pokestops = { ...pokestops, showcase_focus_filter: false } + pokestops = showcasePayload({ showcase_focus_filter: false }) await assert.rejects( - Pokestop.getAvailable(context), - /required showcase_focus_filter capability/, + Pokestop.getAvailable(CONTEXT), + /required showcase_focus filter capability/, ) - assert.equal(sqlExecutions, 0) - pokestops = { ...pokestops } - delete pokestops.showcase_focus_filter + pokestops = showcasePayload() await assert.rejects( - Pokestop.getAvailable(context), - /required showcase_focus_filter capability/, + Pokestop.getAvailable(CONTEXT), + /required showcase_focus filter capability/, + ) + assert.equal(sql.executions, 0) +}) + +test('an unsupported verdict rechecks the status route once before failing', async (t) => { + // Registry still says "no filters block" from before an upgrade that dropped + // the legacy flag; the recheck flips it and the same call succeeds. + let filters = null + t.mock.method(golbatCapabilities, 'advertisesFilters', () => filters !== null) + t.mock.method( + golbatCapabilities, + 'supportsFilter', + (_mem, key) => filters?.[key] === true, ) - assert.equal(sqlExecutions, 0) + const rechecks = [] + t.mock.method(golbatCapabilities, 'recheck', async (mem) => { + rechecks.push(mem) + filters = { showcase_focus: true } + }) + const { Pokestop, sql } = loadPokestop(t, () => showcasePayload()) + + const result = await Pokestop.getAvailable(CONTEXT) + + assert.deepEqual(rechecks, [MEM]) + assert.deepEqual(result.available, ['b9', 'y3']) + assert.equal(sql.executions, 0) }) test('malformed endpoint availability falls through to dual-source SQL', async (t) => { From 4e0dbaeb531bb769f102326447dea903ee5ba964 Mon Sep 17 00:00:00 2001 From: James Berry Date: Thu, 10 Sep 2026 16:12:47 +0100 Subject: [PATCH 2/2] fix(pokestop): re-read Golbat status without the recheck debounce The unsupported showcase-focus verdict in Pokestop.getAvailable called the debounced recheck(), which shares its 30 s floor with the periodic status fetch. A Golbat upgrade landing inside that window left the availability pass throwing until the next one. Use refresh() there: it is still single-flight, and the path is already throttled by the availability refresh window. The debounce stays on the hot-path recheck triggered by 4xx scanner responses. The replaced test now drives the real registry with a fake transport so it exercises the debounce instead of a mocked method. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016611GTrQ2W8WcznKrLQnMv --- server/src/models/Pokestop.js | 9 ++-- .../test/showcaseEndpointAvailability.test.js | 49 +++++++++++++------ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index ea66f4cdc..029c15eb9 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1361,9 +1361,12 @@ class Pokestop extends Model { const hasPayload = res && typeof res === 'object' && !Array.isArray(res) if (hasPayload && !supportsShowcaseFocus(mem, res)) { // The registry can lag a Golbat upgrade that dropped the legacy flag - // (its status was last read before the upgrade). One debounced - // re-read settles it before the verdict becomes a hard error. - await golbatCapabilities.recheck(mem) + // (its status was last read before the upgrade). Re-read it before + // the verdict becomes a hard error. This is refresh(), not the + // debounced recheck(): the upgrade may land seconds after a periodic + // status fetch, and this path is already throttled by the + // availability refresh window, so it cannot hammer Golbat. + await golbatCapabilities.refresh(mem) if (!supportsShowcaseFocus(mem, res)) { throw new Error( 'Golbat lacks the required showcase_focus filter capability', diff --git a/server/test/showcaseEndpointAvailability.test.js b/server/test/showcaseEndpointAvailability.test.js index d24c20c7f..690d3ee11 100644 --- a/server/test/showcaseEndpointAvailability.test.js +++ b/server/test/showcaseEndpointAvailability.test.js @@ -128,26 +128,45 @@ test('a Golbat without a filters block is judged by the legacy showcase_focus_fi assert.equal(sql.executions, 0) }) -test('an unsupported verdict rechecks the status route once before failing', async (t) => { - // Registry still says "no filters block" from before an upgrade that dropped - // the legacy flag; the recheck flips it and the same call succeeds. - let filters = null - t.mock.method(golbatCapabilities, 'advertisesFilters', () => filters !== null) - t.mock.method( - golbatCapabilities, - 'supportsFilter', - (_mem, key) => filters?.[key] === true, - ) - const rechecks = [] - t.mock.method(golbatCapabilities, 'recheck', async (mem) => { - rechecks.push(mem) - filters = { showcase_focus: true } +test('an unsupported verdict re-reads the status even seconds after the last fetch', async (t) => { + // Real registry, fake transport: the status route answers as an older + // Golbat at discovery, then as an upgraded build that dropped the legacy + // flag. The upgrade lands inside the recheck debounce window, so a + // debounced recheck would skip and the pass would throw. + let now = 1_000_000 + let statusReply = { status: 404 } + const statusCalls = [] + const originalFetch = golbatCapabilities.fetch + const originalNow = golbatCapabilities.now + t.after(() => { + golbatCapabilities.fetch = originalFetch + golbatCapabilities.now = originalNow + golbatCapabilities.instances = new Map() + golbatCapabilities.stop() }) + golbatCapabilities.now = () => now + golbatCapabilities.fetch = async (url) => { + statusCalls.push(url) + return { + ok: statusReply.status === 200, + status: statusReply.status, + statusText: '', + json: async () => statusReply.body, + } + } + await golbatCapabilities.discover([{ endpoint: MEM }]) + assert.equal(golbatCapabilities.advertisesFilters(MEM), false) + + statusReply = { + status: 200, + body: { features: {}, limits: {}, filters: { showcase_focus: true } }, + } + now += 10_000 const { Pokestop, sql } = loadPokestop(t, () => showcasePayload()) const result = await Pokestop.getAvailable(CONTEXT) - assert.deepEqual(rechecks, [MEM]) + assert.equal(statusCalls.length, 2) assert.deepEqual(result.available, ['b9', 'y3']) assert.equal(sql.executions, 0) })