← Blog

Claude skill timeout and rate-limit absence

Claude skills that call external APIs, databases, or network services without enforcing per-call timeouts, concurrency limits, and per-session invocation rate limits are vulnerable to resource exhaustion denial-of-service. The four patterns in SA-SKL-TMO cover the specific ways a looping LLM, a rate-limit-free session, or a network failure can exhaust the skill’s resources: indefinite socket holds on hanging connections, thundering herd against upstream APIs, LLM loop invocation floods, and the specific fetch() API pitfall where AbortSignal.timeout() must be specified explicitly.

By SkillAudit  |  2 Nov 2026

MCP skills operate in an environment that is fundamentally different from traditional web servers: the LLM calling the skill can invoke it thousands of times in a single agentic session without deliberate human pacing. A buggy agent loop, a deliberate DoS attempt through crafted prompts, or a benchmark script running against the skill can produce hundreds of concurrent tool invocations or thousands of sequential ones in a short window. Without timeout and rate-limit controls, each of these invocations holds a socket, a database connection, and potentially an upstream API quota slot indefinitely. The accumulated resource hold either exhausts the process event loop, triggers upstream 429 rate-limit responses that degrade all users, or fills the upstream API’s request queue.

SA-SKL-TMO-001: external API calls without per-call timeout cause indefinite socket hold

Node.js http/https requests and the fetch() API have no default timeout. If the upstream server accepts a TCP connection but never sends an HTTP response — a half-open connection, a server that is processing but slow, or a server that has been configured to hold long-polling connections — the Node.js socket remains open indefinitely. In a skill context, each hanging socket consumes one event loop slot, and the Promise returned by the tool handler never resolves. When enough concurrent tool calls hang (one per LLM invocation), the event loop is backpressured: new tool calls queue behind the hanging ones, and the skill becomes unresponsive. Adding a per-call timeout ensures that the socket is destroyed after a bounded time, the Promise rejects with a timeout error, and the LLM receives an error result rather than a hanging call.

// VULNERABLE: HTTP request with no timeout
import https from 'https';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'call_api') {
    const { endpoint, payload } = req.params.arguments;

    return new Promise((resolve, reject) => {
      const request = https.request(endpoint, { method: 'POST' }, (res) => {
        let body = '';
        res.on('data', (chunk) => body += chunk);
        res.on('end', () => resolve({ content: [{ type: 'text', text: body }] }));
      });
      request.on('error', reject);
      request.write(JSON.stringify(payload));
      request.end();
      // MISSING: request.setTimeout(30_000, () => request.destroy())
      // If server never responds: request holds socket indefinitely
    });
  }
});

// ALSO VULNERABLE: fetch() without signal
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_data') {
    const { url } = req.params.arguments;
    const response = await fetch(url);  // ← no AbortSignal, no timeout
    // Server accepts connection but delays response → fetch hangs indefinitely
    const text = await response.text();
    return { content: [{ type: 'text', text }] };
  }
});

// SECURE: per-call timeout on both http module and fetch
import https from 'https';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'call_api') {
    const { endpoint, payload } = req.params.arguments;
    const TIMEOUT_MS = 30_000;

    return new Promise((resolve, reject) => {
      const request = https.request(endpoint, { method: 'POST' }, (res) => {
        let body = '';
        res.on('data', (chunk) => body += chunk);
        res.on('end', () => resolve({ content: [{ type: 'text', text: body }] }));
      });

      // Destroy socket after timeout — Promise rejects with ECONNRESET
      request.setTimeout(TIMEOUT_MS, () => {
        request.destroy(new Error(`API call to ${endpoint} timed out after ${TIMEOUT_MS}ms`));
      });
      request.on('error', reject);
      request.write(JSON.stringify(payload));
      request.end();
    });
  }
});

// DETECTION (SkillAudit):
// https.request / http.request without request.setTimeout() call
// fetch() call without AbortSignal in options
const noTimeout = searchAST(
  'https.request(url, opts, callback) without request.setTimeout() ' +
  'OR fetch(url) | fetch(url, opts) without signal: AbortSignal.timeout(*) in opts'
);
if (noTimeout.length > 0) → HIGH  // SA-SKL-TMO-001

request.setTimeout() is not the same as a response body timeout: request.setTimeout() starts a timer from the moment the request is sent and fires if no data is received within the timeout period. However, if the server starts sending a response but delivers it very slowly (1 byte per second), the timeout does not fire for each byte. For skills that process large response bodies, add a separate timeout on the response stream using res.setTimeout() or wrap the entire request Promise in a Promise.race with a setTimeout that rejects after the total maximum allowed time.

HIGH
SA-SKL-TMO-001 — external API calls without per-call timeout: network failure or slow server holds socket indefinitely; concurrent LLM tool calls accumulate hanging sockets; event loop backpressure makes skill unresponsive. Detection: https.request/fetch without timeout or AbortSignal → HIGH.

SA-SKL-TMO-002: no concurrency limit on parallel tool calls enables thundering herd

When Claude operates in parallel tool call mode — issuing multiple tools/call requests simultaneously — a skill without a concurrency limit accepts all of them simultaneously. If each tool call invokes an upstream API with a per-minute rate limit, 10 simultaneous tool calls exhaust 10x the quota intended for a single request, triggering 429 rate-limit responses for subsequent calls. For upstream APIs that charge per-call (OpenAI, Anthropic, external data services), 100 simultaneous tool calls produce 100 unexpected API charges. For database connection pools, 50 simultaneous tool calls each requesting a connection immediately exhaust a pool of 10 connections, causing all 50 to queue and the skill to become unresponsive for the duration. A simple in-process semaphore limits concurrent executions to a safe ceiling while allowing queued calls to proceed as capacity becomes available.

// VULNERABLE: no concurrency limit — all parallel tool calls execute simultaneously
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'search_index') {
    const { query } = req.params.arguments;

    // Each call makes a request to an external search API with per-minute quota
    // LLM issues 20 parallel searches → 20 simultaneous API calls
    // API has 10 req/min limit → 10 calls get 429 → LLM retries → 30 calls → cascade
    const result = await searchAPI.query(query);
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  }
});

// SECURE: semaphore limits concurrent executions
class Semaphore {
  constructor(max) {
    this.max = max;
    this.current = 0;
    this.queue = [];
  }

  async acquire() {
    if (this.current < this.max) {
      this.current++;
      return;
    }
    await new Promise((resolve) => this.queue.push(resolve));
    this.current++;
  }

  release() {
    this.current--;
    const next = this.queue.shift();
    if (next) next();
  }
}

const searchSemaphore = new Semaphore(5);  // max 5 concurrent searches

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'search_index') {
    const { query } = req.params.arguments;

    await searchSemaphore.acquire();
    try {
      const result = await searchAPI.query(query);
      return { content: [{ type: 'text', text: JSON.stringify(result) }] };
    } finally {
      searchSemaphore.release();
    }
    // LLM issues 20 parallel searches: 5 execute immediately, 15 queue
    // As each completes, next queued call proceeds — upstream never sees >5 concurrent
  }
});

// For database connection pools specifically:
// Set pool max = concurrency limit and configure pool to queue rather than throw on exhaustion
const pool = mysql.createPool({ connectionLimit: 10, queueLimit: 50 });
// queueLimit: 50 means 50 waiting callers before pool.getConnection() throws
// connectionLimit: 10 means at most 10 concurrent DB connections

// DETECTION (SkillAudit):
// Tool handlers that call external HTTP endpoints or DB queries without a semaphore
// or pooled connection limit constraining concurrent executions
const noConcurrencyLimit = searchAST(
  'tool handler invoking fetch | https.request | db.query | searchAPI.* ' +
  'without a preceding semaphore.acquire() or connection pool limit check'
);
if (noConcurrencyLimit.length > 0) → HIGH  // SA-SKL-TMO-002

Set the semaphore maximum to match your upstream rate limit budget: if your upstream API allows 60 requests per minute, and the average request takes 500ms, you can safely run at most 60 × 0.5 / 60s = 0.5 concurrent requests on average — meaning a semaphore of 1 is correct for that API. For APIs with burst allowances (e.g. “60 req/min with a burst of 10”), a semaphore of 5 with a small token bucket provides a safe upper bound. The semaphore also acts as a backpressure signal to the LLM: queued calls increase tool call latency, which the LLM observes and may adapt to by issuing fewer parallel calls.

HIGH
SA-SKL-TMO-002 — no concurrency limit on parallel tool calls: LLM parallel tool use exhausts upstream API rate limit, triggers 429 cascade, and depletes database connection pool. Use semaphore to cap concurrent executions. Detection: tool handler with external calls and no semaphore → HIGH.

SA-SKL-TMO-003: no per-session rate limit on tool invocations enables LLM loop DoS

An LLM agent in a reasoning loop can invoke the same tool thousands of times per minute if the loop condition is never satisfied. This can occur due to prompt injection that forces an infinite loop, a buggy agent implementation that retries on every intermediate result, or a deliberately crafted task that the LLM cannot complete without calling the tool indefinitely. Without a per-session invocation rate limit, each of these tool calls executes and charges upstream API quota. A skill that queries a paid data API can exhaust a monthly API budget in minutes. A skill that writes to a database can create millions of spurious records. A skill that sends emails or notifications can trigger spam filters and email provider account suspension. Per-session limits bound the maximum damage any one LLM session can do, independently of whether the session is benign or adversarial.

// VULNERABLE: no per-session invocation tracking
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'query_database') {
    const { sql } = req.params.arguments;

    // LLM loop calls this 10,000 times in 5 minutes
    // Each call: 1 DB query + charges against API quota
    // No limit: loop runs until budget exhausted or process OOM
    const result = await db.query(sql);
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  }
});

// SECURE: per-session rate limit with sliding window counter
const sessionCounters = new Map();  // sessionId → { count, windowStart }
const RATE_LIMIT = { maxCalls: 100, windowMs: 60_000 };  // 100 calls per minute

function checkSessionRateLimit(sessionId) {
  const now = Date.now();
  const counter = sessionCounters.get(sessionId) ?? { count: 0, windowStart: now };

  // Reset counter if window has expired
  if (now - counter.windowStart > RATE_LIMIT.windowMs) {
    counter.count = 0;
    counter.windowStart = now;
  }

  counter.count++;
  sessionCounters.set(sessionId, counter);

  if (counter.count > RATE_LIMIT.maxCalls) {
    throw new Error(
      `Rate limit exceeded: ${RATE_LIMIT.maxCalls} tool calls per minute per session. ` +
      `Current count: ${counter.count}. Retry after: ${Math.ceil((counter.windowStart + RATE_LIMIT.windowMs - now) / 1000)}s`
    );
  }
}

// Clean up old session counters to prevent memory leak
setInterval(() => {
  const now = Date.now();
  for (const [id, counter] of sessionCounters.entries()) {
    if (now - counter.windowStart > RATE_LIMIT.windowMs * 2) {
      sessionCounters.delete(id);
    }
  }
}, RATE_LIMIT.windowMs);

server.setRequestHandler('tools/call', async (req) => {
  const sessionId = req.params._meta?.sessionId ?? 'anonymous';
  checkSessionRateLimit(sessionId);  // throws if over limit → LLM gets error result

  if (req.params.name === 'query_database') {
    const { sql } = req.params.arguments;
    const result = await db.query(sql);
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  }
});

// DETECTION (SkillAudit):
// Tool handlers that call paid APIs, send emails, write to DB, or invoke external services
// without a per-session invocation counter checked before execution
const noRateLimit = searchAST(
  'tool handler invoking external API | db write | email send | notification ' +
  'without checkSessionRateLimit() or equivalent per-session counter check'
);
if (noRateLimit.length > 0) → HIGH  // SA-SKL-TMO-003

Rate limits should return error results, not throw exceptions: when a tool call exceeds the rate limit, return { isError: true, content: [{ type: 'text', text: 'Rate limit exceeded...' }] } rather than throwing an unhandled exception. An isError: true result is visible to the LLM and can stop the loop; an unhandled exception may be swallowed by a global error handler (see SA-SKL-EVT-004) and leave the LLM with no signal that the limit was reached. Include the retry-after time in the error message so a well-implemented agent can back off appropriately.

HIGH
SA-SKL-TMO-003 — no per-session tool invocation rate limit: LLM reasoning loop invokes tool thousands of times/minute; exhausts paid API quota, fills database with spurious records, or triggers spam filter suspension. Detection: external/DB/email calls without per-session counter → HIGH.

SA-SKL-TMO-004: missing AbortSignal.timeout() on fetch causes indefinite resource hold

The Web fetch() API, available natively in Node.js 18+, has no built-in timeout. Unlike the legacy http.request() API which has a setTimeout() method on the request object, fetch() requires an AbortSignal passed in the signal option to cancel the request. The most ergonomic way to add a timeout is AbortSignal.timeout(ms), introduced in Node.js 17.3 and widely available. Skills that use fetch() without a signal option hold their HTTP connection indefinitely on network failure, firewall drops, or slow upstream responses. In a skill that makes one fetch() call per tool invocation, a batch of 10 concurrent tool calls against an unresponsive endpoint results in 10 hanging connections and 10 Promises that never resolve, effectively deadlocking those event loop microtask slots.

// VULNERABLE: fetch without AbortSignal
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_weather') {
    const { city } = req.params.arguments;

    const response = await fetch(`https://api.weather.example.com/current?city=${encodeURIComponent(city)}`);
    // ← no signal option — hangs indefinitely if weather API is down
    const data = await response.json();
    return { content: [{ type: 'text', text: JSON.stringify(data) }] };
  }
});

// ALSO VULNERABLE: fetch with manual AbortController but no timeout scheduling
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_weather') {
    const { city } = req.params.arguments;
    const controller = new AbortController();
    // MISSING: setTimeout(() => controller.abort(), 30_000)
    // AbortController created but never triggered — provides no timeout protection

    const response = await fetch(`https://api.weather.example.com/current`, {
      signal: controller.signal  // ← signal present but abort never called
    });
    const data = await response.json();
    return { content: [{ type: 'text', text: JSON.stringify(data) }] };
  }
});

// SECURE: AbortSignal.timeout() — clean, no manual cleanup required
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_weather') {
    const { city } = req.params.arguments;

    try {
      const response = await fetch(
        `https://api.weather.example.com/current?city=${encodeURIComponent(city)}`,
        { signal: AbortSignal.timeout(30_000) }  // ← 30s timeout, auto-aborts
      );

      if (!response.ok) {
        throw new Error(`Weather API returned ${response.status}`);
      }

      const data = await response.json();
      return { content: [{ type: 'text', text: JSON.stringify(data) }] };

    } catch (err) {
      if (err.name === 'TimeoutError') {
        return { isError: true, content: [{ type: 'text', text: 'Weather API timed out after 30s' }] };
      }
      throw err;
    }
  }
});

// ALSO SECURE: AbortController with setTimeout for Node.js < 17.3
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_weather') {
    const { city } = req.params.arguments;
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 30_000);

    try {
      const response = await fetch(
        `https://api.weather.example.com/current?city=${encodeURIComponent(city)}`,
        { signal: controller.signal }
      );
      clearTimeout(timeout);  // ← important: clear timer if request succeeds
      return { content: [{ type: 'text', text: await response.text() }] };
    } catch (err) {
      clearTimeout(timeout);
      if (controller.signal.aborted) {
        return { isError: true, content: [{ type: 'text', text: 'Request timed out' }] };
      }
      throw err;
    }
  }
});

// DETECTION (SkillAudit):
// fetch(url) or fetch(url, opts) where opts.signal is absent or is an AbortController.signal
// without a corresponding setTimeout(controller.abort, N) within 5 lines
const fetchNoTimeout = searchAST(
  'fetch(url) | fetch(url, opts) where ' +
  '(opts.signal is absent) OR ' +
  '(opts.signal = controller.signal AND no setTimeout(controller.abort, N) nearby)'
);
if (fetchNoTimeout.length > 0) → HIGH  // SA-SKL-TMO-004

AbortSignal.timeout() also aborts the response body read: if you pass signal: AbortSignal.timeout(30_000), the timeout covers both the connection establishment phase and the response body streaming phase. If a slow server sends the status line immediately but streams the body at 1 byte/second, the AbortSignal fires after 30 seconds total, cancelling both the initial fetch and any in-progress response.text()/response.json() call. A manually created AbortController with setTimeout only aborts if you call controller.abort() — which you must do in both the success and error paths to prevent the timer from running past the response completion.

HIGH
SA-SKL-TMO-004 — fetch() without AbortSignal.timeout(): unresponsive server holds connection indefinitely; concurrent tool calls accumulate hanging Promises; event loop backpressure makes skill unresponsive to new requests. Detection: fetch() without signal: AbortSignal.timeout(N) → HIGH.

SA-SKL-TMO summary

IDPatternSeverityImpact
SA-SKL-TMO-001External API calls without per-call timeoutHIGHNetwork failure holds socket indefinitely; concurrent hangs backpressure event loop
SA-SKL-TMO-002No concurrency limit on parallel tool callsHIGHThundering herd exhausts upstream rate limit; 429 cascade; DB connection pool exhaustion
SA-SKL-TMO-003No per-session tool invocation rate limitHIGHLLM loop exhausts paid API quota, creates millions of spurious DB records in minutes
SA-SKL-TMO-004fetch() without AbortSignal.timeout()HIGHHanging fetch Promises accumulate on network failure; skill becomes unresponsive

How SkillAudit detects SA-SKL-TMO patterns

SkillAudit identifies all outbound network calls within tool handler functions and checks each for timeout controls. For http.request() and https.request(), it verifies that request.setTimeout() is called within the request setup code (SA-SKL-TMO-001). For fetch() calls, it checks for a signal option using AbortSignal.timeout() or a manually aborted AbortController with a corresponding setTimeout (SA-SKL-TMO-004). It identifies tool handlers that invoke external APIs, database queries, or other shared resources without a semaphore or concurrency control wrapping the call (SA-SKL-TMO-002). And it checks whether any per-session invocation counter is evaluated before calls to paid APIs, write operations, or notification-sending functions (SA-SKL-TMO-003).

Run a free SkillAudit scan to check your Claude skill for all four SA-SKL-TMO patterns alongside SA-SKL-RCL resource cleanup leaks, SA-SKL-NET undisclosed network egress, and SA-SKL-EVT stream and EventEmitter vulnerabilities.