[FEATURE] Compress API request bodies with gzip by default

Status Open
Reported on v2.1.246
Maintainer reply None cached
Activity 0 comments · opened Aug 28, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

Claude Code re-uploads the full conversation context on every API call, uncompressed. In a long session that's 500 KB–1 MB+ of request body per turn, and multi-turn tool use means many turns per prompt. On asymmetric residential uplinks this adds real latency to every request; on slow or metered connections (hotspot, tethering, poor routes) it makes Claude Code borderline unusable — we measured a session pushing hundreds of MB of upload in an afternoon.

The fix is unusually cheap because the server side already exists: api.anthropic.com accepts Content-Encoding: gzip request bodies today. Only the client isn't sending them.

Empirically verified current state (2.1.246, Linux x86_64)

  • Request bodies leave Claude Code identity-encoded. Verified by pointing ANTHROPIC_BASE_URL at a local logging proxy: bodies arrive with no Content-Encoding, e.g. a single /v1/messages?beta=true body of 594,750 B raw.
  • The API accepts gzip request bodies right now. Recompressing those same bodies with Content-Encoding: gzip and forwarding to api.anthropic.com works flawlessly (200s, streaming unaffected).
  • Only gzip. zstd, br, and deflate request encodings are all rejected with 400 (tested via /v1/messages/count_tokens).

Measured benefit (real traffic, not synthetic)

Numbers from a gzip-compressing local proxy (stdlib Python, gzip level 6) carrying normal interactive Claude Code sessions:

  • Real request bodies compress ~2.1×: e.g. 594,750 B → 289,145 B; 330,287 B → 147,483 B.
  • One machine, ~3 hours of ordinary use: 185 MB of upload eliminated.
  • CPU cost: single-digit milliseconds per body at gzip -6 — three orders of magnitude below the network time it saves. Even on a fast 20 Mbps uplink, halving a 600 KB body saves ~120 ms per request; on slow links it's the difference between usable and not.
  • Anthropic side benefit: proportional ingress bandwidth reduction across every long session.

Related

  • #13911 (closed, not planned) — asked to upgrade request compression to brotli/zstd, assuming gzip might already be in use. This request differs on both counts: we verified bodies are currently uncompressed, and we ask only for gzip, which the server demonstrably accepts today (brotli/zstd are rejected with 400).
  • #55411 (closed, not planned) — bandwidth-aware mode for metered connections; this is the single highest-leverage, lowest-cost slice of that.
  • #14930 (closed, not planned) — token-level "compression" of context; unrelated mechanism (transport vs tokens).
  • #85046 / #89262 (open) — the download side of the same theme: Claude Code's network layer assumes fast, reliable links.

Proposed Change

In the SDK/client fetch path (a small change — our whole proof-of-concept proxy is ~170 lines):

  1. gzip request bodies above a small threshold (~1 KB) with Content-Encoding: gzip, by default.
  2. Retry once uncompressed on an unexpected 4xx, to survive rare middleboxes that mishandle request Content-Encoding.
  3. Env-var kill switch (e.g. CLAUDE_CODE_DISABLE_REQUEST_COMPRESSION=1).

Workaround for anyone hitting this today

A local ANTHROPIC_BASE_URL proxy that gzips bodies before forwarding to api.anthropic.com works out of the box and is what produced the numbers above.

<details>
<summary>
Python script.
</summary>

#!/usr/bin/env python3
"""Local proxy for Claude Code on slow connections.

Sits between Claude Code and api.anthropic.com and gzip-compresses every
request body (the full conversation context is re-uploaded on each API call,
and it compresses ~2x on real traffic). Streaming (SSE) responses are passed through
incrementally, decompressed locally if the upstream compressed them.
Upstream TLS connections are pooled per client-connection thread, so a
keep-alive client pays the handshake once, not per request.

Usage:
    python3 claude-slim-proxy.py [port]          # default 8377
    ANTHROPIC_BASE_URL=http://127.0.0.1:8377 claude

Stdlib only. No credentials are read or stored; auth headers pass through
unchanged to api.anthropic.com only.
"""
import gzip
import http.client
import sys
import threading
import zlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

UPSTREAM = "api.anthropic.com"
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8377
MIN_COMPRESS = 1024  # don't bother compressing tiny bodies
HOP_HEADERS = {"connection", "keep-alive", "transfer-encoding", "te",
               "proxy-authorization", "proxy-authenticate", "upgrade",
               "trailer", "host", "content-length", "accept-encoding"}

stats = {"in_raw": 0, "in_sent": 0}
stats_lock = threading.Lock()
tls = threading.local()  # per-thread upstream connection


def upstream_conn(fresh=False):
    conn = getattr(tls, "conn", None)
    if fresh and conn is not None:
        try:
            conn.close()
        except OSError:
            pass
        conn = None
    if conn is None:
        conn = http.client.HTTPSConnection(UPSTREAM, timeout=900)
        tls.conn = conn
    return conn


def drop_upstream():
    conn = getattr(tls, "conn", None)
    if conn is not None:
        try:
            conn.close()
        except OSError:
            pass
        tls.conn = None


class Proxy(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):
        sys.stderr.write("[proxy] %s\n" % (fmt % args))

    def _handle(self):
        length = int(self.headers.get("content-length") or 0)
        body = self.rfile.read(length) if length else b""

        headers = {k: v for k, v in self.headers.items()
                   if k.lower() not in HOP_HEADERS
                   and not k.lower().startswith("proxy-")}
        headers["Host"] = UPSTREAM
        # Ask upstream for gzip; we decompress incrementally ourselves so the
        # client always receives identity encoding.
        headers["Accept-Encoding"] = "gzip"

        raw_len = len(body)
        already = any(k.lower() == "content-encoding" for k in headers)
        if body and raw_len >= MIN_COMPRESS and not already:
            body = gzip.compress(body, 6)
            headers["Content-Encoding"] = "gzip"
        headers["Content-Length"] = str(len(body))

        with stats_lock:
            stats["in_raw"] += raw_len
            stats["in_sent"] += len(body)
            saved = stats["in_raw"] - stats["in_sent"]
        if raw_len >= MIN_COMPRESS:
            self.log_message("%s %s up %dB -> %dB (total saved %.1fMB)",
                             self.command, self.path, raw_len, len(body),
                             saved / 1e6)

        # Pooled upstream connection; one retry on a stale keep-alive socket.
        resp = None
        for attempt in (0, 1):
            conn = upstream_conn(fresh=bool(attempt))
            try:
                conn.request(self.command, self.path, body=body, headers=headers)
                resp = conn.getresponse()
                break
            except (OSError, http.client.HTTPException) as e:
                drop_upstream()
                if attempt:
                    self.send_error(502, explain="upstream error: %s" % e)
                    return

        gzipped = (resp.getheader("Content-Encoding") or "").lower() == "gzip"
        # 16+MAX_WBITS = gzip container
        inflater = zlib.decompressobj(16 + zlib.MAX_WBITS) if gzipped else None
        upstream_len = resp.getheader("Content-Length")
        # If we must transform (gunzip) or upstream is chunked, we can't trust
        # Content-Length; stream with chunked framing instead.
        use_chunked = gzipped or upstream_len is None

        self.send_response(resp.status)
        for k, v in resp.getheaders():
            lk = k.lower()
            if lk in HOP_HEADERS or lk in ("content-encoding", "content-length"):
                continue
            self.send_header(k, v)
        if use_chunked:
            self.send_header("Transfer-Encoding", "chunked")
        else:
            self.send_header("Content-Length", upstream_len)
        self.end_headers()

        completed = False
        try:
            while True:
                chunk = resp.read1(65536)
                if not chunk:
                    break
                if inflater:
                    chunk = inflater.decompress(chunk)
                    if not chunk:
                        continue
                if use_chunked:
                    self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
                else:
                    self.wfile.write(chunk)
                self.wfile.flush()
            if inflater:
                tail = inflater.flush()
                if tail and use_chunked:
                    self.wfile.write(b"%x\r\n%s\r\n" % (len(tail), tail))
            if use_chunked:
                self.wfile.write(b"0\r\n\r\n")
            self.wfile.flush()
            completed = True
        except (BrokenPipeError, ConnectionResetError):
            pass  # client went away mid-stream
        finally:
            # Only keep the upstream socket if the response was fully drained;
            # a half-read stream would poison the next pooled request.
            if not completed:
                drop_upstream()

    do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = _handle


if __name__ == "__main__":
    srv = ThreadingHTTPServer(("127.0.0.1", PORT), Proxy)
    srv.daemon_threads = True
    print("claude-slim-proxy listening on http://127.0.0.1:%d -> https://%s" % (PORT, UPSTREAM))
    print("run:  ANTHROPIC_BASE_URL=http://127.0.0.1:%d claude" % PORT)
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass

</details>

Priority

High - Significant impact on productivity

Feature Category

Performance and speed

View original on GitHub ↗