Configuration

Configuration comes from two places: appsettings.json (standard ASP.NET Core config, overridable by environment variables or user secrets) for most settings, and a .env file for broker connections specifically.

appsettings.json sections

Section Key Purpose
GitHub WebhookSecret Shared secret used to validate the GitHub webhook HMAC signature
Broker AuthToken Bearer token required by all /Broker/* admin endpoints
Broker MonthlyMessageLimit Default monthly message limit for every broker connection, in broker-counted messages. Defaults to 2000000, CloudAMQP’s free LavinMQ tier cap. Overridable per connection with BROKER_LIMIT_N in .env (see below). Bound once at startup, so a change needs a restart
Broker MessageCost How many broker-counted messages one published payload consumes against the limit. CloudAMQP counts a message once on publish and once per delivery, so with the exchange bound to two queues a payload costs 3 (the default). Raise it if more queues are bound; values below 1 are treated as 1. Bound once at startup
Consumer ApiUrl Base URL of the Consumer’s API (the sibling webhooks project), e.g. https://webhooks.example.com/. Together with ApiToken it turns on Switch Notifications (see below); leave both empty to switch the Consumer by hand
Consumer ApiToken The Consumer’s API token, sent as Authorization: token <ApiToken>
Logger Url, Key, Token Endpoint and credentials for reporting errors to the Projects Monitor system

Set these via environment variables (e.g. Broker__AuthToken), dotnet user-secrets in development, or your deployment platform’s secret store — never commit real values.

.env — broker connections

Broker connections are not read from appsettings.json; they come from a .env file next to the running executable, loaded by EnvFileLoader:

BROKER_URL_1=amqps://user:password@host1.example.com/vhost1
BROKER_URL_2=amqps://user:password@host2.example.com/vhost2
BROKER_URL_3=amqps://user:password@host3.example.com/vhost3
BROKER_LIMIT_2=1500000
BROKER_ACTIVE=1
  • BROKER_URL_N — one entry per broker connection (any number of them, numbered from 1). The numbering is the order in which connections are tried when one runs out of capacity.
  • BROKER_LIMIT_N — optional monthly message limit for BROKER_URL_N only, overriding Broker:MonthlyMessageLimit. Must be a non-negative integer; anything else is ignored. 0 takes that connection out of rotation without removing it.
  • BROKER_ACTIVE — 1-based index of the connection to use on startup.
  • The active connection switches on its own when it reaches its monthly limit (see below). It can also be changed manually at runtime via POST /Broker/switch/{index}, or the whole list (including limits) can be re-read from disk via POST /Broker/reload — see API Reference.

Monthly message limits and automatic rotation

Every broker connection has a monthly message limit: BROKER_LIMIT_N when set, otherwise Broker:MonthlyMessageLimit. GET /broker/usage/current reports the effective limit next to each broker’s usage.

Before publishing a message the handler reserves Broker:MessageCost units of that limit against the active connection in the SQLite usage store (the store keeps the published-payload count; the limit check multiplies it by the cost). The reservation is a single atomic increment-if-within-limit, so concurrent requests can never push a connection past its limit. If the publish then fails, the reservation is handed back.

Handler-side retries never double-count (a failed publish releases its reservation before the retry reserves again), but broker-side redeliveries to the consumer do count on CloudAMQP’s side and are not visible to the handler, so expect the broker’s own figure to run slightly ahead of usage. Compare against the CloudAMQP console or the broker’s management API (message_stats.publish + deliver_get) when reconciling.

When the active connection is at its limit, the handler walks the connection list in BROKER_URL_N order starting after the active one, wrapping around to the beginning, and switches to the first connection with room. The switch is logged as a warning naming both connections; no operator action is needed. If every connection is at its limit, the payload is not published and POST /GitHub responds 503 Service Unavailable (logged as an error and reported to Projects Monitor).

Usage is counted per calendar month (UTC), so exhausted connections come back into rotation automatically on the 1st. The active connection does not move back by itself at month start; it simply keeps being used until it fills up again.

Telling the Consumer

The Consumer keeps its own list of the same broker connections and does not learn about a switch from the broker itself. When Consumer:ApiUrl and Consumer:ApiToken are set, every automatic switch is followed by a Switch Notification: POST {ApiUrl}/api/v1/broker/pending-switch with the connection the handler left and the one it now publishes through, identified by host and virtualHost (the two services’ lists need not be in the same order, so indexes are never used). The Consumer records it as a pending switch and moves once it has drained the old connection, so nothing is processed out of order.

The notification is written to an outbox in the stats SQLite database before it is sent, and a background dispatcher delivers it — immediately, then retrying with exponential backoff (15 s, 30 s, 1 min … up to 1 h) until the Consumer answers 2xx; after 7 days it is abandoned and logged as an error. GET /Broker/consumer-notifications lists the outbox with each notification’s state. Manual switches (POST /Broker/switch/{index}) and reloads are not notified: move the Consumer by hand in those cases.

With either setting empty the handler logs a warning at switch time and queues nothing. See ADR-0005.

Two things to keep in mind:

  • DELETE /broker/usage/current/{broker} resets the counter routing decisions are based on, so it re-opens that connection with a full month of capacity. Only use it when the broker-side count really was reset.
  • Lowering BROKER_LIMIT_N below the current count (then POST /Broker/reload) stops that connection immediately.

This is how multiple free-tier broker connections are rotated to stay under CloudAMQP’s per-broker message quota — see Architecture, ADR-0002 and ADR-0003.