untappd-mcp 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
+ import { mapCheckinRow } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
4
+ export const VENUE_DRINKS_DEFAULT_CALLS = 10;
5
+ export const VENUE_DRINKS_MAX_CALLS_CAP = 50;
6
+ const TOOL = 'untappd_sync_venue_drinks';
7
+ /** A check-in that no longer exists or isn't visible answers 404 (not a transient failure). */
8
+ function isGone(e) {
9
+ return /\b404\b|not found|does not exist/i.test(messageOf(e));
10
+ }
11
+ /**
12
+ * Opt-in "what did they drink there": for the user's cached user_venues rows,
13
+ * fetch the first and most-recent visit check-ins via checkin/view (works for
14
+ * any public check-in) and store the beer + date in `venue_drinks`. A PARTIAL
15
+ * picture by design — two check-ins per venue at most. Budgeted by `maxCalls`,
16
+ * with the stop-before-429 guard before every call; progress is stored per
17
+ * call, so a re-run continues with what is still pending.
18
+ */
19
+ export async function syncVenueDrinks(client, cache, username, opts) {
20
+ const budget = Math.max(1, Math.min(VENUE_DRINKS_MAX_CALLS_CAP, Math.floor(opts.maxCalls)));
21
+ const filter = { venueIds: opts.venueIds, which: opts.which ?? 'both' };
22
+ const candidates = await cache.venueDrinkCandidates(username, filter);
23
+ let calls = 0;
24
+ let stored = 0;
25
+ let unavailable = 0;
26
+ let errors = 0;
27
+ let firstError;
28
+ let rateStop;
29
+ for (const c of candidates) {
30
+ if (calls >= budget)
31
+ break;
32
+ const verdict = await checkRateBudget(client, cache);
33
+ if (!verdict.ok) {
34
+ rateStop = stopFromVerdict(verdict);
35
+ break;
36
+ }
37
+ calls++;
38
+ let data;
39
+ try {
40
+ data = await client.get(`/checkin/view/${c.checkin_id}`);
41
+ }
42
+ catch (e) {
43
+ if (e instanceof RateLimitError) {
44
+ rateStop = stopFrom429(e);
45
+ break;
46
+ }
47
+ if (isGone(e)) {
48
+ unavailable++;
49
+ await cache.upsertVenueDrinks(username, [{ venue_id: c.venue_id, checkin_id: c.checkin_id, bid: null, beer_name: null, brewery_id: null, brewery_name: null, created_at: null }], new Date().toISOString());
50
+ }
51
+ else {
52
+ errors++;
53
+ firstError ??= messageOf(e);
54
+ }
55
+ continue;
56
+ }
57
+ // Only the beer, brewery and date are kept — never the comment (other people's free text).
58
+ const r = mapCheckinRow(username, data?.checkin);
59
+ const row = {
60
+ venue_id: c.venue_id,
61
+ checkin_id: c.checkin_id,
62
+ bid: r?.bid ?? null,
63
+ beer_name: r?.beer_name ?? null,
64
+ brewery_id: r?.brewery_id ?? null,
65
+ brewery_name: r?.brewery_name ?? null,
66
+ created_at: r?.created_at ?? null,
67
+ };
68
+ if (row.bid === null && row.beer_name === null) {
69
+ // An unexpected shape — don't store a tombstone for it; count and retry later.
70
+ errors++;
71
+ firstError ??= `checkin/view ${c.checkin_id} had no beer`;
72
+ continue;
73
+ }
74
+ await cache.upsertVenueDrinks(username, [row], new Date().toISOString());
75
+ stored++;
76
+ }
77
+ const pending = (await cache.venueDrinkCandidates(username, filter)).length;
78
+ const another = pending > 0;
79
+ let note;
80
+ if (rateStop)
81
+ note = `Paused with ${pending} check-in(s) pending. ${resumeAdvice(rateStop, TOOL)}`;
82
+ else if (another)
83
+ note = `${pending} check-in(s) still pending — run ${TOOL} again to continue (max_calls per run).`;
84
+ else
85
+ note = 'Every selected venue\'s first/recent visit drink is cached; untappd_cache_has_visited lists them under matches[].drinks.';
86
+ if (errors > 0)
87
+ note += ` ${errors} fetch(es) failed and stay pending.`;
88
+ return {
89
+ username,
90
+ api_calls: calls,
91
+ stored,
92
+ unavailable,
93
+ errors,
94
+ pending,
95
+ another_run_needed: another,
96
+ stopped_for_rate_limit: rateStop !== undefined,
97
+ ...(rateStop ? { rate_limit: rateStop } : {}),
98
+ ...(firstError ? { first_error: firstError } : {}),
99
+ note,
100
+ };
101
+ }
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { loadDotenvSafely, readEnvVar, buildQueryString, formatApiError, createHelpfulError, McpToolError, RateLimitError, UnreachableError, } from '@chrischall/mcp-utils';
4
+ import { RateLimitTracker, computeRateLimitStatus, configuredHourlyLimit, observeResponse, } from './ratelimit.js';
4
5
  // Load .env for local (Node) dev; silently skip if dotenv is unavailable (e.g.
5
6
  // the mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and
6
7
  // never lets .env override a host-provided value. Guard on `import.meta.url`:
@@ -13,8 +14,6 @@ if (import.meta.url) {
13
14
  const BASE_URL = 'https://api.untappd.com/v4';
14
15
  const SERVICE = 'Untappd';
15
16
  const REQUEST_TIMEOUT_MS = 30_000;
16
- // Untappd's limit is per hour, so a remaining-count older than that says nothing.
17
- const QUOTA_WINDOW_MS = 60 * 60 * 1000;
18
17
  // Non-secret client constants that mimic the Untappd iPad app (v4.7.13). The
19
18
  // API accepts requests carrying these; NONE are user secrets (the secrets are
20
19
  // UNTAPPD_CLIENT_ID / UNTAPPD_CLIENT_SECRET / UNTAPPD_USERNAME / _PASSWORD,
@@ -153,8 +152,9 @@ export class UntappdClient {
153
152
  userAgent = readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
154
153
  token;
155
154
  loginInFlight = null;
156
- /** Last `X-Ratelimit-Remaining` seen, and when (see {@link quotaRemaining}). */
157
- lastQuota = null;
155
+ /** Every response this process received within the rolling hour (see src/ratelimit.ts). */
156
+ rateLimit = new RateLimitTracker();
157
+ apiCallListeners = [];
158
158
  tokenIsSupplied;
159
159
  /**
160
160
  * Defer the config error so the server can still start (and respond to the
@@ -211,22 +211,36 @@ export class UntappdClient {
211
211
  return this._loginName;
212
212
  }
213
213
  /**
214
- * Calls left this hour, from the most recent response's
215
- * `X-Ratelimit-Remaining` — or null when no response has carried the header
216
- * within the hour. Untappd's public API documents the header; whether the
217
- * private v4 endpoints send it is unverified, so every consumer must treat
218
- * null as "unknown" and fall back to its own budget.
214
+ * Estimated calls left this hour, or null before this process has made any
215
+ * call in the window. Rests on a LOCAL count of calls (the v4 API sends
216
+ * `X-Ratelimit-Remaining: 0` on every success, so the header is only believed
217
+ * once it has reported a live non-zero value, or on a 429) — see
218
+ * src/ratelimit.ts. Consumers must still treat null as "unknown".
219
219
  */
220
220
  get quotaRemaining() {
221
- if (!this.lastQuota || Date.now() - this.lastQuota.at > QUOTA_WINDOW_MS)
221
+ const obs = this.rateLimit.observations();
222
+ if (obs.length === 0)
222
223
  return null;
223
- return this.lastQuota.remaining;
224
+ return computeRateLimitStatus(obs, Date.now(), { configuredLimit: configuredHourlyLimit() }).estimated_remaining;
225
+ }
226
+ /**
227
+ * Subscribe to every API response (status + rate-limit headers). The stdio
228
+ * server uses it to persist the call log into the shared cache file, so other
229
+ * processes (and the next one) know what this hour has already spent.
230
+ */
231
+ onApiCall(listener) {
232
+ this.apiCallListeners.push(listener);
224
233
  }
225
234
  noteQuota(res) {
226
- const raw = res.headers.get('x-ratelimit-remaining');
227
- const n = raw === null ? NaN : Number(raw);
228
- if (raw !== null && raw.trim() !== '' && Number.isFinite(n) && n >= 0) {
229
- this.lastQuota = { remaining: Math.floor(n), at: Date.now() };
235
+ const o = observeResponse(res.status, res.headers);
236
+ this.rateLimit.record(o);
237
+ for (const l of this.apiCallListeners) {
238
+ try {
239
+ l(o);
240
+ }
241
+ catch {
242
+ /* a listener failure must never fail the API call */
243
+ }
230
244
  }
231
245
  }
232
246
  /** App creds for a write; reached only once a token is in hand. */
package/dist/compact.js CHANGED
@@ -75,36 +75,6 @@ export function compactUserBeer(item) {
75
75
  last_had: i.recent_created_at,
76
76
  };
77
77
  }
78
- /**
79
- * Slim one `/brewery/beer_list/` item.
80
- *
81
- * This endpoint needs its OWN projector rather than reusing
82
- * {@link compactBeerResult}, and the reason is easy to miss: the item shape IS
83
- * `{beer:{…}, brewery:{…}}`-wrapped, so `beerCore` fits perfectly — but the two
84
- * fields the search projector adds on top of it are named differently here.
85
- * `/search/beer` says `checkin_count` / `have_had`; `/brewery/beer_list/` says
86
- * `total_count` / `has_had`. Reusing the search projector would therefore have
87
- * emitted a page of records carrying two silent `undefined`s — a beer with no
88
- * check-ins that you have never had — and `projectItems`' drift guard cannot
89
- * catch that, because the CONTAINER is exactly where it expects it to be.
90
- * Established against a live capture of brewery 1142, not by inference.
91
- *
92
- * Rating rides along because the tool's own description promises "per-beer
93
- * rating and check-in counts"; a compact rung that dropped them would
94
- * contradict the thing the caller was told they were getting.
95
- */
96
- export function compactBreweryBeer(item) {
97
- const i = asDict(item) ?? {};
98
- const beer = asDict(i.beer) ?? {};
99
- return {
100
- ...beerCore(i),
101
- rating: beer.rating_score,
102
- rating_count: beer.rating_count,
103
- checkin_count: i.total_count,
104
- have_had: i.has_had,
105
- your_count: i.total_user_count,
106
- };
107
- }
108
78
  /**
109
79
  * Project `<container>.items` in a response with `mapFn`, preserving the rest of
110
80
  * the container (pagination etc.). Drift-safe: returns the raw response
@@ -125,20 +95,57 @@ export const compactBeerSearch = (resp) => projectItems(resp, 'beers', compactBe
125
95
  export const compactWishlist = (resp) => projectItems(resp, 'beers', compactWishlistBeer);
126
96
  export const compactUserBeers = (resp) => projectItems(resp, 'beers', compactUserBeer);
127
97
  /**
128
- * Project a brewery beer list, and drop `sorting_options` with it.
98
+ * The ONE compact shape of `untappd_brewery_beers`, whether its rows came from
99
+ * Untappd just now or from the local cache — so a caller never sees different
100
+ * keys depending on whether the cache happened to open. Both paths first map
101
+ * the page through `mapBreweryBeerItem` (store.ts), which is where the
102
+ * endpoint's own naming is read: `/brewery/beer_list/` says `total_count` (not
103
+ * `checkin_count`) and `has_had` (not `have_had`), established against a live
104
+ * capture of brewery 1142.
129
105
  *
130
- * `sorting_options` is 16 `{sort_key, sort_name}` pairs of UI chrome repeated on
131
- * every page. The tool's `sort` parameter is already a closed enum on its input
132
- * schema, so the list tells a caller nothing they were not handed with the
133
- * schema — it is pure duplication of a contract they already hold.
106
+ * It evolves the old live-only projection as a superset: every item keeps
107
+ * bid/name/style/abv/ibu/brewery/rating/rating_count/checkin_count and gains
108
+ * in_production (+ favorite when the cache marks one). Two old item fields are
109
+ * gone on purpose: `have_had` / `your_count` were the item's `has_had` /
110
+ * `total_user_count`, which describe the AUTHENTICATED account, never the user
111
+ * being asked about. The old top-level `total_count` and `beers.count` stay;
112
+ * Untappd's `sorting_options` (UI chrome duplicating the input enum) stays
113
+ * dropped; `pagination` is ours, so paging reads the same off either path.
134
114
  */
135
- export function compactBreweryBeers(resp) {
136
- const projected = projectItems(resp, 'beers', compactBreweryBeer);
137
- // projectItems returns the SAME object on drift; don't reshape that.
138
- if (projected === resp)
139
- return resp;
140
- const { sorting_options: _dropped, ...rest } = projected;
141
- return rest;
115
+ export function compactBreweryBeerList(r, page, favorites) {
116
+ const count = r.beers.length;
117
+ const more = r.total_count !== null ? page.offset + count < r.total_count : count > 0 && count >= page.limit;
118
+ return {
119
+ brewery_id: r.brewery_id,
120
+ brewery_name: r.brewery_name,
121
+ total_count: r.total_count,
122
+ source: r.source,
123
+ fetched_at: r.fetched_at,
124
+ expires_at: r.expires_at,
125
+ // Always present (null when favorites aren't known: no cache, or no username) so
126
+ // the cached and no-cache paths return the same keys.
127
+ favorite: favorites ? favorites.brewery : null,
128
+ pagination: { offset: page.offset, limit: page.limit, next_offset: more ? page.offset + count : null },
129
+ beers: {
130
+ count,
131
+ items: r.beers.map((b) => {
132
+ const fav = favorites?.beer(b.bid);
133
+ return {
134
+ bid: b.bid,
135
+ name: b.beer_name,
136
+ style: b.style,
137
+ abv: b.abv,
138
+ ibu: b.ibu,
139
+ brewery: r.brewery_name,
140
+ rating: b.rating_score,
141
+ rating_count: b.rating_count,
142
+ checkin_count: b.checkin_count,
143
+ in_production: b.in_production,
144
+ ...(fav !== undefined ? { favorite: fav } : {}),
145
+ };
146
+ }),
147
+ },
148
+ };
142
149
  }
143
150
  /**
144
151
  * The rungs this server honours (`@chrischall/mcp-utils`' `view` vocabulary,
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ import { registerWishlistTools } from './tools/wishlist.js';
14
14
  import { registerCheckinTools } from './tools/checkin.js';
15
15
  import { registerUtilityTools } from './tools/utilities.js';
16
16
  import { registerCacheTools } from './tools/cache.js';
17
+ import { registerFavoriteTools } from './tools/favorites.js';
18
+ import { registerTripTools } from './tools/trip.js';
17
19
  import { registerMenuTools } from './tools/menu.js';
18
20
  import { logRegisteredTools } from './tools/diagnostics.js';
19
21
  import { CheckinCache, defaultCachePath, describeCachePath } from './cache/db.js';
@@ -28,15 +30,28 @@ const client = new UntappdClient();
28
30
  const cachePath = defaultCachePath();
29
31
  console.error(describeCachePath(cachePath));
30
32
  let nodeCache;
31
- const nodeCacheProvider = () => (nodeCache ??= CheckinCache.open(cachePath));
33
+ const nodeCacheProvider = () => {
34
+ if (!nodeCache) {
35
+ nodeCache = CheckinCache.open(cachePath);
36
+ // Hand the shared call log what this process already spent before the cache opened.
37
+ void nodeCache.recordApiCalls?.(client.rateLimit.observations()).catch(() => { });
38
+ }
39
+ return nodeCache;
40
+ };
41
+ // Persist every API response's rate-limit observation into the shared cache file
42
+ // (once it is open), so a second process — or the next one — counts this hour's
43
+ // calls too (src/ratelimit.ts). Recording never fails an API call.
44
+ client.onApiCall((o) => {
45
+ void nodeCache?.recordApiCalls?.([o]).catch(() => { });
46
+ });
32
47
  await runMcp({
33
48
  name: 'untappd-mcp',
34
49
  version: VERSION,
35
50
  banner: '[untappd-mcp] This project was developed and is maintained by AI (Claude Opus 4.8). Use at your own discretion.',
36
51
  tools: [
37
52
  (s) => registerBeerTools(s, client, nodeCacheProvider),
38
- (s) => registerBreweryTools(s, client),
39
- (s) => registerVenueTools(s, client),
53
+ (s) => registerBreweryTools(s, client, nodeCacheProvider),
54
+ (s) => registerVenueTools(s, client, nodeCacheProvider),
40
55
  (s) => registerUserTools(s, client),
41
56
  (s) => registerFeedTools(s, client),
42
57
  (s) => registerResolveTools(s, client),
@@ -48,6 +63,8 @@ await runMcp({
48
63
  // The cache is backed by a local `node:sqlite` file here; the provider is
49
64
  // injectable, so another deployment can back the same tools differently.
50
65
  (s) => registerCacheTools(s, client, nodeCacheProvider),
66
+ (s) => registerFavoriteTools(s, client, nodeCacheProvider),
67
+ (s) => registerTripTools(s, client, nodeCacheProvider),
51
68
  (s) => registerMenuTools(s, client, nodeCacheProvider),
52
69
  // Keep last: logs the full registered toolset (count + names) at startup.
53
70
  (s) => logRegisteredTools(s, 'stdio'),
package/dist/menu/pick.js CHANGED
@@ -92,7 +92,11 @@ export async function ensureFresh(client, cache, username, maxCalls) {
92
92
  const prior = await cache.getState(username);
93
93
  const mode = prior?.beers_complete ? 'incremental' : 'backfill';
94
94
  let calls = 0;
95
- const counting = { get: (p, q) => (calls++, client.get(p, q)) };
95
+ const counting = {
96
+ get: (p, q) => (calls++, client.get(p, q)),
97
+ // Hand the sync's rate guard the real tracker (absent on test fakes).
98
+ rateLimit: client.rateLimit,
99
+ };
96
100
  const extra = {};
97
101
  if (maxCalls < 1) {
98
102
  extra.skipped = 'no API budget left for a sync this run';
@@ -102,6 +106,8 @@ export async function ensureFresh(client, cache, username, maxCalls) {
102
106
  const s = await syncUserBeers(counting, cache, username, maxCalls);
103
107
  if (s.incremental)
104
108
  extra.stopped_at_bid = s.stopped_at_bid;
109
+ if (s.stopped_for_rate_limit)
110
+ extra.rate_limited = true;
105
111
  }
106
112
  catch (e) {
107
113
  if (e instanceof RateLimitError)
@@ -0,0 +1,236 @@
1
+ // Rate-limit tracking for the ~100 calls/hour Untappd v4 limit.
2
+ //
3
+ // Every response the client receives is recorded as an ApiCallObservation
4
+ // (time, status, and whatever X-Ratelimit-* / Retry-After headers it carried).
5
+ // The headers alone can't be trusted: the v4 API sends `X-Ratelimit-Remaining:
6
+ // 0` on EVERY successful response (observed live 2026-11-02), so a header-only
7
+ // reading would report the quota exhausted after the first call. The estimate
8
+ // therefore rests on a LOCAL count of calls in the rolling hour, and only takes
9
+ // the header into account once it has reported a live (non-zero) value — or
10
+ // when Untappd actually answered 429.
11
+ //
12
+ // The stdio server also persists observations into the shared cache file
13
+ // (`api_calls` table), so a fresh process — or a second one sharing the file —
14
+ // knows what was already spent this hour. `checkRateBudget` merges both views
15
+ // and is the guard every paging sync consults before each page, so it can stop
16
+ // cleanly with resume info instead of failing mid-batch on a 429.
17
+ import { readEnvVar } from '@chrischall/mcp-utils';
18
+ export const RATE_WINDOW_MS = 60 * 60 * 1000;
19
+ /** Untappd's documented per-token limit; override with UNTAPPD_RATE_LIMIT_PER_HOUR. */
20
+ export const DEFAULT_HOURLY_LIMIT = 100;
21
+ /** Calls the guard leaves unspent so interactive tools still work after a sync. */
22
+ export const DEFAULT_RESERVE = 2;
23
+ function intHeader(h, name) {
24
+ const raw = h.get(name);
25
+ if (raw === null || raw.trim() === '')
26
+ return null;
27
+ const n = Number(raw);
28
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
29
+ }
30
+ /** Build an observation from a response's status and headers. */
31
+ export function observeResponse(status, headers, now = Date.now()) {
32
+ const limit = intHeader(headers, 'x-ratelimit-limit');
33
+ const reset = intHeader(headers, 'x-ratelimit-reset');
34
+ let reset_at = null;
35
+ if (reset !== null) {
36
+ if (reset > 1e12)
37
+ reset_at = reset; // epoch ms
38
+ else if (reset > 1e9)
39
+ reset_at = reset * 1000; // epoch seconds
40
+ else
41
+ reset_at = now + reset * 1000; // seconds from now
42
+ }
43
+ const ra = intHeader(headers, 'retry-after');
44
+ return {
45
+ at: now,
46
+ status,
47
+ remaining: intHeader(headers, 'x-ratelimit-remaining'),
48
+ limit: limit !== null && limit > 0 ? limit : null,
49
+ reset_at,
50
+ retry_after_s: ra !== null && ra > 0 ? ra : null,
51
+ };
52
+ }
53
+ /** UNTAPPD_RATE_LIMIT_PER_HOUR, when set to a positive integer. */
54
+ export function configuredHourlyLimit() {
55
+ const raw = readEnvVar('UNTAPPD_RATE_LIMIT_PER_HOUR');
56
+ const n = raw === undefined || raw === null ? NaN : Number(raw);
57
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
58
+ }
59
+ const iso = (ms) => (ms === null ? null : new Date(ms).toISOString());
60
+ function compute(all, now, opts) {
61
+ const obs = all.filter((o) => o.at > now - RATE_WINDOW_MS).sort((a, b) => a.at - b.at);
62
+ const latestWith = (k) => {
63
+ for (let i = obs.length - 1; i >= 0; i--)
64
+ if (obs[i][k] !== null)
65
+ return obs[i];
66
+ return undefined;
67
+ };
68
+ const headerLimit = latestWith('limit')?.limit ?? null;
69
+ const configured = opts.configuredLimit ?? null;
70
+ const hourly_limit = configured ?? headerLimit ?? DEFAULT_HOURLY_LIMIT;
71
+ const limit_source = configured !== null ? 'env' : headerLimit !== null ? 'header' : 'assumed';
72
+ const lastRemaining = latestWith('remaining');
73
+ const headerRemaining = lastRemaining?.remaining ?? null;
74
+ const headerLive = obs.some((o) => o.remaining !== null && o.remaining > 0);
75
+ const resetMs = lastRemaining?.reset_at ?? null;
76
+ const headerTrusted = lastRemaining !== undefined && (headerLive || lastRemaining.status === 429) && (resetMs === null || resetMs > now);
77
+ let blockedUntilMs = null;
78
+ let last429 = null;
79
+ for (const o of obs) {
80
+ if (o.status !== 429)
81
+ continue;
82
+ last429 = o.at;
83
+ const until = o.retry_after_s !== null
84
+ ? o.at + o.retry_after_s * 1000
85
+ : o.reset_at ?? (obs[0] ? obs[0].at + RATE_WINDOW_MS : o.at + RATE_WINDOW_MS);
86
+ if (until > now && (blockedUntilMs === null || until > blockedUntilMs))
87
+ blockedUntilMs = until;
88
+ }
89
+ const local = hourly_limit - obs.length;
90
+ let estimate = local;
91
+ if (headerTrusted && headerRemaining !== null)
92
+ estimate = Math.min(estimate, headerRemaining);
93
+ if (blockedUntilMs !== null)
94
+ estimate = 0;
95
+ return {
96
+ hourly_limit,
97
+ limit_source,
98
+ calls_last_hour: obs.length,
99
+ estimated_remaining: Math.max(0, estimate),
100
+ header_remaining: headerRemaining,
101
+ header_remaining_trusted: headerTrusted,
102
+ header_reset_at: iso(resetMs),
103
+ last_response_at: iso(obs.length ? obs[obs.length - 1].at : null),
104
+ last_429_at: iso(last429),
105
+ blocked_until: iso(blockedUntilMs),
106
+ times: obs.map((o) => o.at),
107
+ blockedUntilMs,
108
+ headerBoundResetMs: headerTrusted ? resetMs : null,
109
+ };
110
+ }
111
+ /** The rate-limit picture for a set of observations (pure; for tests and the status tool). */
112
+ export function computeRateLimitStatus(obs, now, opts) {
113
+ const { times: _t, blockedUntilMs: _b, headerBoundResetMs: _h, ...status } = compute(obs, now, opts);
114
+ return status;
115
+ }
116
+ /** In-memory record of this process's responses within the rolling hour. */
117
+ export class RateLimitTracker {
118
+ obs = [];
119
+ record(o) {
120
+ this.obs.push(o);
121
+ this.prune(o.at);
122
+ }
123
+ observations(now = Date.now()) {
124
+ this.prune(now);
125
+ return [...this.obs];
126
+ }
127
+ prune(now) {
128
+ const cutoff = now - RATE_WINDOW_MS;
129
+ if (this.obs.length && this.obs[0].at <= cutoff)
130
+ this.obs = this.obs.filter((o) => o.at > cutoff);
131
+ }
132
+ }
133
+ /** This process's observations merged with the shared cache log, de-duplicated. */
134
+ async function mergedObservations(client, cache, now) {
135
+ const tracker = client.rateLimit;
136
+ const mine = tracker?.observations(now) ?? [];
137
+ let theirs = [];
138
+ try {
139
+ theirs = (await cache?.recentApiCalls?.(now - RATE_WINDOW_MS)) ?? [];
140
+ }
141
+ catch {
142
+ /* an unreadable log just means "this process's view only" */
143
+ }
144
+ // The shared log normally already holds this process's own calls (the stdio
145
+ // server persists each one), so merge as a multiset: per identical
146
+ // observation, keep the larger of the two counts rather than summing them.
147
+ // Two genuinely separate calls in the same millisecond stay two.
148
+ const key = (o) => `${o.at}:${o.status}:${o.remaining}:${o.retry_after_s}`;
149
+ const group = (xs) => {
150
+ const m = new Map();
151
+ for (const o of xs)
152
+ m.set(key(o), [...(m.get(key(o)) ?? []), o]);
153
+ return m;
154
+ };
155
+ const a = group(mine);
156
+ const b = group(theirs);
157
+ const out = [];
158
+ for (const k of new Set([...a.keys(), ...b.keys()])) {
159
+ const x = a.get(k) ?? [];
160
+ const y = b.get(k) ?? [];
161
+ out.push(...(x.length >= y.length ? x : y));
162
+ }
163
+ return out;
164
+ }
165
+ /** The current rate-limit picture (this process + the shared cache log). */
166
+ export async function rateLimitStatus(client, cache) {
167
+ const now = Date.now();
168
+ return computeRateLimitStatus(await mergedObservations(client, cache, now), now, {
169
+ configuredLimit: configuredHourlyLimit(),
170
+ });
171
+ }
172
+ /**
173
+ * The stop-before-429 guard: may a sync spend `needed` more calls and still
174
+ * leave `reserve` unspent? Paging syncs call it before EVERY page and stop
175
+ * cleanly (progress is persisted per page) with `resume_at` when it says no.
176
+ */
177
+ export async function checkRateBudget(client, cache, opts = {}) {
178
+ const needed = opts.needed ?? 1;
179
+ const reserve = opts.reserve ?? DEFAULT_RESERVE;
180
+ const now = Date.now();
181
+ const c = compute(await mergedObservations(client, cache, now), now, { configuredLimit: configuredHourlyLimit() });
182
+ const base = {
183
+ estimated_remaining: c.estimated_remaining,
184
+ calls_last_hour: c.calls_last_hour,
185
+ hourly_limit: c.hourly_limit,
186
+ reserve,
187
+ };
188
+ if (c.estimated_remaining - needed >= reserve) {
189
+ return { ok: true, ...base, resume_at: null, resume_after_s: null };
190
+ }
191
+ let resumeMs = null;
192
+ if (c.blockedUntilMs !== null)
193
+ resumeMs = c.blockedUntilMs;
194
+ else if (c.headerBoundResetMs !== null)
195
+ resumeMs = c.headerBoundResetMs;
196
+ else {
197
+ // Rolling window: wait until enough of the oldest calls age out.
198
+ const deficit = needed + reserve - (c.hourly_limit - c.calls_last_hour);
199
+ if (deficit > 0 && deficit <= c.times.length)
200
+ resumeMs = c.times[deficit - 1] + RATE_WINDOW_MS;
201
+ }
202
+ return {
203
+ ok: false,
204
+ ...base,
205
+ resume_at: iso(resumeMs),
206
+ resume_after_s: resumeMs === null ? null : Math.max(0, Math.ceil((resumeMs - now) / 1000)),
207
+ reason: c.blockedUntilMs !== null
208
+ ? 'Untappd answered 429 (rate limited) and its wait has not elapsed yet.'
209
+ : `Stopped before Untappd's hourly rate limit: ~${c.estimated_remaining} of ${c.hourly_limit} calls left this hour, ${reserve} held in reserve.`,
210
+ };
211
+ }
212
+ /** A guard verdict as a sync's stop record. */
213
+ export function stopFromVerdict(v) {
214
+ return {
215
+ reason: v.reason ?? 'rate limit',
216
+ estimated_remaining: v.estimated_remaining,
217
+ calls_last_hour: v.calls_last_hour,
218
+ hourly_limit: v.hourly_limit,
219
+ resume_at: v.resume_at,
220
+ resume_after_s: v.resume_after_s,
221
+ };
222
+ }
223
+ /** A 429 that slipped past the guard, as a sync's stop record. */
224
+ export function stopFrom429(e, now = Date.now()) {
225
+ const ra = e.retryAfterSeconds;
226
+ return {
227
+ reason: 'Untappd answered 429 (rate limited); progress up to the last full page is saved.',
228
+ resume_at: ra !== undefined ? new Date(now + ra * 1000).toISOString() : null,
229
+ resume_after_s: ra ?? null,
230
+ };
231
+ }
232
+ /** One-line resume advice for a sync's `note`. */
233
+ export function resumeAdvice(stop, tool) {
234
+ const when = stop.resume_at ? ` after ${stop.resume_at} (~${Math.ceil((stop.resume_after_s ?? 0) / 60)} min)` : ' later';
235
+ return `${stop.reason} Progress is saved — run ${tool} again${when} to resume.`;
236
+ }