Skill: stephen-standridge/simplehttpskill (version 0.1.0)

--- name: simple-http description: Make HTTP requests (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) with custom headers, automatic retries, and graceful error handling. Use when the user needs to call an API, fetch a URL, send webhooks, or make any HTTP request without external dependencies. --- # Simple HTTP Skill Make HTTP requests using only Node.js built-in modules. Supports all standard methods, arbitrary headers, automatic retries with exponential backoff, and never throws on failure — always resolves with an inspectable response object. ## Required Inputs - **url** (string): Fully qualified URL to request. - **method** (string, optional): HTTP method. Default `GET`. - **headers** (object, optional): Request headers. - **body** (string | Buffer | object, optional): Request body. Objects are auto-serialized to JSON. - **maxRetries** (number, optional): Retry attempts for transient failures. Default `3`. - **timeout** (number, optional): Socket timeout in ms. Default `30000`. ## Step-by-Step Workflow 1. Import the client from `src/http-client.js`: ```js const { HttpClient } = require("./src/http-client"); ``` 2. Create a client instance (optionally set default headers shared across calls): ```js const client = new HttpClient({ defaultHeaders: { Authorization: "Bearer <token>" }, maxRetries: 3, }); ``` 3. Make requests using convenience methods or the generic `request()`: ```js // GET const resp = await client.get("https://api.example.com/items"); // POST with JSON body const resp = await client.post("https://api.example.com/items", { body: { name: "widget" }, }); // PUT with custom headers const resp = await client.put("https://api.example.com/items/1", { headers: { "X-Request-Id": "abc123" }, body: { name: "updated" }, }); // DELETE const resp = await client.delete("https://api.example.com/items/1"); // Generic form — any method const resp = await client.request("PATCH", "https://api.example.com/items/1", { body: { qty: 5 }, }); ``` 4. Inspect the response: ```js if (resp.ok) { console.log(resp.body); // parsed JSON or raw string console.log(resp.status); // e.g. 200 console.log(resp.headers); // response headers object } else { console.log(resp.error); // human-readable error (null if HTTP error with status) console.log(resp.status); // HTTP status code or null for network errors } ``` ## Output Format Every call resolves with an object containing: | Key | Type | Description | |-----------|-------------------|----------------------------------------------------| | `ok` | `boolean` | `true` if status is 2xx | | `status` | `number \| null` | HTTP status code; `null` for network-level errors | | `headers` | `object` | Response headers | | `body` | `any` | Parsed JSON (if content-type is JSON), else string | | `error` | `string \| null` | Error description on failure; `null` on success | ## Error Handling & Retry Behavior - **Retried automatically:** Connection errors, timeouts, and HTTP 429 / 5xx responses. - **Not retried:** 4xx errors (except 429) — returned immediately. - **Backoff:** Exponential with jitter (base 500ms, capped at 30s). - **Graceful failure:** The client never throws. After exhausting retries, it resolves with the last error response so the caller can always inspect `resp.ok` and `resp.error`. ## Configuration Options All options can be set at the client level (constructor) and overridden per-request: | Option | Default | Description | |------------------|----------|--------------------------------------| | `defaultHeaders` | `{}` | Headers applied to every request | | `maxRetries` | `3` | Max retry attempts | | `timeout` | `30000` | Socket timeout in ms | | `backoffBase` | `500` | Base delay (ms) for exponential backoff | | `backoffMax` | `30000` | Maximum backoff delay cap (ms) | ## Dependencies None — uses only Node.js built-in modules (`http`, `https`, `url`).

path: clawhub.json
content: {
  "name": "simple-http",
  "version": "1.0.0",
  "description": "Make HTTP requests with custom headers, retries, and graceful error handling using only Node.js built-in modules.",
  "category": "utilities",
  "tags": ["http", "requests", "api", "fetch", "networking"],
  "license": "MIT",
  "entry": "src/http-client.js"
}


path: src/http-client.js
content: "use strict";

const http = require("http");
const https = require("https");
const { URL } = require("url");

const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);

const DEFAULTS = {
  maxRetries: 3,
  timeout: 30_000,
  backoffBase: 500,
  backoffMax: 30_000,
};

/**
 * Calculate exponential backoff delay with jitter.
 * @param {number} attempt - Zero-based attempt index.
 * @param {number} base - Base delay in ms.
 * @param {number} max - Maximum delay cap in ms.
 * @returns {number} Delay in ms.
 */
function backoffDelay(attempt, base, max) {
  const delay = Math.min(base * 2 ** attempt, max);
  return delay * (0.5 + Math.random() * 0.5);
}

/**
 * Sleep for a given number of milliseconds.
 * @param {number} ms
 * @returns {Promise<void>}
 */
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Parse a raw body buffer into a string, and attempt JSON parse if appropriate.
 * @param {Buffer} buf
 * @param {string|undefined} contentType
 * @returns {any}
 */
function parseBody(buf, contentType) {
  const text = buf.toString("utf-8");
  if (contentType && contentType.includes("json")) {
    try {
      return JSON.parse(text);
    } catch {
      return text;
    }
  }
  return text;
}

/**
 * Execute a single HTTP(S) request. Returns a Promise that always resolves.
 * @param {object} opts
 * @param {string} opts.method
 * @param {URL} opts.parsedUrl
 * @param {Record<string, string>} opts.headers
 * @param {Buffer|null} opts.body
 * @param {number} opts.timeout
 * @returns {Promise<{ok: boolean, status: number|null, headers: Record<string,string>, body: any, error: string|null}>}
 */
function doRequest({ method, parsedUrl, headers, body, timeout }) {
  return new Promise((resolve) => {
    const transport = parsedUrl.protocol === "https:" ? https : http;

    const req = transport.request(
      parsedUrl,
      { method, headers, timeout },
      (res) => {
        const chunks = [];
        res.on("data", (chunk) => chunks.push(chunk));
        res.on("end", () => {
          const buf = Buffer.concat(chunks);
          const contentType = res.headers["content-type"] || "";
          const responseHeaders = Object.assign({}, res.headers);
          resolve({
            ok: res.statusCode >= 200 && res.statusCode < 300,
            status: res.statusCode,
            headers: responseHeaders,
            body: parseBody(buf, contentType),
            error: null,
          });
        });
        res.on("error", (err) => {
          resolve({
            ok: false,
            status: res.statusCode || null,
            headers: Object.assign({}, res.headers),
            body: null,
            error: err.message,
          });
        });
      }
    );

    req.on("timeout", () => {
      req.destroy(new Error("Request timed out"));
    });

    req.on("error", (err) => {
      resolve({
        ok: false,
        status: null,
        headers: {},
        body: null,
        error: err.message,
      });
    });

    if (body) {
      req.write(body);
    }
    req.end();
  });
}

class HttpClient {
  /**
   * @param {object} [options]
   * @param {Record<string, string>} [options.defaultHeaders] - Headers applied to every request.
   * @param {number} [options.maxRetries] - Max retry attempts for retryable failures.
   * @param {number} [options.timeout] - Socket timeout in ms.
   * @param {number} [options.backoffBase] - Base delay (ms) for exponential backoff.
   * @param {number} [options.backoffMax] - Maximum delay cap (ms) for backoff.
   */
  constructor(options = {}) {
    this.defaultHeaders = options.defaultHeaders || {};
    this.maxRetries = options.maxRetries ?? DEFAULTS.maxRetries;
    this.timeout = options.timeout ?? DEFAULTS.timeout;
    this.backoffBase = options.backoffBase ?? DEFAULTS.backoffBase;
    this.backoffMax = options.backoffMax ?? DEFAULTS.backoffMax;
  }

  /**
   * Send an HTTP request with automatic retries.
   *
   * @param {string} method - HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS).
   * @param {string} url - Fully qualified URL.
   * @param {object} [options]
   * @param {Record<string, string>} [options.headers] - Per-request headers (merged with defaults).
   * @param {string|Buffer|object} [options.body] - Request body. Objects are JSON-serialized.
   * @param {number} [options.timeout] - Override instance timeout (ms).
   * @param {number} [options.maxRetries] - Override instance maxRetries.
   * @returns {Promise<{ok: boolean, status: number|null, headers: Record<string,string>, body: any, error: string|null}>}
   */
  async request(method, url, options = {}) {
    method = method.toUpperCase();
    const effectiveTimeout = options.timeout ?? this.timeout;
    const effectiveRetries = options.maxRetries ?? this.maxRetries;

    const mergedHeaders = { ...this.defaultHeaders, ...options.headers };

    let body = null;
    if (options.body != null) {
      if (typeof options.body === "object" && !Buffer.isBuffer(options.body)) {
        body = Buffer.from(JSON.stringify(options.body), "utf-8");
        if (!mergedHeaders["Content-Type"] && !mergedHeaders["content-type"]) {
          mergedHeaders["Content-Type"] = "application/json";
        }
      } else if (typeof options.body === "string") {
        body = Buffer.from(options.body, "utf-8");
      } else {
        body = options.body;
      }
    }

    const parsedUrl = new URL(url);
    let lastResult = null;

    for (let attempt = 0; attempt <= effectiveRetries; attempt++) {
      lastResult = await doRequest({
        method,
        parsedUrl,
        headers: mergedHeaders,
        body,
        timeout: effectiveTimeout,
      });

      const shouldRetry =
        lastResult.status === null ||
        RETRYABLE_STATUS_CODES.has(lastResult.status);

      if (!shouldRetry || attempt === effectiveRetries) {
        return lastResult;
      }

      await sleep(
        backoffDelay(attempt, this.backoffBase, this.backoffMax)
      );
    }

    return lastResult;
  }

  /** @param {string} url @param {object} [options] */
  get(url, options) {
    return this.request("GET", url, options);
  }
  /** @param {string} url @param {object} [options] */
  post(url, options) {
    return this.request("POST", url, options);
  }
  /** @param {string} url @param {object} [options] */
  put(url, options) {
    return this.request("PUT", url, options);
  }
  /** @param {string} url @param {object} [options] */
  patch(url, options) {
    return this.request("PATCH", url, options);
  }
  /** @param {string} url @param {object} [options] */
  delete(url, options) {
    return this.request("DELETE", url, options);
  }
  /** @param {string} url @param {object} [options] */
  head(url, options) {
    return this.request("HEAD", url, options);
  }
  /** @param {string} url @param {object} [options] */
  options(url, options) {
    return this.request("OPTIONS", url, options);
  }
}

module.exports = { HttpClient };


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)