Your AI app answers a question about your data. The answer sounds right, the number looks plausible, and nothing errors. A week later someone in finance notices it was wrong.
The fix most teams reach for is a better prompt. The fix that actually protects users is simpler: when the system can't back an answer, it should say so, and your app should show that clearly instead of a number.
This post shows how to wire that into an app, in Python and Node, using FactQL. The pattern applies to any AI data layer that can decline.
What a decline looks like
FactQL runs the query before it answers. When it can back the answer, you get the explanation, the query that ran, the rows and the lineage:
{
"result": "success",
"sql": "SELECT region, SUM(revenue) AS total_revenue FROM orders GROUP BY region ORDER BY total_revenue DESC",
"data": { "columns": ["region", "total_revenue"], "rows": [["North", 43148.0], ["South", 41223.0]] },
"explanation": "North leads with 43,148, followed by South (41,223).",
"trace_id": "c5f50cff-..."
}
When it can't, it declines instead of guessing. Note this is still HTTP 200:
{
"result": "error",
"decline_reason": "no_safe_sql",
"explanation": "I couldn't find data that answers this. Try naming the table or column you mean.",
"trace_id": "c5f50cff-..."
}
That status code matters. If your code only checks resp.ok, a decline sails through and your UI renders an empty or half-filled answer. You have to check result.
The four rules
- Check
resultbefore you show any number. -
On a decline, show the
explanation. It's written for the end user and tells them what to change. - Don't retry the same question. It gets the same answer.
-
Retry only on HTTP 429, 500 and 503, after
Retry-After.
Python
import os, sys, time
import requests
RETRYABLE = {429, 500, 503}
def ask(question: str, attempts: int = 3) -> dict:
for attempt in range(attempts):
resp = requests.post(
"https://api.factql.ai/query",
headers={"X-API-Key": os.environ["FACTQL_API_KEY"]},
json={"q": question},
timeout=120,
)
if resp.status_code in RETRYABLE and attempt < attempts - 1:
time.sleep(int(resp.headers.get("Retry-After", 2 ** (attempt + 1))))
continue
resp.raise_for_status()
return resp.json()
def render(answer: dict) -> str:
if answer.get("result") == "success":
return answer["explanation"]
print(f"[log] declined: {answer.get('decline_reason')} trace={answer.get('trace_id')}", file=sys.stderr)
return f"I can't verify that from your data. {answer.get('explanation', '')}".strip()
print(render(ask("What was our ad spend last quarter?")))
Ask about something your data doesn't contain (ad spend on a sales-only database is the easiest) and you'll see the decline path.
Node
const RETRYABLE = new Set([429, 500, 503]);
async function ask(question, attempts = 3) {
for (let i = 0; i < attempts; i++) {
const resp = await fetch("https://api.factql.ai/query", {
method: "POST",
headers: { "X-API-Key": process.env.FACTQL_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ q: question }),
});
if (RETRYABLE.has(resp.status) && i < attempts - 1) {
const wait = Number(resp.headers.get("Retry-After")) || 2 ** (i + 1);
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
}
function render(answer) {
if (answer.result === "success") return answer.explanation;
console.error(`[log] declined: ${answer.decline_reason} trace=${answer.trace_id}`);
return `I can't verify that from your data. ${answer.explanation ?? ""}`.trim();
}
If an agent is calling it
When FactQL is a tool for an LLM agent, the decline has to survive the agent too. Two things help:
- Return the decline to the agent with an unmistakable prefix, like
DECLINED:. - Tell the agent in its system prompt: only state numbers that come back from the tool; if it returns DECLINED, say you can't verify that and pass on the explanation; never estimate.
Without that instruction, a helpful model will often "fill the gap" with an estimate, which puts you right back where you started.
Logging declines is free product research
Every decline_reason you log is a question your users asked that your data couldn't answer. Grouped weekly, that list tells you which tables, columns or definitions to add next.
Full code
All of this, plus a LangChain / LangGraph tool, OpenAI-compatible function calling, a Next.js route and an embedded chat, is in factql/factql-examples (MIT).
We build FactQL, so we're biased: it has a free tier with no card. The pattern works with any AI data layer that can say "I can't verify that".
Top comments (0)