knext

Request hardening

Rate limiting, payload-size caps, and malformed-request handling for a knext app — what the platform gives you, what it does not, and the recipe for the rest.

knext bounds how many requests hit your app, how long each may run, and — since the in-process byte cap shipped — how large a single request body may be. It does not bound request rate per client. This page is precise about which is which, because the difference decides whether you need to do anything.

Request bodies are capped at 8 MiB by default, for every route. The runtime refuses a larger body with 413 before your handler is entered, and it counts bytes rather than trusting Content-Length — so a chunked request that sends no length is refused too. Change it with the KNEXT_MAX_REQUEST_BYTES environment variable; see The request body cap.

The 8 MiB cap is not the same limit as Next.js's. Next caps Server Action payloads at 1 MB by default, and that setting does not cover App Router route handlers. The two are deliberately different numbers so that a 413 tells you which one fired: 1 MB is Next, 8 MiB (or whatever you set) is knext.

What the platform already bounds

  • Request body size — 8 MiB by default, on every route, enforced by the runtime before your code runs. See The request body cap.
  • Concurrency per podspec.scaling.containerConcurrency (default 20) is enforced by the Knative queue-proxy inside the pod, so it applies to every request that reaches your app.
  • Request durationspec.timeoutSeconds (default 300s) caps how long one request may occupy a concurrency slot. Lower it per app if you have no long-running streaming responses.
  • Scale ceilingspec.scaling.maxScale (default 10) bounds how far a flood can scale you out, which is what turns an overload into a bounded cost rather than an unbounded one.
  • Direct-dial isolation — the default NetworkPolicy admits only the queue-proxy and metrics ports, so a pod sharing your namespace cannot bypass the queue-proxy by dialling your container port. See Security → Network isolation. This is enforced only if your CNI supports NetworkPolicy (Calico, Cilium); on flannel the policy is inert. kn-next doctor and the NetworkPolicyEnforced condition on your app both report which case your cluster is in.
  • Malformed HTTP — external traffic passes three independent HTTP parsers before your code.
  • Metrics-port separation — the app's Prometheus listener defaults to :9464, deliberately off :9091, which Knative's queue-proxy binds inside every pod on a stock Serving install (default config-observability). The NetworkPolicy's scrape grants, the prometheus.io/port annotation and the shipped PodMonitor are all fixed to 9464. Never set METRICS_PORT=9091 — the app races queue-proxy for the port and crash-loops with EADDRINUSE; kn-next doctor detects this.

What it does not bound

GapWhat it means in practice
Request rate per clientNothing limits how fast one client can call you. A flood scales you out to maxScale and stops there.
Slow clients in aggregateBounded per pod by concurrency and by timeoutSeconds, but not globally.

The recipe

1. Put a proxy in front — for external traffic

Terminate external traffic at a proxy that enforces rate limits and payload caps before they reach the cluster: your cloud load balancer's policies, or an nginx/Envoy ingress layer.

A front proxy binds external traffic only. It does nothing about a pod inside your cluster calling your app — that is what the NetworkPolicy above is for. Three more caveats worth knowing before you rely on one:

  • Rate-limit counters are per replica. Three proxy replicas with "100 req/min" allow 300 unless the proxy shares state (Redis-backed limiter or equivalent).
  • Chunked bodies have no Content-Length. Enforcing a size cap on them requires the proxy to buffer — which moves the memory pressure to the proxy rather than removing it.
  • Cloud LB payload policies vary. Verify your provider actually offers one; several do not.

2. Set the request body cap

knext caps request bodies in-process, so this control applies whether the traffic came through your proxy, through the Knative ingress, or from a pod inside the cluster dialling your app directly.

  • Default: 8388608 bytes (8 MiB). Chosen against the memory limit and containerConcurrency (20 in-flight × 8 MiB stays far inside a 1Gi pod), and deliberately above Next's 1 MB Server Action limit so the two never answer at the same threshold.

  • The runtime counts bytes. A body sent with chunked transfer encoding and no Content-Length is refused at the same size as one that declares its length. Content-Length is never trusted on its own.

  • The response is a bare 413 with no body. It is produced by the runtime before any knext or Next.js code runs, so there is nothing to attach a message to. To confirm which cap is in force, read the line the app prints on start:

    REQUEST_BYTE_CAP:8388608 METRICS_BYTE_CAP:65536 (default)

Change it per app with an environment variable:

apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
spec:
  env:
    - name: KNEXT_MAX_REQUEST_BYTES
      value: "20971520"          # 20 MiB

KNEXT_MAX_REQUEST_BYTES=0 removes the cap entirely. The app then buffers a body of any size the runtime accepts, and with containerConcurrency above 1 that is an out-of-memory kill rather than a 413. It is deliberate, it is logged loudly on every start, and it should be temporary. Any value that is not a non-negative integer is ignored with a warning and the 8 MiB default stays in force — a typo cannot silently remove the cap. The metrics port stays capped either way.

If you need a smaller, per-route limit — say 100 KB on one JSON endpoint — the platform cap does not replace it. Check the length as you read, rather than trusting the header:

// app/api/upload/route.ts
const MAX_BYTES = 1_000_000;

export async function POST(req: Request) {
  if (!req.body) return new Response('empty body', { status: 400 });

  let total = 0;
  const chunks: Uint8Array[] = [];
  // Count bytes as they arrive and bail early — never buffer first and check after.
  for await (const chunk of req.body as unknown as AsyncIterable<Uint8Array>) {
    total += chunk.byteLength;
    if (total > MAX_BYTES) {
      return new Response('payload too large', { status: 413 });
    }
    chunks.push(chunk);
  }

  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
  return Response.json({ ok: true, size: total, keys: Object.keys(body).length });
}

3. Set the Server Action limit deliberately

The 1 MB default applies to Server Actions only. Set it explicitly so the value is a decision rather than an inheritance:

// next.config.ts
export default {
  experimental: {
    serverActions: { bodySizeLimit: '1mb' },
  },
};

4. Tighten the per-app knobs

apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
spec:
  timeoutSeconds: 30            # no long-running streams? drop it from 300
  scaling:
    containerConcurrency: 20    # requests in flight per pod
    maxScale: 10                # the ceiling a flood can reach

Why knext does not ship a proxy

Putting a proxy sidecar next to every app would add a container to the cold-start path, which is the one thing a scale-to-zero platform cannot spend freely. The byte cap above is the in-process alternative, and it costs one integer parse at start — nothing per request, and nothing at all on the cold-start path.

Rate limiting is the piece that is still yours. The platform bounds concurrency, duration, scale and bytes; nothing bounds how fast one client may call you, and a proxy is still the right place for that.

On this page