Agent buffering & backpressure

How events reach the dashboard without dropping the floor when the network hiccups - buffering, batching, retries, and what gives way under load.

5 min read · verified against [email protected]

A naive implementation would POST to core for every detection, every rate-limit hit, every ban. At any meaningful traffic, that floods the network, multiplies request latency, and falls apart the moment core has a hiccup. Buffering decouples emission from delivery.

Why buffer

Without bufferingWith buffering
One HTTP request per eventEvents append to in-memory buffer, fire and forget
Event emission blocks the request pathBackground task flushes the batch on a cadence
Network hiccup amplifies into request latencyTransient core failures absorbed by the buffer
Core flap means events are lost foreverRequest path never waits for core

The buffer is the shock absorber. Its size, flush cadence, and retry behavior are how you tune it.

Two buffers - events and metrics, independent

Bounded in-memory queues. Events and metrics each get their own. They fill independently, overflow independently, and flush independently to different endpoints.

Events buffer - detections, bans, rate-limit hits, behavioral rule trips. High-cardinality signal, one entry per security decision. Controlled by agent_enable_events.

Metrics buffer - aggregated counts and timings, pre-aggregated before emission. Lower-cardinality. Controlled by agent_enable_metrics.

Disabling one doesn't affect the other. agent_enable_metrics=False is a valid debugging move; events keep flowing.

Flush triggers - size OR time

Two conditions, evaluated continuously. When either crosses its threshold, the buffer flushes.

Size trigger - buffer fills to 80%. len(buffer) >= buffer_size * 0.8. Fires during traffic spikes when events arrive faster than they ship. Headroom before overflow - fires when 20% capacity remains, so a slow flush doesn't immediately push the buffer past its limit.

Time trigger - every flush_interval seconds. now - last_flush >= flush_interval. Bounds how stale a buffered event can get. Even at zero traffic, a sentinel flush runs on the interval.

The OR is what gives you both low latency under load AND predictable delivery during quiet periods.

Retry & backoff - exponential with a cap, 429 takes a different path

Each flush gets retry_attempts tries. Generic failures wait longer between attempts.

Attempt 1 → fail → wait 2s
Attempt 2 → fail → wait 4s
Attempt 3 → fail → give up

Formula: delay = backoff_factor × 2^attempt, capped at 60s. Default backoff_factor=1.0.

429 from core is special - the agent doesn't immediately retry. It hands control to its internal rate limiter, which paces subsequent flushes until core stops returning 429. A naive retry on 429 would amplify the load that triggered it. Self-throttling makes the agent a cooperative client instead of an adversarial one.

Overflow - what happens when the buffer fills

If events arrive faster than they can be flushed (sustained spike, prolonged core outage), the buffer fills. Three policies, picked via AgentConfig.buffer_overflow_policy (guard-agent 2.4.0+):

PolicyBehaviorWhen to use
"drop" (default)Silently evict the oldest entry. No exception, no log line, no metric.High-throughput production where event volume is high-cardinality and individual drops don't matter. Preserves prior behavior verbatim.
"block"Backpressure the caller until a flush frees space.Event integrity is critical. Only safe with start_auto_flush wired or a flush callback in place, otherwise the caller hangs forever. clear_buffer() is the manual escape hatch.
"raise"BufferFullError propagates to the caller.Tests, strict environments where dropping events is unacceptable. Caller decides what to do.

Opt-in - spill to Redis persistence. Independent of policy. When enabled, the agent persists overflowing entries to Redis at agent_events:* hashes (TTL 3600s). On recovery, the buffer drains from there as well as from memory. Trades a small amount of latency and Redis storage for delivery durability.

Sizing - survive one bad flush

Buffer sizing is a function of your peak rate and your flush cadence. The goal: a single flush failure shouldn't push you into overflow. Two flushes worth of headroom is the rule of thumb.

buffer_size ≥ peak_events_per_second × flush_interval × 2

Example - high-traffic API at 1,000 events/sec, flush_interval=2: buffer ≥ 4,000. The example app's agent_buffer_size=5000 covers this comfortably.

Wiring

SecurityConfig(
    agent_buffer_size=5000,
    agent_flush_interval=2,
    agent_retry_attempts=3,
    agent_timeout=30,
    agent_enable_events=True,
    agent_enable_metrics=True,
)
FieldEffect
agent_buffer_size=5000Up to 5,000 events queued before overflow eviction. Library default is 100.
agent_flush_interval=2Time trigger: flush every 2 seconds if not already.
agent_retry_attempts=3Up to 3 retries per flush. Delays: 2s / 4s / 8s.
agent_timeout=30HTTP timeout per attempt - 30 seconds.
agent_enable_events=TrueEvents buffer active.
agent_enable_metrics=TrueMetrics buffer active.

Troubleshooting

SymptomLikely causeFix
Events disappear during core outagesBuffer overflowed; silent drop of oldest with no error surface. Outage duration × event rate exceeded capacity.Increase agent_buffer_size, or enable Redis persistence so overflow spills instead of evicts. Re-run sizing formula with actual peak rate.
Events are minutes lateflush_interval too long; or retries stretching the cycle (2s + 4s + 8s before giving up)Lower agent_flush_interval. Only raise retry_attempts after diagnosing the upstream failure.
Persistent 429 from core during normal trafficAgent is self-throttling; events still flow, just slower. If persistent, you're exceeding your plan quota.Check plan tier and event quota in dashboard. Consider plan upgrade, sampling at source, or filtering low-signal events.