FR EN

Handling outages

What should happen when your code calls check() and Agreely cannot be reached? Agreely's answer is deny, by default, everywhere. This guide explains why, and how to make a narrow, audited exception when you genuinely need one.

Fail-closed is the default

When Agreely is unreachable (a 503, a timeout, or a network error):

  • check() returns false (deny);
  • checkDetailed() throws AgreelyUnavailableError;
  • the CLI exits 4 (distinct from a deny's 10).

A real 200 deny is never affected by any of this. An outage is not a denial, but the safe default for an outage is to behave as if denied, so an infrastructure problem can never silently grant access to data.

Distinguish outage from deny

Use checkDetailed() (or the CLI exit code) when you need to tell the two apart. A deny is an authoritative answer you must honor; an outage is a transient failure you may retry, alert on, or, for a chosen category, degrade. check() collapses both to false on purpose, so the simplest integration is always safe.

The fail-open exception

Some categories are low-risk enough that denying them during an outage is worse than allowing them (for example, a non-sensitive analytics signal). The SDKs let you fail open for such categories, but only through independent gates, so it can never happen by accident and is always audited.

TypeScript: three gates

const agreely = new Agreely({
  apiKey,
  degradeOnOutage: {
    mode: "fail-open",                 // gate 1a: the explicit word
    categories: ["Browsing/usage"],    // gate 1b: ONLY these may ever degrade
    maxOutageWindow: "5m",             // refuse to degrade past this
    onDegrade: (ctx) => audit.log(ctx) // MANDATORY, without it, the constructor throws
  },
});

// gate 2: the call must ALSO opt in. Effective only because the category is
// allow-listed. Without the config, this per-call opt-in still denies.
await agreely.check("cust_8812", "Browsing/usage", "Analytics", { onOutage: "allow" });

// gate 3 (operator): break-glass, for an active incident.
agreely.breakGlass.engage({ reason: "incident-4471", ttl: "30m", scope: ["Browsing/usage"] });

PHP: two gates

PHP ships the same config allow-list + per-call opt-in. It omits break-glass in v1, because a request-scoped runtime cannot hold a fleet-wide "engaged" state honestly (see the PHP SDK).

$agreely = new Agreely([
    'apiKey' => $key,
    'degradeOnOutage' => [
        'mode'            => 'fail-open',
        'categories'      => ['Browsing/usage'],
        'maxOutageWindow' => '5m',
        'onDegrade'       => fn ($ctx) => $audit->log($ctx), // MANDATORY
    ],
]);
$agreely->check('cust_8812', 'Browsing/usage', 'Analytics', ['onOutage' => 'allow']);

The guardrails

What fail-open will not let you do

  • No silent degrade. onDegrade is mandatory; without an audit sink the constructor throws. Every degraded allow emits an evidence record, and every break-glass-authorized allow emits its own event.
  • No unbounded window. Both ttl and maxOutageWindow are bounded by maxDegradeWindow, 24h by default, which a client option can raise; over that bound the constructor throws AgreelyConfigError.
  • No allow-cache. There is no cache of allow decisions; fail-open is computed per call during an outage, never replayed from a stored allow.
  • No accidental opt-in. A per-call onOutage: "allow" with no matching allow-listed category has no effect (the check still denies) and logs a one-time warning.

Tuning 429s, not outages

A rate limit (429) is not an outage: it is a signal to slow down. Two constructor options tune it, without ever touching the fail-closed policy. maxRetries (default 0) adds extra attempts on a 429 only and only for idempotent calls (GET and check); a mutating call never retries. respectRetryAfter (default true) honors the server's Retry-After header. An outage, by contrast, is retried once only: on a network error or a 503, both SDKs make a single further attempt of an idempotent call (GET and check), inside the same time budget. If that fails too, the call denies (or throws AgreelyUnavailableError) and leaves the decision to you. A write is never retried.

Suspended billing is not an outage

When the billing of the company behind the key is suspended, the API answers 402. It is neither transient nor retryable: billing has to be brought up to date. check() returns false, checkDetailed() throws AgreelyBillingInactiveError, and the CLI exits with 7. The fail-open exception never applies to it: it only plays for an outage. Only the consent-withdrawal route keeps answering, because honouring a withdrawal is a duty, not a paid service.

Recommendation

Keep the default. Fail-closed is correct for anything that touches sensitive data or carries a compliance obligation, which is most of what you will gate. Reach for fail-open only for a specific, low-risk category where availability genuinely outweighs strict enforcement, and wire its onDegrade sink into the same audit trail you would show a regulator.

Next