Your Agent Got a 400 With No Error Message. Here's How to Find Out Why.
An empty 400 is the worst failure an AI agent can hit, because the model cannot tell a wrong field name from a wrong value and will happily retry the same broken call forever. Here is how to read one, and how to stop generating them.
Key takeaways
- -A 400 with no usable body is almost always a shape problem: an invented field, a wrong type, a missing required parameter or an ID in the wrong place.
- -Never retry a 400. It is deterministic. The same request will fail identically and you have just doubled your rate-limit burn.
- -The model cannot debug this. It has no way to tell a wrong field name from a wrong value, so it guesses and makes it worse.
- -Validate arguments against the real schema before the request leaves your machine, and the 400 never happens in the first place.
- -Six questions, in order, will identify almost any opaque 400 in under five minutes.
A 400 Bad Request with an empty body is the single most expensive error an AI agent can hit. Not because it is hard to fix, but because of what the agent does next.
A 500 tells the agent to back off and retry. A 429 tells it to wait. A 404 tells it the thing is not there. A bare 400 tells it nothing, so the model does the only thing it can: it guesses what might be wrong, changes something, and tries again. And again. Each attempt is a plausible-looking request that fails identically, and nothing in the loop is getting closer to the answer.
The short version: an opaque 400 is nearly always a shape problem, it is deterministic, and you must not retry it. The rest of this post is how to find which shape problem in about five minutes, and how to stop producing them at all.

Why the model cannot debug this
Worth being explicit, because teams keep trying to prompt their way out of it.
When a request fails with a useful message, the model has something to reason about. "missing required parameter: customer" is actionable. An empty body, or {"error":"Bad Request"}, contains exactly zero bits of information about which of the twelve things you sent was wrong.
So the model falls back on its prior, which is its memory of the API's documentation. That memory is the thing that produced the broken request in the first place. You are asking the source of the error to correct the error using the same information that caused it. This is the mechanism behind why your AI agent calls the wrong API and why AI coding agents keep using deprecated APIs.
There is also a quieter cost. A model that has been burned by three unexplained 400s often abandons the approach entirely and tells the user the integration does not support what they asked for. The feature was fine. One wrong field name talked the agent out of it.
Never retry a 400
This is the one rule worth hard-coding.
4xx means the request was wrong. Sending the identical request again produces the identical failure, costs another rate-limit token, and adds another confusing line to your logs. The only 4xx worth retrying is 429, and that is a different mechanism with a Retry-After header.
The defaults in most agent frameworks retry on any non-2xx. Check yours. A correct retry policy looks like this:
function isRetryable(status) {
if (status === 429) return true; // rate limited, honour Retry-After
if (status >= 500 && status !== 501) return true; // server side, may succeed
return false; // everything else: fix the request
}And if you do retry, the retry must carry the same idempotency key as the original, or a timed-out write becomes two writes. That is covered in idempotency explained.
Six questions, in order
Run these against the actual request your agent sent, not the one you think it sent. Log the outgoing request body before you start.
1. Does every field you sent actually exist?
This is the most common cause by a distance. Models invent plausible field names: customer_id where the API wants customer, email_address where it wants email, name where it wants firstname and lastname separately.
Many APIs reject unknown fields outright with no explanation of which one. Diff your request body against the real schema field by field. The offending key is usually obvious once they are side by side.
2. Is anything the wrong type?
"amount": "1000" instead of "amount": 1000. A string where an integer belongs, or the reverse. This happens constantly because the model is producing JSON as text and quoting is a coin flip.
Booleans are the sneaky version: "true" instead of true passes JSON parsing and fails validation.
3. Is a required field missing?
Obvious when stated, hard to see in practice, because the model usually sends something for every field it thinks exists. The missing one is typically a field the model did not know about at all, often a newer required parameter that post-dates its training data.
4. Is an ID in the wrong place, or the wrong kind of ID?
Path parameter versus query parameter versus body field. /v1/customers/{id}/charges with the ID in the body instead of the path gives you a 400 with no explanation on plenty of APIs.
The nastier variant is the right slot with the wrong ID type. Stripe is the classic: a charge ID ch_... where a payment intent pi_... belongs, or a customer cus_... where a subscription sub_... belongs. Both are strings, both look right.
5. Is the format wrong rather than the value?
Dates are the usual suspect. ISO 8601 versus Unix epoch versus YYYY-MM-DD. Currency as 10.00 versus 1000 minor units, which is the single most expensive formatting mistake in this list because it silently succeeds in the wrong direction when it does not 400.
Enums too: "status": "Active" where the API wants "active".
6. Is it the endpoint, not the payload?
If all five above check out, the request may be well-formed for an endpoint that no longer takes it. Deprecated versions, a renamed path, a method that moved from POST to PATCH. Confirm the operation still exists in its current form before debugging the body further.
Get the body, if there is one
Before assuming there is no message, make sure your code is not throwing it away. A surprising number of HTTP wrappers raise on non-2xx and discard the response body in the process.
const res = await fetch(url, { method: "POST", headers, body });
if (!res.ok) {
// read it before you throw it away
const text = await res.text();
console.error("status", res.status);
console.error("body", text || "(empty)");
console.error("request-id", res.headers.get("request-id")
?? res.headers.get("x-request-id")
?? "(none)");
throw new Error(`${res.status}: ${text.slice(0, 500)}`);
}Two things worth grabbing while you are there. Many APIs put detail in headers rather than the body. And almost all of them return a request ID, which turns a support ticket from "it sometimes fails" into "here is the exact request."

The fix is upstream
Everything above is diagnosis. The actual fix is to stop the malformed request leaving your machine.
A 400 means the request was wrong, and you can usually know it was wrong before you send it, because the API's schema says so. Validating arguments against the real schema at call time turns an opaque remote 400 into a local error that names the field.
That is one of the things an execution runtime does. With Swytchcode the method definition comes from the registry rather than the model's memory, so an invented field fails immediately with a message that identifies it:
npm install -g swytchcode
swy login
swy init --mode=sandbox
swy get stripe
swy discover "refund a charge" # find the operation by description
swy info stripe.refund.create3 # see the real inputs and outputs
swy add method stripe.refund.create3
swy auth connect stripeswy info is the command that ends most of these arguments. It prints the actual parameters, their types and which are required, which is the document the model was approximating from memory.
import { exec, isSwytchcodeError } from "@swytchcode/runtime";
try {
const res = await exec("stripe.refund.create3", {
body: { charge: "ch_3QxYzAbCdEfGhIjK" },
});
console.log(res.id, res.status);
} catch (err) {
if (isSwytchcodeError(err)) {
// schema failure, policy block, expired login or provider error,
// named rather than a bare 400
console.error(err.message);
}
throw err;
}No .env file is involved. Credentials go into ~/.swytchcode/credentials.db outside your project via swy auth connect, and mode is set once at swy init in .swytchcode/tooling.json. The model never sees a key, which is the argument in credential brokering for AI agents.
There is also a prevention layer most people skip. Only methods you have explicitly allowed with swy add method can run at all, so an agent reaching for an operation you never approved fails at the runtime with a clear reason rather than producing a mysterious 400 from a half-remembered endpoint.
And the reason the schema stays right: every API added to Swytchcode becomes agent-ready in minutes, and from then on its specs and documentation are observed for breaking changes and updates. A renamed parameter arrives as an integration update rather than as a week of unexplained 400s.
Make the failure legible in your logs
Whatever you use, log enough to answer "why did this fail" without rerunning it. The operation as a stable canonical ID rather than a constructed URL, a hash of the arguments, the status, whether the response passed schema validation, and the provider's request ID.
A 400 with schema_valid: false and a named field is a five-minute fix. A 400 with nothing attached is an afternoon. The full field list is in logging every API call an agent makes.
The sibling failure is worth knowing about too: a 200 OK with the error hidden in the response body, which is more dangerous because nothing fails at all and the agent proceeds on a false premise.
FAQ
Why does my AI agent get a 400 error with no message?
Because the request shape is wrong and the API chose not to say how. The usual causes, in order of frequency: a field name the model invented, a value of the wrong type, a missing required parameter, an ID in the wrong position or of the wrong kind, a format mismatch on a date or currency, or an endpoint that has changed. Diff the actual outgoing request against the real schema rather than asking the model to guess again.
Should I retry a 400 error?
No. 4xx means the request itself was wrong, so an identical retry fails identically while burning rate limit and filling your logs. The only 4xx worth retrying is 429, honouring Retry-After. Many agent frameworks retry on any non-2xx by default, so check yours explicitly.
Why does my agent keep sending the same broken request?
Because an empty 400 gives it nothing to learn from, so it falls back on its memory of the documentation, which is what produced the broken request in the first place. Without a schema in the loop, the agent is guessing from the same wrong source each time. Validate against the real schema before sending, and the loop never starts.
How do I see the real error from the API?
Read the response body before your HTTP wrapper throws it away, since many wrappers raise on non-2xx and discard it. Check response headers too, as some APIs put detail there. Capture the provider's request ID header, which lets their support team find the exact call. If the body really is empty, the request shape is the only thing left to check.
What is the difference between a 400 and a 422?
Broadly, a 400 means the request was malformed and a 422 means it parsed correctly but failed validation. In practice APIs are inconsistent and plenty return 400 for both. Treat them the same way: do not retry, and go looking for a shape problem.
Can I stop these errors happening at all?
Mostly, yes. The majority are preventable by validating arguments against the current schema before the request is sent, so a hallucinated field fails locally with a message that names it. Restricting the agent to an explicit allow list of operations removes a second class, where the model reaches for an endpoint that does not exist. What remains is genuine value errors, and those usually come back with a usable message.
Does a 400 ever mean the API is broken rather than my request?
Rarely, but it happens when a provider ships a change that makes previously valid requests invalid, which is a breaking change arriving without a version bump. The tell is that a request which worked last week now fails unchanged. Check the provider's changelog, and if your tooling tracks spec and documentation changes, check there first.
More content
How to Log Every API Call an AI Agent Makes
Log the call where it is sent, not where it is described. Record the operation, the actor, the idempotency key, the policy decision and the outcome. Never record the credential. If the agent writes its own log, the calls that were blocked quietly disappear.
Composio Pricing Compared with Nango, Arcade, Pipedream, and Swytchcode
Composio is free for 100,000 tool calls a month, then $29. Nango is $50 pay-as-you-go. Arcade Team is $25 plus a cent a call. Pipedream Startup is $99 billed annually. Swytchcode is $149 for a million. The headline prices are the least useful part, so here is the arithmetic.
Multi-Tenant OAuth for AI Agents: How to Store a Credential for Every Customer
Multi-tenant OAuth means one stored grant per customer, not one token in a .env file. If every customer connects their own Slack or GitHub, you need a hosted connect flow, a credential stored against their ID, and every call tagged with whose account it used. Here is who does what.
