@thotischner/observability-mcp 3.8.2 → 3.8.3

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.
@@ -6,7 +6,27 @@ export type FetchLike = (url: string, init?: {
6
6
  ok: boolean;
7
7
  status: number;
8
8
  json: () => Promise<unknown>;
9
+ /** Optional — used to honor `Retry-After` on a throttle response. */
10
+ headers?: {
11
+ get(name: string): string | null;
12
+ };
9
13
  }>;
14
+ /** Why a lookup failed in a way that is NOT a stable property of the address —
15
+ * retrying later (or in a smaller batch) may succeed. Issue #523. */
16
+ export type RdapTransientReason = "rate_limited" | "timeout" | "upstream_error" | "network_error";
17
+ /** Outcome of a single RDAP lookup. `not_found` is a genuine negative (the
18
+ * address is not in any registry / carries no enrichment) and is safe to
19
+ * cache; `transient` is an upstream failure (throttle, timeout, 5xx) that must
20
+ * NOT be conflated with a negative and is never cached. */
21
+ export type RdapOutcome = {
22
+ status: "ok";
23
+ value: IpEnrichment;
24
+ } | {
25
+ status: "not_found";
26
+ } | {
27
+ status: "transient";
28
+ reason: RdapTransientReason;
29
+ };
10
30
  export interface RdapResolverOptions {
11
31
  /** Bootstrap base; rdap.org redirects to the authoritative RIR. */
12
32
  baseUrl?: string;
@@ -18,6 +38,14 @@ export interface RdapResolverOptions {
18
38
  fetch?: FetchLike;
19
39
  /** Max cache entries (LRU-ish trim). */
20
40
  maxCache?: number;
41
+ /** Retries on a TRANSIENT failure (throttle/timeout/5xx). Default 2. A true
42
+ * negative (404 / no-data) is never retried. */
43
+ maxRetries?: number;
44
+ /** Base backoff in ms; attempt N waits min(base * 2^N, 5000), unless the
45
+ * throttle response carries a usable `Retry-After`. Default 250. */
46
+ backoffMs?: number;
47
+ /** Injected sleep (tests pass a no-op to stay fast). Defaults to setTimeout. */
48
+ sleep?: (ms: number) => Promise<void>;
21
49
  }
22
50
  /** Parse an RDAP IP-network response into our enrichment shape. country +
23
51
  * org/name only; RDAP carries no city or hosting flag. */
@@ -29,12 +57,23 @@ export declare class RdapResolver {
29
57
  private readonly timeoutMs;
30
58
  private readonly fetch;
31
59
  private readonly maxCache;
60
+ private readonly maxRetries;
61
+ private readonly backoffMs;
62
+ private readonly sleep;
32
63
  private cache;
33
64
  /** Monotonic clock injected for tests; defaults to Date.now via a getter. */
34
65
  now: () => number;
35
66
  constructor(opts?: RdapResolverOptions);
36
- /** Look up one IP via RDAP. Returns null on miss/error (never throws
37
- * a flaky RIR must not fail the batch). Cached by IP with a TTL. */
67
+ /** Look up one IP via RDAP. Returns the enrichment on a hit, or null on a
68
+ * miss OR a transient failure (never throws). Back-compat shim callers
69
+ * that need to tell a true negative from a throttle should use {@link resolve}. */
38
70
  lookup(ip: string): Promise<IpEnrichment | null>;
71
+ /** Look up one IP via RDAP, distinguishing a genuine negative (`not_found`,
72
+ * cached) from a transient upstream failure (`transient`, never cached so a
73
+ * later retry can succeed). Bounded retry with backoff on transient. Never
74
+ * throws — a flaky RIR must not fail the batch (issue #523). */
75
+ resolve(ip: string): Promise<RdapOutcome>;
76
+ /** One RDAP HTTP attempt mapped to an outcome (+ a Retry-After hint). */
77
+ private attempt;
39
78
  private put;
40
79
  }
@@ -71,6 +71,9 @@ export class RdapResolver {
71
71
  timeoutMs;
72
72
  fetch;
73
73
  maxCache;
74
+ maxRetries;
75
+ backoffMs;
76
+ sleep;
74
77
  cache = new Map();
75
78
  /** Monotonic clock injected for tests; defaults to Date.now via a getter. */
76
79
  now;
@@ -81,34 +84,74 @@ export class RdapResolver {
81
84
  this.timeoutMs = opts.timeoutMs ?? 4000;
82
85
  this.fetch = opts.fetch ?? globalThis.fetch;
83
86
  this.maxCache = opts.maxCache ?? 10_000;
87
+ this.maxRetries = opts.maxRetries ?? 2;
88
+ this.backoffMs = opts.backoffMs ?? 250;
89
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
84
90
  this.now = () => Date.now();
85
91
  }
86
- /** Look up one IP via RDAP. Returns null on miss/error (never throws
87
- * a flaky RIR must not fail the batch). Cached by IP with a TTL. */
92
+ /** Look up one IP via RDAP. Returns the enrichment on a hit, or null on a
93
+ * miss OR a transient failure (never throws). Back-compat shim callers
94
+ * that need to tell a true negative from a throttle should use {@link resolve}. */
88
95
  async lookup(ip) {
96
+ const o = await this.resolve(ip);
97
+ return o.status === "ok" ? o.value : null;
98
+ }
99
+ /** Look up one IP via RDAP, distinguishing a genuine negative (`not_found`,
100
+ * cached) from a transient upstream failure (`transient`, never cached so a
101
+ * later retry can succeed). Bounded retry with backoff on transient. Never
102
+ * throws — a flaky RIR must not fail the batch (issue #523). */
103
+ async resolve(ip) {
89
104
  if (ipv4ToInt(ip) === null && ipv6ToBigInt(ip) === null)
90
- return null;
105
+ return { status: "not_found" };
91
106
  const cached = this.cache.get(ip);
92
- if (cached && cached.expiresAt > this.now())
93
- return cached.value;
94
- let value = null;
107
+ if (cached && cached.expiresAt > this.now()) {
108
+ return cached.value ? { status: "ok", value: cached.value } : { status: "not_found" };
109
+ }
110
+ for (let attempt = 0;; attempt++) {
111
+ const { outcome, retryAfterMs } = await this.attempt(ip);
112
+ if (outcome.status === "ok") {
113
+ this.put(ip, { value: outcome.value, expiresAt: this.now() + this.ttlMs });
114
+ return outcome;
115
+ }
116
+ if (outcome.status === "not_found") {
117
+ this.put(ip, { value: null, expiresAt: this.now() + this.negTtlMs });
118
+ return outcome;
119
+ }
120
+ // transient — retry with backoff, but never cache it as a negative.
121
+ if (attempt >= this.maxRetries)
122
+ return outcome;
123
+ const backoff = retryAfterMs ?? Math.min(this.backoffMs * 2 ** attempt, 5000);
124
+ await this.sleep(backoff);
125
+ }
126
+ }
127
+ /** One RDAP HTTP attempt mapped to an outcome (+ a Retry-After hint). */
128
+ async attempt(ip) {
129
+ const ac = new AbortController();
130
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
95
131
  try {
96
- const ac = new AbortController();
97
- const timer = setTimeout(() => ac.abort(), this.timeoutMs);
98
- try {
99
- const res = await this.fetch(`${this.baseUrl}/ip/${encodeURIComponent(ip)}`, { signal: ac.signal });
100
- if (res.ok)
101
- value = parseRdapResponse(await res.json());
132
+ const res = await this.fetch(`${this.baseUrl}/ip/${encodeURIComponent(ip)}`, { signal: ac.signal });
133
+ if (res.ok) {
134
+ const parsed = parseRdapResponse(await res.json());
135
+ // A 2xx with no country/org is a genuine "no enrichment for this IP".
136
+ return { outcome: parsed ? { status: "ok", value: parsed } : { status: "not_found" } };
102
137
  }
103
- finally {
104
- clearTimeout(timer);
138
+ // 429/403 are the throttle responses RIRs use; 5xx is upstream trouble —
139
+ // both are transient. 404 (and other malformed-query 4xx) is a genuine
140
+ // negative for this address.
141
+ if (res.status === 429 || res.status === 403) {
142
+ return { outcome: { status: "transient", reason: "rate_limited" }, retryAfterMs: retryAfter(res) };
105
143
  }
144
+ if (res.status >= 500)
145
+ return { outcome: { status: "transient", reason: "upstream_error" } };
146
+ return { outcome: { status: "not_found" } };
106
147
  }
107
148
  catch {
108
- value = null; // network/timeout/parse treat as a miss
149
+ // AbortController fired our own timeout; otherwise a network error.
150
+ return { outcome: { status: "transient", reason: ac.signal.aborted ? "timeout" : "network_error" } };
151
+ }
152
+ finally {
153
+ clearTimeout(timer);
109
154
  }
110
- this.put(ip, { value, expiresAt: this.now() + (value ? this.ttlMs : this.negTtlMs) });
111
- return value;
112
155
  }
113
156
  put(ip, entry) {
114
157
  if (this.cache.size >= this.maxCache) {
@@ -120,3 +163,14 @@ export class RdapResolver {
120
163
  this.cache.set(ip, entry);
121
164
  }
122
165
  }
166
+ /** Parse a `Retry-After` header (delta-seconds form) into ms, capped at 5s so a
167
+ * hostile/huge value can't stall a batch. Ignores the HTTP-date form. */
168
+ function retryAfter(res) {
169
+ const raw = res.headers?.get?.("retry-after");
170
+ if (!raw)
171
+ return undefined;
172
+ const secs = Number(raw.trim());
173
+ if (!Number.isFinite(secs) || secs < 0)
174
+ return undefined;
175
+ return Math.min(secs * 1000, 5000);
176
+ }
@@ -76,3 +76,85 @@ describe("RdapResolver", () => {
76
76
  assert.equal(await r.lookup("8.8.8.8"), null);
77
77
  });
78
78
  });
79
+ // Issue #523: a rate-limit / upstream failure must be distinguishable from a
80
+ // true negative, and must NOT poison the cache as one.
81
+ describe("RdapResolver.resolve — transient vs true negative (#523)", () => {
82
+ const noSleep = async () => { };
83
+ it("maps a 200 hit to ok", async () => {
84
+ const { fetch } = stubFetch(() => ({ ok: true, status: 200, body: RDAP_GOOGLE }));
85
+ const r = new RdapResolver({ fetch, sleep: noSleep });
86
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
87
+ });
88
+ it("maps a 404 to a true negative (not_found)", async () => {
89
+ const { fetch } = stubFetch(() => ({ ok: false, status: 404, body: {} }));
90
+ const r = new RdapResolver({ fetch, sleep: noSleep });
91
+ assert.deepEqual(await r.resolve("203.0.113.7"), { status: "not_found" });
92
+ });
93
+ it("maps a 200 with no country/org to not_found, not a bogus hit", async () => {
94
+ const { fetch } = stubFetch(() => ({ ok: true, status: 200, body: { handle: "x" } }));
95
+ const r = new RdapResolver({ fetch, sleep: noSleep });
96
+ assert.deepEqual(await r.resolve("203.0.113.8"), { status: "not_found" });
97
+ });
98
+ it("maps a 429 to transient:rate_limited after exhausting retries", async () => {
99
+ const { fetch, calls } = stubFetch(() => ({ ok: false, status: 429, body: {} }));
100
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 2 });
101
+ assert.deepEqual(await r.resolve("203.0.113.10"), { status: "transient", reason: "rate_limited" });
102
+ assert.equal(calls.length, 3, "1 initial + 2 retries");
103
+ });
104
+ it("maps a 5xx to transient:upstream_error", async () => {
105
+ const { fetch } = stubFetch(() => ({ ok: false, status: 503, body: {} }));
106
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 0 });
107
+ assert.deepEqual(await r.resolve("203.0.113.11"), { status: "transient", reason: "upstream_error" });
108
+ });
109
+ it("maps a thrown network error to transient:network_error", async () => {
110
+ const r = new RdapResolver({ fetch: (async () => { throw new Error("ECONNRESET"); }), sleep: noSleep, maxRetries: 0 });
111
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "transient", reason: "network_error" });
112
+ });
113
+ it("does NOT cache a transient failure — a later success resolves", async () => {
114
+ let mode = "throttle";
115
+ const calls = [];
116
+ const fetch = async (url) => {
117
+ calls.push(url);
118
+ return mode === "throttle"
119
+ ? { ok: false, status: 429, json: async () => ({}) }
120
+ : { ok: true, status: 200, json: async () => RDAP_GOOGLE };
121
+ };
122
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 0 });
123
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "transient", reason: "rate_limited" });
124
+ mode = "ok";
125
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
126
+ assert.equal(calls.length, 2, "transient was not cached, so the retry re-fetched");
127
+ });
128
+ it("retries a transient then succeeds, returning ok", async () => {
129
+ let n = 0;
130
+ const fetch = async () => {
131
+ n++;
132
+ return n === 1
133
+ ? { ok: false, status: 429, json: async () => ({}) }
134
+ : { ok: true, status: 200, json: async () => RDAP_GOOGLE };
135
+ };
136
+ const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 2 });
137
+ assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
138
+ assert.equal(n, 2, "succeeded on the first retry");
139
+ });
140
+ it("honors a numeric Retry-After for backoff (capped)", async () => {
141
+ const slept = [];
142
+ let n = 0;
143
+ const fetch = async () => {
144
+ n++;
145
+ if (n === 1)
146
+ return { ok: false, status: 429, json: async () => ({}), headers: { get: (h) => (h.toLowerCase() === "retry-after" ? "2" : null) } };
147
+ return { ok: true, status: 200, json: async () => RDAP_GOOGLE };
148
+ };
149
+ const r = new RdapResolver({ fetch, sleep: async (ms) => { slept.push(ms); }, maxRetries: 1 });
150
+ await r.resolve("8.8.8.8");
151
+ assert.deepEqual(slept, [2000], "waited the Retry-After delta (2s) before retrying");
152
+ });
153
+ it("caches a true negative (no re-fetch within negTtl)", async () => {
154
+ const { fetch, calls } = stubFetch(() => ({ ok: false, status: 404, body: {} }));
155
+ const r = new RdapResolver({ fetch, sleep: noSleep });
156
+ await r.resolve("203.0.113.7");
157
+ await r.resolve("203.0.113.7");
158
+ assert.equal(calls.length, 1, "negative cached");
159
+ });
160
+ });
package/dist/index.js CHANGED
@@ -794,6 +794,7 @@ async function main() {
794
794
  "Resolve a batch of IPv4 or IPv6 addresses to geo (country/city), ASN/org, and a hosting/proxy flag.",
795
795
  "When to use: answering 'where are these visitors from?' or 'which of these IPs are bots / datacenter / VPN exit nodes?' over access logs, without an out-of-band geo-API call per IP. Both IPv4 and IPv6 clients are resolved — don't pre-filter v6 out.",
796
796
  "Behavior: read-only. By default looks each IP up in a LOCAL offline dataset the operator configured (OMCP_IP_ENRICH_FILE) with NO external network call — safe in air-gapped deployments. Optionally, if the operator enabled OMCP_IP_ENRICH_RDAP, IPs the dataset doesn't cover fall back to an online RDAP query (country/org only) and the result carries via:'rdap'; the offline dataset is always preferred. Returns one row per input IP with found=true/false plus any known fields. If neither is configured it returns a clear notice explaining how to enable them.",
797
+ "RDAP rate-limits: a row with found=false AND transient:true (error names the cause, e.g. 'rate_limited') is NOT a confirmed negative — the registry throttled or failed the lookup, so the IP may resolve on a later retry or in a smaller batch. Such rows are counted in summary.transient (separate from summary.unmatched) and a top-level `note` is added. Don't treat transient rows as 'unknown/suspicious'; retry them (results are cached, so repeats are cheap).",
797
798
  "Related: pull the IPs from `query_logs` (use `labels`/`aggregate` to find the IPs of interest first).",
798
799
  ].join(" "), {
799
800
  ips: z
@@ -19,6 +19,12 @@ export interface IpEnrichmentResult {
19
19
  /** Which backend produced the hit — "dataset" (offline CSV) or "rdap"
20
20
  * (online fallback). Absent when not found. */
21
21
  via?: "dataset" | "rdap";
22
+ /** True when `found:false` is NOT a confirmed negative but an RDAP upstream
23
+ * failure (throttle/timeout/5xx) — the address may resolve on a later retry.
24
+ * Issue #523: never conflate a rate-limit with "not in any registry". */
25
+ transient?: boolean;
26
+ /** Machine-readable reason when `transient` — e.g. "rate_limited". */
27
+ error?: string;
22
28
  }
23
29
  export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext, rdap?: RdapResolver | null): Promise<{
24
30
  content: {
@@ -38,6 +38,7 @@ rdap) {
38
38
  let invalid = 0;
39
39
  let matched = 0;
40
40
  let viaRdap = 0;
41
+ let transient = 0;
41
42
  for (const ip of ips) {
42
43
  if (typeof ip !== "string" || !isValidIp(ip)) {
43
44
  invalid++;
@@ -53,16 +54,26 @@ rdap) {
53
54
  continue;
54
55
  }
55
56
  if (rdap) {
56
- const r = await rdap.lookup(ip);
57
- if (r) {
57
+ const r = await rdap.resolve(ip);
58
+ if (r.status === "ok") {
58
59
  matched++;
59
60
  viaRdap++;
60
- results.push({ ip, found: true, via: "rdap", ...r });
61
+ results.push({ ip, found: true, via: "rdap", ...r.value });
62
+ continue;
63
+ }
64
+ if (r.status === "transient") {
65
+ // NOT a confirmed negative — an RDAP throttle/timeout/5xx. Mark it so an
66
+ // agent doesn't treat the IP as "unknown" and can retry later (#523).
67
+ transient++;
68
+ results.push({ ip, found: false, transient: true, error: r.reason });
61
69
  continue;
62
70
  }
63
71
  }
64
72
  results.push({ ip, found: false });
65
73
  }
74
+ // unmatched = confirmed negatives only; transient failures are reported
75
+ // separately so the all-clear can't silently absorb a wall of rate-limits.
76
+ const unmatched = ips.length - matched - invalid - transient;
66
77
  return {
67
78
  content: [
68
79
  {
@@ -72,12 +83,19 @@ rdap) {
72
83
  summary: {
73
84
  total: ips.length,
74
85
  matched,
75
- unmatched: ips.length - matched - invalid,
86
+ unmatched,
76
87
  invalid,
77
- ...(rdap ? { viaRdap } : {}),
88
+ ...(rdap ? { viaRdap, transient } : {}),
78
89
  },
79
90
  datasetSize: dataset?.size ?? 0,
80
91
  ...(rdap ? { rdapEnabled: true } : {}),
92
+ ...(transient > 0
93
+ ? {
94
+ note: `${transient} RDAP lookup(s) failed transiently (e.g. rate-limited by the ` +
95
+ `registry) and are marked transient:true — these are NOT confirmed negatives. ` +
96
+ `Retry them later or in a smaller batch; results are cached so repeats are cheap.`,
97
+ }
98
+ : {}),
81
99
  }, null, 2),
82
100
  },
83
101
  ],
@@ -48,13 +48,21 @@ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
48
48
  });
49
49
  });
50
50
  describe("enrichIpsHandler — optional RDAP fallback (issue #477)", () => {
51
- // Minimal RdapResolver stub: returns a fixed hit for one IP, null otherwise,
52
- // and records which IPs it was asked about (to prove CSV-first).
53
- function rdapStub(hit) {
51
+ // Minimal RdapResolver stub: returns a fixed hit for one IP, a true negative
52
+ // otherwise, and records which IPs it was asked about (to prove CSV-first).
53
+ // `transient` IPs resolve to a transient outcome (e.g. rate-limited) — #523.
54
+ function rdapStub(hit, transient = {}) {
54
55
  const asked = [];
55
56
  return {
56
57
  asked,
57
- resolver: { lookup: async (ip) => { asked.push(ip); return hit[ip] ?? null; } },
58
+ resolver: {
59
+ resolve: async (ip) => {
60
+ asked.push(ip);
61
+ if (transient[ip])
62
+ return { status: "transient", reason: transient[ip] };
63
+ return hit[ip] ? { status: "ok", value: hit[ip] } : { status: "not_found" };
64
+ },
65
+ },
58
66
  };
59
67
  }
60
68
  it("with no dataset but RDAP enabled → not 'not configured'; resolves via RDAP", async () => {
@@ -90,4 +98,29 @@ describe("enrichIpsHandler — optional RDAP fallback (issue #477)", () => {
90
98
  assert.match(out.error, /not configured/i);
91
99
  assert.match(out.error, /OMCP_IP_ENRICH_RDAP/);
92
100
  });
101
+ // Issue #523: a rate-limited lookup must NOT masquerade as a confirmed negative.
102
+ it("marks a rate-limited lookup transient (not a confirmed negative)", async () => {
103
+ const { resolver } = rdapStub({ "8.8.8.8": { country: "US", org: "Google LLC" } }, { "203.0.113.10": "rate_limited" });
104
+ const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8", "203.0.113.10"] }, undefined, resolver));
105
+ const hit = out.results.find((r) => r.ip === "8.8.8.8");
106
+ assert.equal(hit.found, true);
107
+ const throttled = out.results.find((r) => r.ip === "203.0.113.10");
108
+ assert.equal(throttled.found, false);
109
+ assert.equal(throttled.transient, true);
110
+ assert.equal(throttled.error, "rate_limited");
111
+ // The throttled IP is counted as transient, NOT folded into `unmatched`.
112
+ assert.equal(out.summary.matched, 1);
113
+ assert.equal(out.summary.transient, 1);
114
+ assert.equal(out.summary.unmatched, 0);
115
+ assert.match(out.note, /NOT confirmed negatives/i);
116
+ });
117
+ it("a genuine miss stays a clean negative — no transient marker, no note", async () => {
118
+ const { resolver } = rdapStub({ "8.8.8.8": { country: "US", org: "Google LLC" } });
119
+ const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8", "203.0.113.9"] }, undefined, resolver));
120
+ const miss = out.results.find((r) => r.ip === "203.0.113.9");
121
+ assert.equal(miss.found, false);
122
+ assert.equal(miss.transient, undefined);
123
+ assert.equal(out.summary.transient, 0);
124
+ assert.equal(out.note, undefined);
125
+ });
93
126
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.8.2",
3
+ "version": "3.8.3",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",