Feature flag 404s after a delete and recreate: what actually breaks
Which Infrai flag routes 404 on a missing key, which one quietly answers false instead, and how to write fallback defaults that survive a recreate in Node 22.
Delete a flag on Infrai and the read routes split into two camps. GET /v1/flags/get/{key}, GET /v1/flags/get_value/{key} and POST /v1/flags/toggle/{key} all answer HTTP 404 with the error code FLAG_NOT_FOUND. But GET /v1/flags/is_enabled/{key} answers HTTP 200 with enabled: false. That second behaviour is the one that reaches production, because nothing throws and no alert fires.
A recreated flag isn’t the flag you deleted, either. It comes back at version: 1 with an empty rules array, and any rollout percentage or tag set you had configured is gone. Infrai’s flags namespace stores a key, not a lineage — there’s no archive state, no restore, no tombstone you can consult later.
The missing-key matrix
We deleted a scratch key and asked every read route about it. This is what came back:
| Route | Missing key | Body |
|---|---|---|
GET /v1/flags/get/{key} | 404 | FLAG_NOT_FOUND, param: "key" |
GET /v1/flags/get_value/{key} | 404 | FLAG_NOT_FOUND, param: "key" |
POST /v1/flags/toggle/{key} | 404 | FLAG_NOT_FOUND, param: "key" |
GET /v1/flags/is_enabled/{key} | 200 | {"enabled": false, "value": false} |
GET /v1/flags/get_all | 200 | key simply absent from the map |
DELETE /v1/flags/delete/{key} | 200 | {"deleted": false} |
Ask for a key that never existed and the 404 is unambiguous:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -w '\nHTTP %{http_code}\n' \
"https://api.infrai.cc/v1/flags/get/kb_does_not_exist_xyz" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": false,
"error": {
"code": "FLAG_NOT_FOUND",
"http_status": 404,
"message": "flag 'kb_does_not_exist_xyz' not found",
"docs_url": "https://docs.infrai.cc/errors",
"retryable": false,
"param": "key",
"trace_id": "trc_d6ee1ef56e66494a96a5e191"
}
}
Now the same key through the boolean shorthand:
curl -sS -w '\nHTTP %{http_code}\n' \
"https://api.infrai.cc/v1/flags/is_enabled/kb_does_not_exist_xyz" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": { "key": "kb_does_not_exist_xyz", "enabled": false, "value": false }
}
No error. No 404. A deleted kill switch and a typo in a flag name are indistinguishable from a feature that’s legitimately off, which means a rename during a refactor silently dark-launches everything behind the old key. Worth flagging that in our testing is_enabled returned false for keys that do exist and whose default_value is true — so it isn’t a reliable read path in either direction, and you should route your reads through get_value or get_all instead.
Delete is idempotent, and that’s the useful part
DELETE /v1/flags/delete/{key} never 404s. The first call returns deleted: true, every call after that returns deleted: false, and both are HTTP 200.
curl -sS -X DELETE "https://api.infrai.cc/v1/flags/delete/kb_legacy_banner" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
# {"ok":true,"data":{"id":"kb_legacy_banner","deleted":true}}
curl -sS -X DELETE "https://api.infrai.cc/v1/flags/delete/kb_legacy_banner" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
# {"ok":true,"data":{"id":"kb_legacy_banner","deleted":false}}
So a teardown script can run twice without special-casing anything — read deleted if you actually care whether you were the one who removed it. The catch is on the way back up: recreating the key through POST /v1/flags/set gives you a fresh record whose version restarts at 1, with no rollout object and no tags. Any compare-and-swap logic that cached the old version number will now collide against a lower number than it expects, and a 10% rollout you’d been ramping quietly becomes 100% of whatever the new default_value says. Re-apply the rollout explicitly after every recreate; don’t assume it rode along.
A resolver that can’t be surprised
The fix is to stop treating “the API answered” as “the flag exists”. Fetch the whole map, keep the last good copy, and resolve unknown keys against defaults you declared in code — so a deleted or misspelled key falls back to a value you chose rather than to false by accident.
import process from "node:process";
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
// Declared in code, reviewed like code. Unknown key => this value.
const DEFAULTS = {
kb_beta_banner: false,
kb_checkout_v2: false,
};
let snapshot = { at: 0, flags: null };
async function loadFlags() {
const res = await fetch(`${BASE}/v1/flags/get_all`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`get_all failed: HTTP ${res.status}`);
const body = await res.json();
if (!body.ok) throw new Error(`get_all error: ${body.error?.code}`);
snapshot = { at: Date.now(), flags: body.data.flags };
return snapshot.flags;
}
export async function flag(key) {
if (!(key in DEFAULTS)) {
throw new Error(`flag "${key}" has no declared default — add one to DEFAULTS`);
}
try {
if (Date.now() - snapshot.at > 30_000) await loadFlags();
} catch (err) {
console.error(`flag refresh failed, using last snapshot: ${err.message}`);
}
const flags = snapshot.flags;
if (flags && key in flags) return flags[key];
console.warn(`flag "${key}" missing upstream — falling back to declared default`);
return DEFAULTS[key];
}
console.log("kb_beta_banner =>", await flag("kb_beta_banner"));
Two things that snippet does deliberately. It throws on a key with no declared default, which turns a typo into a deploy-time crash instead of a silent false in production; and it distinguishes “missing from the map” from “refresh failed”, logging each differently, because those need different pages at 3am.
Verify the map really contains your key:
curl -sS "https://api.infrai.cc/v1/flags/get_all" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.data.flags | has("kb_beta_banner")'
And read the full record — version, rules, rollout — when you need to know what a recreate dropped:
curl -sS "https://api.infrai.cc/v1/flags/get/kb_beta_banner" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '{key: .data.key, version: .data.version, rollout: .data.rollout, rules: .data.rules}'
When to use something else
Every route above is free and rate-limited, and calling them doesn’t draw down the $2 credit a new account starts with (verified 2026-07-26). Rates and billing classes do move, generally downward, so read GET /v1/discovery for today’s answer rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.namespace=="flags") | {id, free: .billing.free}]'
The real limitation isn’t price, it’s history. There’s no audit trail of who deleted what, and no soft-delete to undo. If a deleted flag needs to be recoverable and attributable, LaunchDarkly’s archive-and-restore workflow is the product that exists for exactly that, and Unleash gives you the same thing on infrastructure you run yourself — pick either over rebuilding an audit log on top of a key-value store. Flagsmith is a reasonable middle if you want self-hosting with a console your support team can use.
What Infrai gives you instead is that the flag store, the error tracker that catches the fallback, the queue behind the migration and the metrics you judge it on all sit on one key and one bill. When a recreate goes wrong, the follow-up work doesn’t start with a new vendor signup.