instagram-mcp-server 1.6.6

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.
Files changed (59) hide show
  1. package/.env.example +2 -0
  2. package/.github/dependabot.yml +50 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/release.yml +200 -0
  5. package/CONTRIBUTING.md +38 -0
  6. package/LICENSE +21 -0
  7. package/Makefile +16 -0
  8. package/README.md +141 -0
  9. package/dist/client.d.ts +57 -0
  10. package/dist/client.js +140 -0
  11. package/dist/client.test.d.ts +10 -0
  12. package/dist/client.test.js +212 -0
  13. package/dist/errors.d.ts +12 -0
  14. package/dist/errors.js +87 -0
  15. package/dist/errors.test.d.ts +9 -0
  16. package/dist/errors.test.js +164 -0
  17. package/dist/first-comment.test.d.ts +10 -0
  18. package/dist/first-comment.test.js +56 -0
  19. package/dist/handlers.test.d.ts +23 -0
  20. package/dist/handlers.test.js +355 -0
  21. package/dist/index.d.ts +49 -0
  22. package/dist/index.js +627 -0
  23. package/dist/lib/index.d.ts +6 -0
  24. package/dist/lib/index.js +5 -0
  25. package/dist/lib/insights.d.ts +55 -0
  26. package/dist/lib/insights.js +59 -0
  27. package/dist/rate-limiter.d.ts +72 -0
  28. package/dist/rate-limiter.js +219 -0
  29. package/dist/rate-limiter.test.d.ts +1 -0
  30. package/dist/rate-limiter.test.js +153 -0
  31. package/dist/response.d.ts +24 -0
  32. package/dist/response.js +35 -0
  33. package/dist/response.test.d.ts +1 -0
  34. package/dist/response.test.js +71 -0
  35. package/dist/sanitize.d.ts +17 -0
  36. package/dist/sanitize.js +27 -0
  37. package/dist/sanitize.test.d.ts +1 -0
  38. package/dist/sanitize.test.js +43 -0
  39. package/dist/tools.test.d.ts +14 -0
  40. package/dist/tools.test.js +188 -0
  41. package/package.json +32 -0
  42. package/src/client.test.ts +285 -0
  43. package/src/client.ts +204 -0
  44. package/src/errors.test.ts +299 -0
  45. package/src/errors.ts +108 -0
  46. package/src/first-comment.test.ts +75 -0
  47. package/src/handlers.test.ts +460 -0
  48. package/src/index.ts +960 -0
  49. package/src/lib/index.ts +6 -0
  50. package/src/lib/insights.ts +182 -0
  51. package/src/rate-limiter.test.ts +185 -0
  52. package/src/rate-limiter.ts +257 -0
  53. package/src/response.test.ts +80 -0
  54. package/src/response.ts +43 -0
  55. package/src/sanitize.test.ts +52 -0
  56. package/src/sanitize.ts +35 -0
  57. package/src/tools.test.ts +251 -0
  58. package/tsconfig.json +15 -0
  59. package/vitest.config.ts +10 -0
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Public surface for non-MCP consumers.
3
+ */
4
+ export * from "./insights.js";
5
+ export { InstagramClient, InstagramApiError, createClient } from "../client.js";
6
+ export type { Credentials, GraphApiError } from "../client.js";
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Pure SENSE/insights functions for the Instagram Graph API.
3
+ *
4
+ * Same pattern as Facebook's `lib/insights.ts` — single source of truth
5
+ * for both the MCP server's tool handlers and the web app's
6
+ * `platform-insights.ts` orchestrator (importing via `@instagram-mcp/lib`).
7
+ */
8
+ import type { InstagramClient } from "../client.js";
9
+ import { withRetry } from "../rate-limiter.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // ig_get_account_insights
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface AccountInsightsArgs {
16
+ /** "day" | "week" | "days_28". Default: "day". */
17
+ period?: string;
18
+ /** Unix timestamp (string), inclusive. */
19
+ since?: string;
20
+ /** Unix timestamp (string), inclusive. */
21
+ until?: string;
22
+ }
23
+
24
+ export interface AccountInsightsResult {
25
+ period: string;
26
+ insights: unknown[];
27
+ }
28
+
29
+ const ACCOUNT_INSIGHTS_METRICS = "reach,profile_views,accounts_engaged";
30
+
31
+ export async function fetchAccountInsights(
32
+ client: InstagramClient,
33
+ args: AccountInsightsArgs = {},
34
+ ): Promise<AccountInsightsResult> {
35
+ const params: Record<string, string> = {
36
+ metric: ACCOUNT_INSIGHTS_METRICS,
37
+ metric_type: "total_value",
38
+ period: args.period || "day",
39
+ };
40
+ if (args.since) params.since = args.since;
41
+ if (args.until) params.until = args.until;
42
+
43
+ const response = await withRetry(() =>
44
+ client.get<{ data: unknown[] }>(`/${client.accountId}/insights`, params),
45
+ );
46
+
47
+ return { insights: response.data, period: params.period };
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // ig_get_post_insights
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface PostInsightsArgs {
55
+ mediaId: string;
56
+ }
57
+
58
+ export interface PostInsightsResult {
59
+ mediaId: string;
60
+ insights: unknown[];
61
+ }
62
+
63
+ const POST_INSIGHTS_METRICS =
64
+ "reach,likes,comments,shares,saved,total_interactions,views";
65
+
66
+ export async function fetchPostInsights(
67
+ client: InstagramClient,
68
+ args: PostInsightsArgs,
69
+ ): Promise<PostInsightsResult> {
70
+ if (!args.mediaId.trim()) throw new Error("mediaId cannot be empty");
71
+ const response = await withRetry(() =>
72
+ client.get<{ data: unknown[] }>(`/${args.mediaId}/insights`, {
73
+ metric: POST_INSIGHTS_METRICS,
74
+ }),
75
+ );
76
+ return { mediaId: args.mediaId, insights: response.data };
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // ig_get_audience_demographics
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export type DemographicsMetric =
84
+ | "follower_demographics"
85
+ | "engaged_audience_demographics"
86
+ | "reached_audience_demographics";
87
+
88
+ export type DemographicsBreakdown = "age" | "city" | "country" | "gender";
89
+
90
+ export interface AudienceDemographicsArgs {
91
+ metric?: DemographicsMetric;
92
+ breakdown?: DemographicsBreakdown;
93
+ }
94
+
95
+ export interface AudienceDemographicsResult {
96
+ demographics: unknown[];
97
+ }
98
+
99
+ export async function fetchAudienceDemographics(
100
+ client: InstagramClient,
101
+ args: AudienceDemographicsArgs = {},
102
+ ): Promise<AudienceDemographicsResult> {
103
+ const metric = args.metric || "follower_demographics";
104
+ const breakdown = args.breakdown || "country";
105
+
106
+ const response = await withRetry(() =>
107
+ client.get<{ data: unknown[] }>(`/${client.accountId}/insights`, {
108
+ metric,
109
+ period: "lifetime",
110
+ metric_type: "total_value",
111
+ breakdown,
112
+ }),
113
+ );
114
+
115
+ return { demographics: response.data };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Recent media list — derived helper used by the web orchestrator to compute
120
+ // per-post averages without making a separate ig_get_post_insights call per
121
+ // media. NOT exposed as an MCP tool today (the MCP server only fetches by
122
+ // mediaId), but we keep it here for parity with Facebook's fetchPageFeed.
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export interface RecentMediaArgs {
126
+ /** Default 10, max 100. */
127
+ limit?: number;
128
+ }
129
+
130
+ export interface IgMedia {
131
+ id: string;
132
+ mediaType?: string;
133
+ permalink?: string;
134
+ likeCount?: number;
135
+ commentsCount?: number;
136
+ reach?: number;
137
+ totalInteractions?: number;
138
+ }
139
+
140
+ export async function fetchRecentMedia(
141
+ client: InstagramClient,
142
+ args: RecentMediaArgs = {},
143
+ ): Promise<{ media: IgMedia[] }> {
144
+ const limit = Math.max(1, Math.min(args.limit || 10, 100));
145
+ const response = await withRetry(() =>
146
+ client.get<{
147
+ data: Array<{
148
+ id: string;
149
+ media_type?: string;
150
+ permalink?: string;
151
+ like_count?: number;
152
+ comments_count?: number;
153
+ insights?: {
154
+ data?: Array<{ name: string; values?: Array<{ value: number }> }>;
155
+ };
156
+ }>;
157
+ }>(`/${client.accountId}/media`, {
158
+ fields:
159
+ "id,media_type,permalink,like_count,comments_count," +
160
+ "insights.metric(reach,total_interactions)",
161
+ limit: String(limit),
162
+ }),
163
+ );
164
+
165
+ const media: IgMedia[] = (response.data || []).map((m) => {
166
+ const metricMap: Record<string, number> = {};
167
+ for (const x of m.insights?.data ?? []) {
168
+ metricMap[x.name] = x.values?.[0]?.value ?? 0;
169
+ }
170
+ return {
171
+ id: m.id,
172
+ mediaType: m.media_type,
173
+ permalink: m.permalink,
174
+ likeCount: m.like_count,
175
+ commentsCount: m.comments_count,
176
+ reach: metricMap.reach,
177
+ totalInteractions: metricMap.total_interactions,
178
+ };
179
+ });
180
+
181
+ return { media };
182
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ TokenBucket,
4
+ checkRateLimit,
5
+ PUBLISH_TOOL_NAMES,
6
+ withRetry,
7
+ __resetRateLimiter,
8
+ } from "./rate-limiter.js";
9
+
10
+ // Every test starts with an empty tenant bucket map so state from prior
11
+ // tests (especially "exhaust the bucket" cases) doesn't leak.
12
+ beforeEach(() => {
13
+ __resetRateLimiter();
14
+ });
15
+
16
+ describe("TokenBucket", () => {
17
+ it("allows consumption when tokens are available", () => {
18
+ const bucket = new TokenBucket({ maxTokens: 10, refillRate: 0.01 });
19
+ expect(bucket.tryConsume(1)).toBe(true);
20
+ });
21
+
22
+ it("rejects consumption when empty", () => {
23
+ const bucket = new TokenBucket({ maxTokens: 2, refillRate: 0.0001 });
24
+ expect(bucket.tryConsume(1)).toBe(true);
25
+ expect(bucket.tryConsume(1)).toBe(true);
26
+ expect(bucket.tryConsume(1)).toBe(false);
27
+ });
28
+
29
+ it("reports 0 wait when tokens available", () => {
30
+ const bucket = new TokenBucket({ maxTokens: 10, refillRate: 0.01 });
31
+ expect(bucket.msUntilAvailable(1)).toBe(0);
32
+ });
33
+
34
+ it("reports positive wait when empty", () => {
35
+ const bucket = new TokenBucket({ maxTokens: 1, refillRate: 0.001 });
36
+ bucket.tryConsume(1);
37
+ expect(bucket.msUntilAvailable(1)).toBeGreaterThan(0);
38
+ });
39
+
40
+ it("handles multi-token costs", () => {
41
+ const bucket = new TokenBucket({ maxTokens: 5, refillRate: 0.01 });
42
+ expect(bucket.tryConsume(3)).toBe(true);
43
+ expect(bucket.tryConsume(3)).toBe(false);
44
+ expect(bucket.tryConsume(2)).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("checkRateLimit", () => {
49
+ it("allows reads under the limit", () => {
50
+ const result = checkRateLimit("ig_get_comments");
51
+ expect(result.allowed).toBe(true);
52
+ });
53
+
54
+ it("allows publish under the limit", () => {
55
+ const result = checkRateLimit("ig_publish_photo");
56
+ expect(result.allowed).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("PUBLISH_TOOL_NAMES", () => {
61
+ it("contains all publish tools", () => {
62
+ expect(PUBLISH_TOOL_NAMES.has("ig_publish_photo")).toBe(true);
63
+ expect(PUBLISH_TOOL_NAMES.has("ig_publish_carousel")).toBe(true);
64
+ expect(PUBLISH_TOOL_NAMES.has("ig_publish_reel")).toBe(true);
65
+ });
66
+
67
+ it("does not contain SENSE tools", () => {
68
+ expect(PUBLISH_TOOL_NAMES.has("ig_get_comments")).toBe(false);
69
+ expect(PUBLISH_TOOL_NAMES.has("ig_get_account_insights")).toBe(false);
70
+ });
71
+
72
+ it("does not contain non-publish ACT tools", () => {
73
+ expect(PUBLISH_TOOL_NAMES.has("ig_reply_comment")).toBe(false);
74
+ expect(PUBLISH_TOOL_NAMES.has("ig_delete_comment")).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe("checkRateLimit — per-tenant isolation", () => {
79
+ it("two different tenants have independent buckets", () => {
80
+ // Consume 10 tokens from tenant A
81
+ for (let i = 0; i < 10; i++) {
82
+ const r = checkRateLimit("ig_get_comments", "account_A");
83
+ expect(r.allowed).toBe(true);
84
+ }
85
+ // Tenant B should still have a full bucket — unaffected by A
86
+ const rB = checkRateLimit("ig_get_comments", "account_B");
87
+ expect(rB.allowed).toBe(true);
88
+ });
89
+
90
+ it("exhausting one tenant's publish bucket doesn't affect another", () => {
91
+ // ig_publish_photo has 25 tokens/day; burn all of them on tenant A
92
+ for (let i = 0; i < 25; i++) {
93
+ const r = checkRateLimit("ig_publish_photo", "tenant_exhaust");
94
+ expect(r.allowed).toBe(true);
95
+ }
96
+ // 26th call from tenant A should be blocked
97
+ const over = checkRateLimit("ig_publish_photo", "tenant_exhaust");
98
+ expect(over.allowed).toBe(false);
99
+
100
+ // But a fresh tenant should still be allowed
101
+ const fresh = checkRateLimit("ig_publish_photo", "tenant_fresh");
102
+ expect(fresh.allowed).toBe(true);
103
+ });
104
+
105
+ it("same tenantKey shares the same bucket across calls", () => {
106
+ for (let i = 0; i < 25; i++) {
107
+ checkRateLimit("ig_publish_carousel", "same_key");
108
+ }
109
+ // 26th call on the same key must be blocked
110
+ const r = checkRateLimit("ig_publish_carousel", "same_key");
111
+ expect(r.allowed).toBe(false);
112
+ });
113
+
114
+ it("undefined tenantKey uses the shared default bucket", () => {
115
+ // Consuming with no tenantKey should hit the default bucket
116
+ for (let i = 0; i < 25; i++) {
117
+ checkRateLimit("ig_publish_reel");
118
+ }
119
+ const r = checkRateLimit("ig_publish_reel");
120
+ expect(r.allowed).toBe(false);
121
+
122
+ // Meanwhile a named tenant should still have its own full bucket
123
+ const named = checkRateLimit("ig_publish_reel", "still_fresh");
124
+ expect(named.allowed).toBe(true);
125
+ });
126
+
127
+ it("reads from one tenant do not drain another tenant's global bucket", () => {
128
+ // Burn 100 read calls on tenant A (half the global bucket)
129
+ for (let i = 0; i < 100; i++) {
130
+ checkRateLimit("ig_get_comments", "reader_A");
131
+ }
132
+ // Tenant B should still have its full 200-token bucket — 150 consecutive
133
+ // read calls must all succeed. If B shared A's depleted bucket, the
134
+ // 101st total read (or thereabouts) would be blocked.
135
+ let allowedCount = 0;
136
+ for (let i = 0; i < 150; i++) {
137
+ const r = checkRateLimit("ig_get_comments", "reader_B");
138
+ if (r.allowed) allowedCount++;
139
+ }
140
+ expect(allowedCount).toBe(150);
141
+ });
142
+ });
143
+
144
+ describe("withRetry", () => {
145
+ it("succeeds on first try without retrying", async () => {
146
+ const fn = vi.fn().mockResolvedValue("ok");
147
+ const result = await withRetry(fn);
148
+ expect(result).toBe("ok");
149
+ expect(fn).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it("throws non-429 errors immediately", async () => {
153
+ const err = new Error("not found");
154
+ const fn = vi.fn().mockRejectedValue(err);
155
+ await expect(withRetry(fn)).rejects.toThrow("not found");
156
+ expect(fn).toHaveBeenCalledTimes(1);
157
+ });
158
+
159
+ it("retries on 429 status errors", { timeout: 10_000 }, async () => {
160
+ const rate429 = Object.assign(new Error("rate limit"), { status: 429 });
161
+ const fn = vi.fn().mockRejectedValueOnce(rate429).mockResolvedValue("ok");
162
+ const result = await withRetry(fn);
163
+ expect(result).toBe("ok");
164
+ expect(fn).toHaveBeenCalledTimes(2);
165
+ });
166
+
167
+ it("retries on Graph API code 4 errors", { timeout: 10_000 }, async () => {
168
+ const graphErr = Object.assign(new Error("too many calls"), { code: 4 });
169
+ const fn = vi.fn().mockRejectedValueOnce(graphErr).mockResolvedValue("ok");
170
+ const result = await withRetry(fn);
171
+ expect(result).toBe("ok");
172
+ expect(fn).toHaveBeenCalledTimes(2);
173
+ });
174
+
175
+ it(
176
+ "gives up after max retries on persistent 429s",
177
+ { timeout: 30_000 },
178
+ async () => {
179
+ const rate429 = Object.assign(new Error("rate limit"), { status: 429 });
180
+ const fn = vi.fn().mockRejectedValue(rate429);
181
+ await expect(withRetry(fn)).rejects.toThrow("rate limit");
182
+ expect(fn).toHaveBeenCalledTimes(4); // initial + 3 retries
183
+ },
184
+ );
185
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Token-bucket rate limiter for Instagram Graph API.
3
+ *
4
+ * Instagram Graph API rate limits are per **Business Account**, not per app.
5
+ * This limiter keys buckets by the caller's `tenantKey` (the IG Business
6
+ * Account id) so that one dashboard user posting aggressively cannot
7
+ * exhaust another user's quota.
8
+ *
9
+ * Each tenant gets an independent pair of buckets:
10
+ * - globalBucket: 200 tokens / 1 hour (all API calls)
11
+ * - publishBucket: 25 tokens / 24 hours (photo / carousel / reel only)
12
+ *
13
+ * When `tenantKey` is undefined (single-tenant / env-based usage), all calls
14
+ * share a single default bucket pair under the sentinel key "__default__".
15
+ *
16
+ * Stale tenant entries are evicted after 4h of inactivity on every call to
17
+ * keep memory bounded for long-running multi-tenant servers.
18
+ *
19
+ * Also provides:
20
+ * - waitForRateLimit(): pre-flight check with optional wait
21
+ * - withRetry(): exponential backoff on server-side 429s
22
+ */
23
+
24
+ const ONE_HOUR_MS = 60 * 60 * 1000;
25
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
26
+ const MAX_WAIT_MS = 60_000;
27
+ const MAX_429_RETRIES = 3;
28
+ const TENANT_TTL_MS = 4 * 60 * 60 * 1000; // 4h matches client cache TTL
29
+
30
+ export class TokenBucket {
31
+ private tokens: number;
32
+ private lastRefill: number;
33
+ private readonly maxTokens: number;
34
+ private readonly refillRate: number; // tokens per ms
35
+
36
+ constructor(config: { maxTokens: number; refillRate: number }) {
37
+ this.maxTokens = config.maxTokens;
38
+ this.refillRate = config.refillRate;
39
+ this.tokens = config.maxTokens;
40
+ this.lastRefill = Date.now();
41
+ }
42
+
43
+ tryConsume(cost = 1): boolean {
44
+ this.refill();
45
+ if (this.tokens >= cost) {
46
+ this.tokens -= cost;
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ msUntilAvailable(cost = 1): number {
53
+ this.refill();
54
+ if (this.tokens >= cost) return 0;
55
+ const deficit = cost - this.tokens;
56
+ return Math.ceil(deficit / this.refillRate);
57
+ }
58
+
59
+ private refill(): void {
60
+ const now = Date.now();
61
+ const elapsed = now - this.lastRefill;
62
+ const newTokens = elapsed * this.refillRate;
63
+ this.tokens = Math.min(this.maxTokens, this.tokens + newTokens);
64
+ this.lastRefill = now;
65
+ }
66
+ }
67
+
68
+ // --- Per-tenant bucket storage ---
69
+
70
+ interface TenantBuckets {
71
+ global: TokenBucket;
72
+ publish: TokenBucket;
73
+ lastAccessed: number;
74
+ }
75
+
76
+ const tenantBuckets = new Map<string, TenantBuckets>();
77
+ const DEFAULT_TENANT_KEY = "__default__";
78
+
79
+ function newBucketPair(): TenantBuckets {
80
+ return {
81
+ global: new TokenBucket({
82
+ maxTokens: 200,
83
+ refillRate: 200 / ONE_HOUR_MS,
84
+ }),
85
+ publish: new TokenBucket({
86
+ maxTokens: 25,
87
+ refillRate: 25 / ONE_DAY_MS,
88
+ }),
89
+ lastAccessed: Date.now(),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Get (or lazily create) the bucket pair for a tenant. Evicts stale entries
95
+ * on every call so long-running multi-tenant servers don't leak memory.
96
+ */
97
+ function getBuckets(tenantKey: string | undefined): TenantBuckets {
98
+ const key = tenantKey || DEFAULT_TENANT_KEY;
99
+ const now = Date.now();
100
+
101
+ // Evict stale entries (except __default__ which is always kept)
102
+ for (const [k, v] of tenantBuckets) {
103
+ if (k !== DEFAULT_TENANT_KEY && now - v.lastAccessed > TENANT_TTL_MS) {
104
+ tenantBuckets.delete(k);
105
+ }
106
+ }
107
+
108
+ let entry = tenantBuckets.get(key);
109
+ if (!entry) {
110
+ entry = newBucketPair();
111
+ tenantBuckets.set(key, entry);
112
+ } else {
113
+ entry.lastAccessed = now;
114
+ }
115
+ return entry;
116
+ }
117
+
118
+ /**
119
+ * Test-only: wipe all tenant buckets. Used by unit tests to isolate cases.
120
+ */
121
+ export function __resetRateLimiter(): void {
122
+ tenantBuckets.clear();
123
+ }
124
+
125
+ // --- Publish tool detection ---
126
+
127
+ export const PUBLISH_TOOL_NAMES = new Set([
128
+ "ig_publish_photo",
129
+ "ig_publish_carousel",
130
+ "ig_publish_reel",
131
+ ]);
132
+
133
+ /**
134
+ * Check rate limits and consume tokens.
135
+ * Peek-then-consume: check all relevant buckets before consuming any.
136
+ *
137
+ * @param toolName Tool being invoked (used to detect publish cost)
138
+ * @param tenantKey Per-tenant key (typically the IG Business Account id).
139
+ * If omitted, uses a shared default bucket — fine for
140
+ * single-tenant / env-based usage.
141
+ * @param overrideCost Cost to consume (default 1)
142
+ */
143
+ export function checkRateLimit(
144
+ toolName?: string,
145
+ tenantKey?: string,
146
+ overrideCost?: number,
147
+ ): { allowed: true } | { allowed: false; retryAfterMs: number } {
148
+ const { global, publish } = getBuckets(tenantKey);
149
+ const isPublish = toolName ? PUBLISH_TOOL_NAMES.has(toolName) : false;
150
+ const cost = overrideCost ?? 1;
151
+
152
+ // Peek global bucket
153
+ const globalWait = global.msUntilAvailable(cost);
154
+ if (globalWait > 0) return { allowed: false, retryAfterMs: globalWait };
155
+
156
+ if (isPublish) {
157
+ const publishWait = publish.msUntilAvailable(cost);
158
+ if (publishWait > 0) return { allowed: false, retryAfterMs: publishWait };
159
+
160
+ // Consume both
161
+ global.tryConsume(cost);
162
+ publish.tryConsume(cost);
163
+ return { allowed: true };
164
+ }
165
+
166
+ // Read path: consume global only
167
+ if (!global.tryConsume(cost)) {
168
+ return {
169
+ allowed: false,
170
+ retryAfterMs: global.msUntilAvailable(cost),
171
+ };
172
+ }
173
+ return { allowed: true };
174
+ }
175
+
176
+ /**
177
+ * Pre-flight rate limit check. Waits up to 60s if bucket is near-empty.
178
+ * Returns DEFER guidance if wait would exceed 60s.
179
+ */
180
+ export async function waitForRateLimit(
181
+ toolName?: string,
182
+ tenantKey?: string,
183
+ overrideCost?: number,
184
+ ): Promise<{ allowed: true } | { allowed: false; retryAfterMs: number }> {
185
+ const check = checkRateLimit(toolName, tenantKey, overrideCost);
186
+ if (check.allowed) return check;
187
+
188
+ if (check.retryAfterMs <= MAX_WAIT_MS) {
189
+ await sleep(check.retryAfterMs);
190
+ return checkRateLimit(toolName, tenantKey, overrideCost);
191
+ }
192
+
193
+ return check;
194
+ }
195
+
196
+ /**
197
+ * Retry a function with exponential backoff on HTTP 429 errors.
198
+ * Also parses Retry-After header when available.
199
+ */
200
+ export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
201
+ for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
202
+ try {
203
+ return await fn();
204
+ } catch (e: unknown) {
205
+ const is429 = isRateLimitError(e);
206
+ if (!is429 || attempt === MAX_429_RETRIES) throw e;
207
+
208
+ // Honor Retry-After header if present
209
+ const retryAfter = extractRetryAfter(e);
210
+ const backoffMs = retryAfter ?? 2000 * Math.pow(2, attempt);
211
+
212
+ console.error(
213
+ `[rate-limit] Instagram 429 — backing off ${backoffMs / 1000}s (attempt ${attempt + 1}/${MAX_429_RETRIES})...`,
214
+ );
215
+ await sleep(backoffMs);
216
+ }
217
+ }
218
+ throw new Error("Unreachable");
219
+ }
220
+
221
+ function isRateLimitError(e: unknown): boolean {
222
+ if (typeof e !== "object" || e === null) return false;
223
+ const obj = e as Record<string, unknown>;
224
+
225
+ // Graph API error format: { error: { code: 4, ... } } or HTTP 429
226
+ if (obj.status === 429) return true;
227
+ if (typeof obj.code === "number" && (obj.code === 4 || obj.code === 32))
228
+ return true;
229
+
230
+ // Nested error object
231
+ if (typeof obj.error === "object" && obj.error !== null) {
232
+ const inner = obj.error as Record<string, unknown>;
233
+ if (inner.code === 4 || inner.code === 32) return true;
234
+ }
235
+
236
+ if (e instanceof Error) {
237
+ const msg = e.message.toLowerCase();
238
+ if (msg.includes("429") || msg.includes("rate limit")) return true;
239
+ }
240
+
241
+ return false;
242
+ }
243
+
244
+ function extractRetryAfter(e: unknown): number | null {
245
+ if (typeof e !== "object" || e === null) return null;
246
+ const obj = e as Record<string, unknown>;
247
+
248
+ // Check for retryAfter in error metadata
249
+ if (typeof obj.retryAfter === "number") return obj.retryAfter * 1000;
250
+ if (typeof obj.retryAfterMs === "number") return obj.retryAfterMs;
251
+
252
+ return null;
253
+ }
254
+
255
+ export function sleep(ms: number): Promise<void> {
256
+ return new Promise((resolve) => setTimeout(resolve, ms));
257
+ }