Every call goes through the same steps. Any step can stop it.
- Authenticate. Verify the session token. The token names the provider, so the agent can't pick another.
- Match. Turn the call into an action and its params. Unknown paths, params, fields and arguments are rejected here. So are paths with ambiguous encodings (
//,%2f,%2e...) and method-override headers (X-HTTP-Method-Overrideand friends), which could make the upstream see a different call than the gateway checked. - Decode. For actions with a body decoder (e.g. Gmail
messages.send), read the body intor.context. A body that can't be decoded is denied. - Decide. Run the policy, then any checks.
- Forward. Add the real credential and send the exact bytes that were checked to the provider's fixed upstream. Only a short list of request headers is passed on (
accept,content-type,if-match...); cookies and the session token are dropped. The response comes back as-is, minus cookies and connection headers.
Errors
Every error message starts with Error for waterlock proxy: , followed by the reason. The agent can tell the gateway refused the call, not the provider, and won't treat it as an outage to retry.
Errors are handled per provider. Each provider answers in the shape its official SDK parses, so a denial surfaces like any other error from that SDK, with its usual error class, code and message. The shape, and what the SDK exposes, is on each provider's page, under Errors:
A generic body is the fallback, decided case by case: for a provider whose SDK has no error format to match, and for custom providers that don't set one. REST: { error: { code, message, action?, rules? } }. On a GraphQL endpoint, GraphQL's shape, so clients still surface the message: { errors: [{ message, extensions: { code, action?, rules? } }] }. unauthenticated and unknown_provider happen before the gateway knows the provider, so they always use the generic REST body.
The gateway's error codes. Each provider page says where the code goes in its shape:
| Status | code | When |
|---|---|---|
| 401 | unauthenticated | Missing, malformed, wrongly signed or expired session token. |
| 403 | policy_denied | The policy or a check denied the call. message is the reason. |
| 404, 405, 400 | policy_denied | No such action, method or argument, or an ambiguous path. |
| 400 | bad_request | Method-override header, wrong GraphQL content type. |
| 413 | bad_request | Body over maxBodyBytes (10 MB by default). |
| 403 | unknown_provider | The token's provider isn't on this gateway. |
| 502 | upstream_auth | The credential source failed. |
| 502 | upstream_unreachable | The provider couldn't be reached. |
Custom providers
Set formatError to answer in your provider's own shape. It gets the status, the code from the table above, the prefixed message and, for denials, the action and rule ids.
import { createProvider, type GatewayError } from "@waterlock/gateway";
createProvider<AcmeRequest>({
id: "acme",
// ...
formatError: (e: GatewayError) =>
Response.json({ code: e.status, message: e.message }, { status: e.status }),
});