headroomlabs-ai/headroom · error · Error

Headroom OpenCode wrap blocked direct HTTP/2 connection to $

Error message

Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. Use fetch, http, or https so traffic can be routed through Headroom.

What it means

The transport wrapper monkey-patches Node's http2.connect: HTTP/2 connections cannot be transparently routed through an HTTP forward proxy the way http/https/fetch can, so when the target origin matches the routing rules (shouldRoute, given the configured proxy), the wrapper throws instead of letting traffic bypass Headroom. This is an intentional enforcement of 'all routed traffic goes through Headroom', not a routing bug.

Source

Thrown at plugins/opencode/src/transport.ts:408

  } as HttpRequest | HttpsRequest;
}

function wrapGet(request: HttpRequest | HttpsRequest): HttpGet | HttpsGet {
  return function headroomGet(this: unknown, ...args: unknown[]) {
    const req = Reflect.apply(request, this, args);
    req.end();
    return req;
  } as HttpGet | HttpsGet;
}

function wrapHttp2Connect(originalConnect: Http2Connect): Http2Connect {
  return function headroomHttp2Connect(this: unknown, authority: string | URL, ...args: unknown[]) {
    const state = getState();
    if (state) {
      const proxy = normalizeProxyUrl(state.proxyUrl);
      const upstream = authority instanceof URL ? authority : new URL(String(authority));
      if (shouldRoute(upstream, proxy)) {
        throw new Error(
          `Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. ` +
            "Use fetch, http, or https so traffic can be routed through Headroom.",
        );
      }
    }
    return Reflect.apply(originalConnect, this, [authority, ...args]);
  } as Http2Connect;
}

export function installHeadroomTransport(options: InstallOptions): () => void {
  const existing = getState();
  if (existing) {
    existing.refs += 1;
    existing.proxyUrl = options.proxyUrl;
    existing.debug = Boolean(options.debug);
    installProcessEnv(options.proxyUrl);
    return () => uninstallHeadroomTransport();
  }

View on GitHub (pinned to 322425c43b)

Solutions

  1. Switch the call to fetch(), http.request, or https.request — these are wrapped and routed through Headroom correctly
  2. If the origin must go direct, exclude it from routing (e.g. NO_PROXY / the bypass configuration consumed by shouldRoute) so http2.connect passes through unwrapped
  3. In libraries, prefer undici/fetch-style APIs that the wrapper can intercept instead of raw h2 sessions

Example fix

// before
import http2 from "node:http2";
const session = http2.connect("https://api.example.com"); // throws when routed

// after
const res = await fetch("https://api.example.com/v1/thing"); // wrapped, routed via Headroom
Defensive patterns

Strategy: type-guard

Validate before calling

import http2 from "node:http2";

/** Detect the exact throw so callers can rewrite the call path. */
function isHttp2BlockedByHeadroom(e: unknown): boolean {
  return e instanceof Error && e.message.includes("blocked direct HTTP/2 connection");
}

// Prefer APIs the transport can route, before reaching for http2:
// use fetch() / node:https for any origin the proxy routes.

Type guard

import { isLocalProxyUrl } from "./url.js";

/** True when http2.connect to this origin will be passed through unwrapped. */
function http2Allowed(origin: string, proxyUrl: string): boolean {
  // Same-origin as the proxy, local, or bypass-listed origins are not routed
  if (origin === proxyUrl || isLocalProxyUrl(origin)) return true;
  const noProxy = (process.env.NO_PROXY ?? "").split(",").map((s) => s.trim());
  return noProxy.some((entry) => entry !== "" && origin.includes(entry));
}

Try / catch

import http2 from "node:http2";

let session: ClientHttp2Session;
try {
  session = http2.connect("https://api.example.com");
} catch (e) {
  if (e instanceof Error && e.message.includes("blocked direct HTTP/2 connection")) {
    // HTTP/2 cannot be proxied transparently — fall back to a routable API
    const res = await fetch("https://api.example.com");
    // handle res ...
  } else {
    throw e;
  }
}

Prevention

When it happens

Trigger: After installHeadroomTransport({ proxyUrl }), any code calls http2.connect('https://api.example.com') (or a library like gRPC does internally) for an origin that shouldRoute() matches — not excluded via proxy bypass rules and not the proxy itself.

Common situations: A dependency using node-http2 or native http2 for an API call that used to work; gRPC/protobuf clients in the same process; analytics SDKs preferring HTTP/2; the app expects direct egress but the transport is installed globally.

Related errors


AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15). Data as JSON: /api/errors/c5a7e7a21156b550. Report an issue: GitHub.