Help us shape the future of Go! Take the 2026 Go Developer Survey

concert

command module
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 21, 2026 License: Apache-2.0 Imports: 31 Imported by: 0

README

Concert

Go Reference Latest Release Go Version Apache 2.0 License Built on room GitHub Stars

A FIFO waiting room reverse proxy. Put Concert in front of any HTTP origin, such as a PHP site, a WordPress install, or a legacy app that falls over under load. When traffic exceeds what the origin can handle, visitors wait in an orderly queue with a live position instead of getting 502s and timeouts. Admitted visitors load their page's assets through a separate tier that queued and denied visitors can't reach.

Concert is a single Go binary built on room, a FIFO waiting room middleware for Gin, and sema, a resizable semaphore.

Why

A PHP-FPM pool has a fixed number of workers (pm.max_children). When every worker is busy, new requests pile up in the web server's backlog until they time out. Users see 502 or 504 errors, hit refresh, and make the spike worse.

Concert caps the number of concurrent page requests that reach your origin. Everyone past that cap is shown a waiting room page with their queue position, and they are admitted automatically, in arrival order, as slots free up. Your origin only ever sees the load you chose.

How it works

browser ──▶ TLS terminator ──▶ concert :8080 ──▶ origin :3000
                                  │
          page requests ──────────┼─▶ room: slot free?
                                  │     yes ──▶ proxied, concert_admit pass issued
                                  │     no  ──▶ waiting room page, polls /queue/status
                                  │
          asset requests ─────────┼─▶ valid concert_admit pass?
                                  │     no  ──▶ 403
                                  │     yes ──▶ per-user semaphore (HTTP/1.1 or HTTP/2 rule)
                                  │               ──▶ global asset semaphore ──▶ proxied
                                  │
          bypass paths ───────────┴─▶ proxied, no guard

Pages go through room. A request whose ticket falls inside the serving window takes a slot, is proxied, and releases the slot when its response has been fully delivered. Other requests get a room_ticket cookie and the waiting room page, which polls /queue/status and reloads when the visitor's turn arrives. -cap is the maximum number of page requests in flight to your origin.

Admitted page responses also carry concert_admit, a signed, HttpOnly pass that slides forward as the visitor keeps browsing.

Assets don't queue. A stylesheet or image can't run the waiting room's JavaScript, so making one wait is the same as failing it. Instead, asset paths require a valid pass, and each pass has its own small concurrency pool. One visitor can't monopolise the asset tier, and visitors who were never admitted can't touch it at all. A global semaphore across all passes protects the host.

Install

go install github.com/andreimerlescu/concert@latest

Or build from source:

git clone https://github.com/andreimerlescu/concert.git
cd concert
make build

make build writes static binaries for Linux, macOS and Windows on amd64 and arm64 to bin/. Requires Go 1.22 or newer.

Quick start

Start something to protect. PHP's built-in server is enough for a demo:

php -S 127.0.0.1:3000 -t /var/www/html

Put Concert in front of it with a deliberately small capacity:

concert -upstream http://127.0.0.1:3000 -cap 5 -assets "/css/*,/js/*,/images/*"

Open http://127.0.0.1:8080/ in a browser, then generate load from another terminal:

ab -c 100 -n 2000 http://127.0.0.1:8080/

Refresh the browser while the load test runs and you'll land in the waiting room, then be admitted automatically as slots open.

Configuration

Every option can be set with a flag or an environment variable. A flag overrides its environment variable, and the environment variable overrides the built-in default.

Flag Environment Default Description
-listen CONCERT_LISTEN :8080 Address to listen on
-upstream CONCERT_UPSTREAM http://127.0.0.1:3000 Origin to proxy to (http or https)
-cap CONCERT_CAPACITY 500 Max concurrent page requests allowed through to the origin
-max-queue CONCERT_MAX_QUEUE 10000 Reject new arrivals with 503 beyond this queue depth (0 = unlimited)
-reaper CONCERT_REAPER 30s How often abandoned tickets are cleaned up
-token-ttl CONCERT_TOKEN_TTL 0 (room default, 5m) Sliding lifetime of a queued ticket, 30s–24h
-secure-cookie CONCERT_SECURE_COOKIE false Mark cookies Secure (only if browsers reach you over HTTPS)
-cookie-path CONCERT_COOKIE_PATH / Path attribute of cookies
-cookie-domain CONCERT_COOKIE_DOMAIN (empty) Domain attribute of cookies
-preserve-host CONCERT_PRESERVE_HOST true Forward the client's Host header to the origin
-bypass CONCERT_BYPASS /favicon.ico Paths that skip every guard; suffix /* for a prefix
-assets CONCERT_ASSETS (empty) Asset paths that require an admission pass; suffix /* for a prefix
-asset-public CONCERT_ASSET_PUBLIC (empty) Asset paths served without a pass, still under the global asset cap
-asset-cap CONCERT_ASSET_CAP 0 (derived) Global concurrent asset requests; 0 means cap × asset-user-cap-h2
-asset-wait CONCERT_ASSET_WAIT 2s Max wait for a global asset slot before 503
-asset-user-cap-h1 CONCERT_ASSET_USER_CAP_H1 8 Concurrent asset requests per pass over HTTP/1.x
-asset-user-cap-h2 CONCERT_ASSET_USER_CAP_H2 128 Concurrent asset requests per pass over HTTP/2 and HTTP/3
-asset-user-wait CONCERT_ASSET_USER_WAIT 2s Max wait for a per-pass asset slot before 429
-admit-ttl CONCERT_ADMIT_TTL 10m Sliding lifetime of the admission pass (minimum 30s)
-client-proto-header CONCERT_CLIENT_PROTO_HEADER (empty) Header from a trusted TLS terminator carrying the client's HTTP protocol
-access-log CONCERT_ACCESS_LOG true Write an access log line per non-asset request
-html CONCERT_HTML_FILE (empty) Custom waiting room HTML file
-skip-url CONCERT_SKIP_URL (empty) Payment page URL for the skip-the-line card (see limitations)
-rate CONCERT_RATE 0 Base price per queue position (0 disables skip-the-line)
-surge CONCERT_SURGE 0 Extra price per position for each client in the queue
-pass CONCERT_PASS_DURATION 0 VIP pass lifetime after paying to skip (0 disables passes)
-upstream-timeout CONCERT_UPSTREAM_TIMEOUT 30s How long to wait for the origin's response headers
-api-json CONCERT_API_JSON true Answer queued non-browser clients with JSON 429 instead of HTML
-retry-after CONCERT_RETRY_AFTER 5 Retry-After seconds sent to queued API clients
-version Print the version and exit
CONCERT_ADMIN_TOKEN (empty) Bearer token for POST /_room/cap; the endpoint is disabled when unset
CONCERT_ADMIT_SECRET (random) Key that signs admission passes; at least 32 bytes

Both secrets are environment-only on purpose: command-line arguments are visible in ps output and shell history.

When CONCERT_ADMIT_SECRET is unset, Concert generates a random key at startup. Passes then stop working on restart, and a second instance won't accept the first instance's passes. Set it explicitly in production:

openssl rand -base64 48

Choosing a page capacity

-cap counts concurrent page requests, not users. A slot is held from the moment a request is admitted until the last byte of the origin's response has been sent to the client.

For a PHP-FPM origin, start with -cap at or slightly below the pool's pm.max_children. Concert then admits only as many requests as there are workers to run them, and the backlog lives in Concert's queue, where users can see their position, instead of in a socket buffer where they can't.

Throughput follows from Little's law: requests admitted per second ≈ cap ÷ average time a slot is held. With -cap 50 and a 200 ms average response, Concert admits about 250 requests per second. If the origin slows down under load, admissions slow down with it, which is exactly the protection you want.

Page capacity can be changed at runtime without a restart; see POST /_room/cap below.

Assets

List the paths that hold your stylesheets, scripts, fonts and images in -assets:

concert -upstream http://127.0.0.1:3000 \
        -assets "/wp-content/themes/*,/wp-content/plugins/*,/wp-includes/*" \
        -asset-public "/wp-content/uploads/*"
The admission pass

Every page response that room admits carries concert_admit: an HttpOnly cookie holding a random ID, an expiry, and an HMAC signature. Checking it costs one HMAC and no lookup, so any instance sharing CONCERT_ADMIT_SECRET accepts it.

The pass slides. It is re-signed once it is past half of -admit-ttl, on either a page load or an asset request. Visitors who keep browsing stay admitted, while most responses carry no Set-Cookie header and stay cacheable.

An asset request without a valid pass gets 403 Forbidden immediately. It never waits and never reaches your origin.

Per-user concurrency: HTTP/1.1 and HTTP/2

Each pass gets its own semaphores, so one visitor can have at most a fixed number of asset requests in flight. Browsers behave very differently on each protocol, so there are two rules.

HTTP/1.1 browsers open about 6 connections per host, so their natural concurrency is about 6. The default -asset-user-cap-h1 8 leaves a little slack.

HTTP/2 and HTTP/3 browsers send every request at once over one connection, up to the server's stream limit (nginx's http2_max_concurrent_streams defaults to 128). The default -asset-user-cap-h2 128 matches that.

A request that finds its pool full waits up to -asset-user-wait, then gets 429 Too Many Requests with Retry-After: 1. Each pass keeps a separate pool per protocol family, created on first use.

Behind a TLS terminator, Concert can't see the client's protocol. Browsers only speak HTTP/2 over TLS, and the terminator talks to Concert over HTTP/1.1 no matter what the browser used. Have the terminator pass the protocol in a header, and name that header with -client-proto-header:

proxy_set_header X-Client-Proto $server_protocol;

concert -client-proto-header X-Client-Proto ...

Values starting with HTTP/1 use the HTTP/1.1 rule, and HTTP/2 or HTTP/3 use the HTTP/2 rule. Anything else falls back to the protocol of Concert's own connection. Only enable this when a terminator you control sets the header, since it overwrites whatever the client sent.

Global asset concurrency

After its per-user check, every asset request takes a slot from one global semaphore. By default its size is cap × asset-user-cap-h2: with -cap 1200 and -asset-user-cap-h2 200, the asset cap is 240,000. Set -asset-cap to override it with a number that reflects what your asset tier can actually serve. A request that can't get a global slot within -asset-wait gets 503 Service Unavailable with Retry-After: 1.

The per-user check runs first, so a client over its own limit is rejected before it can take a global slot.

The asset cap is fixed at startup. POST /_room/cap changes only the page cap.

Public assets

Some assets are fetched by things that never loaded a page first:

  • link-preview crawlers from Slack, iMessage, Facebook and LinkedIn fetching your og:image
  • email clients rendering newsletter images
  • RSS readers
  • other sites embedding your images

List those paths in -asset-public. They skip the pass check but still count against the global asset semaphore.

Logging

The asset path writes no log lines; activity is counted in /_room/stats. Asset paths, /queue/status and /_room/healthz are also left out of the access log, and -access-log=false turns the access log off entirely.

Bypass paths

-bypass sends matching paths straight to the origin with no pass, no queue and no semaphore. Use it only for traffic that has no admitted visitor behind it and can't tolerate a queue:

-bypass "/favicon.ico,/robots.txt,/.well-known/acme-challenge/*,/webhooks/*"

That covers payment-provider webhooks, certificate renewal challenges and uptime checks. Bypassed paths are completely unprotected, so never bypass anything that runs expensive application code.

A path may appear in only one of -bypass, -assets and -asset-public. Overlapping or conflicting entries are rejected at startup.

API and non-browser clients

Browsers get the HTML waiting room. Any request whose Accept header does not include text/html gets a machine-readable response instead:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 5
Set-Cookie: room_ticket=…; Path=/; HttpOnly; SameSite=Lax

{"hint":"keep the room_ticket cookie and retry to hold your position","queued":true,"retry_after_seconds":5,"status_url":"/queue/status"}

A client that keeps cookies holds its place in line across retries:

curl -c jar -b jar https://example.com/api/orders      # 429, ticket issued
curl -c jar -b jar https://example.com/queue/status    # {"ready":false,"position":12,...}
curl -c jar -b jar https://example.com/api/orders      # 200 once ready

When -max-queue is reached, new arrivals receive 503 Service Unavailable with a JSON body and a Retry-After header.

Set -api-json=false to serve the HTML page to every client regardless of Accept.

Operations endpoints

These are never queued, so they keep answering even when the room is full.

Endpoint Purpose
GET /_room/healthz Liveness check; returns ok
GET /_room/stats Page and asset capacity, occupancy, queue depth, and counters
POST /_room/cap Change page capacity at runtime (requires CONCERT_ADMIN_TOKEN)

Example stats output:

{
  "asset_cap": 64000,
  "asset_denied_total": 312,
  "asset_global_throttled_total": 0,
  "asset_in_flight": 214,
  "asset_served_total": 48211,
  "asset_user_cap_h1": 8,
  "asset_user_cap_h2": 128,
  "asset_user_throttled_total": 9,
  "asset_users": 1180,
  "cap": 500,
  "evicted_total": 3,
  "live_queue_depth": 118,
  "max_queue_depth": 10000,
  "occupancy": 500,
  "promoted_total": 0,
  "queue_depth": 121,
  "queued_total": 412,
  "timeouts_total": 0,
  "token_ttl": "5m0s",
  "upstream": "http://127.0.0.1:3000",
  "utilization": 0.98
}

live_queue_depth counts only clients still holding a ticket, so it is the better number for dashboards than queue_depth, which briefly includes abandoned tickets. asset_users is the number of passes with an active per-user pool. Idle pools are removed after -admit-ttl.

Raising page capacity during an event:

curl -X POST https://example.com/_room/cap \
     -H "Authorization: Bearer $CONCERT_ADMIN_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"cap": 80}'

Expose /_room/* only on a trusted network, or block it at your TLS terminator.

Reserved paths

Concert answers these paths itself, and they never reach your origin:

/queue/status
/_room/healthz
/_room/stats
/_room/cap

Cookies

Concert removes all of these from requests before forwarding to the origin, so your application never sees them.

Cookie Purpose
room_ticket HttpOnly. Identifies a queued visitor's place in line
room_pass HttpOnly. VIP pass after paying to skip the line
room_probe Readable by JavaScript. Lets the waiting room page detect whether cookies work; carries no secret
concert_admit HttpOnly. Signed admission pass for the asset tier

A browser that refuses cookies can't hold a place in line. Instead of reloading forever, the waiting room page detects this and tells the visitor that cookies are required.

The same message appears when cookies are misconfigured. The most common cause is setting -secure-cookie while browsers reach Concert over plain HTTP. A wrong -cookie-domain produces it too.

Custom waiting room page

Replace the built-in page with -html /path/to/waiting_room.html. The file may use these placeholders:

Placeholder Replaced with
{{.Position}} The visitor's queue position
{{.SkipURL}} The skip-the-line URL, or an empty string

Your page's JavaScript is responsible for the whole client side of the queue:

  • Poll GET /queue/status every few seconds.
  • Reload the page when the response contains "ready": true.
  • Treat "cookies_required": true as final: stop polling and show an error.
  • Check document.cookie for room_probe before the first poll. If it is missing, show the cookie error immediately rather than polling.
  • Read position, and optionally skip_cost, rate_per_pos and has_pass, to update the display.

The waiting room page loads before the visitor has an admission pass. Any stylesheet, script or image it uses must come from a -bypass or -asset-public path, or be inlined.

Production deployment

A typical layout keeps TLS at the edge and Concert on localhost:

internet ──▶ nginx :443 (TLS) ──▶ concert 127.0.0.1:8080 ──▶ php-fpm site 127.0.0.1:3000

nginx:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Client-Proto $server_protocol;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection $connection_upgrade;
        proxy_buffering    off;
        proxy_read_timeout 3600s;
    }
}

Put the map block in the http section. Turning proxy_buffering off lets streamed responses reach clients immediately.

systemd unit (/etc/systemd/system/concert.service):

[Unit]
Description=Concert waiting room proxy
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/concert
Environment=CONCERT_LISTEN=127.0.0.1:8080
Environment=CONCERT_UPSTREAM=http://127.0.0.1:3000
Environment=CONCERT_CAPACITY=50
Environment=CONCERT_SECURE_COOKIE=true
Environment=CONCERT_CLIENT_PROTO_HEADER=X-Client-Proto
Environment=CONCERT_ASSETS=/wp-content/themes/*,/wp-content/plugins/*,/wp-includes/*
Environment=CONCERT_ASSET_PUBLIC=/wp-content/uploads/*
Environment=CONCERT_BYPASS=/favicon.ico,/robots.txt,/.well-known/acme-challenge/*
Environment=CONCERT_ACCESS_LOG=false
EnvironmentFile=/etc/concert/secrets.env
Restart=on-failure
DynamicUser=yes
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target

/etc/concert/secrets.env, mode 0600:

CONCERT_ADMIT_SECRET=…
CONCERT_ADMIN_TOKEN=…

On SIGTERM Concert stops accepting new connections and gives in-flight requests up to 30 seconds to finish.

Limitations

One instance, one queue. Queue state lives in the process's memory. Two Concert instances are two independent waiting rooms: the origin sees up to twice -cap, and a visitor's place in line exists only on the instance that issued their ticket. If you must run more than one, divide -cap by the number of instances and enable sticky sessions at the load balancer. Use a cookie the load balancer inserts itself, because room_ticket is only issued once a visitor is queued. Admission passes work across instances as long as they share CONCERT_ADMIT_SECRET. Per-user and global asset semaphores are per instance.

A pass limits concurrency, not request rate. A pass holder can have at most its per-user cap of asset requests in flight, but fast assets finish quickly, so a single pass can still generate many requests per second. The global semaphore bounds the total load on the host regardless. A visitor who discards cookies and keeps reloading gets a new pass on each admitted page load, which is bounded by page admissions. Volumetric attacks belong at a CDN or WAF in front of Concert.

Long-lived connections hold slots. WebSockets and server-sent event streams keep their page slot until they close. With -cap 50 and 50 open sockets, nobody else gets in. Run a separate Concert instance for long-lived paths.

Client address headers. Concert sets X-Forwarded-For to the address its own connection came from, and X-Forwarded-Proto to the scheme Concert itself received. Behind a TLS terminator, the origin therefore sees the terminator's address as the client and http as the scheme. Applications that log client IPs, rate-limit by IP, or build absolute URLs from the forwarded scheme (WordPress's HTTPS detection, for example) need to account for this.

Skip the line is not wired end to end. -rate, -surge, -skip-url, and -pass enable the pricing card on the waiting room page. But promoting a paid visitor requires calling room's in-process API after payment, and Concert does not yet expose an endpoint your payment flow can call. Leave -rate at 0 in production for now.

Development

make all                    # vet, clean, test, race tests, benchmarks, cross-platform build
make build                  # binaries for linux, darwin, windows × amd64, arm64 in bin/
go test -race -count=1 ./...

The test suite runs the real waiting room and asset tier against a fake origin. It covers:

  • configuration precedence and validation
  • admission pass signing, tampering, expiry and refresh
  • per-user HTTP/1.1 and HTTP/2 limits, and the global asset limit
  • public assets
  • queueing for browsers and API clients, the queue-depth breaker, and cookie-jar resume
  • bypass routing, streaming, and proxy header handling
  • the operations endpoints and graceful shutdown

License

Copyright 2026 Andrei Merlescu

Licensed under the Apache License, Version 2.0. You may not use this project except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Documentation

The Go Gopher

There is no documentation for this package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL