Data Connector Errors
@monospace/cli 0.2 and @monospace/extension-kit 0.3. Throw a Connector Error
A data connector extension reports a failure by throwing one of the kit's error classes from setup or a handler. Import them from @monospace/extension-kit/data-connector.
The connector exposes an external data source as collections, which are containers for items of the same type. An item is a single data object, and its named values are fields. This excerpt from the Stripe example's client throws a kit class when the Stripe API can't be reached or doesn't answer in time. It reads the body inside the same try, and keeps the rejection as cause; // … marks the lines it leaves out:
import {
AccessDenied,
ConnectionFailed,
ForeignKeyConstraintViolation,
NullConstraintViolation,
QueryRejected,
RateLimited,
UniqueConstraintViolation,
} from '@monospace/extension-kit/data-connector';
// Pinned next to the field paths it governs; see collections.ts.
import { STRIPE_API_VERSION } from './collections';
const BASE_URL = 'https://api.stripe.com/v1/';
/** Engine sets no deadline on `query` or `testConnection`, so every request carries its own. */
const TIMEOUT_MS = 10_000;
/** The most objects Stripe returns per list request. */
const PAGE_SIZE = 100;
/** Stripe ids are letters, digits, `_` and `-`. Anything else can't name an object. */
const STRIPE_ID = /^[\w-]+$/u;
// …
/** Sends one request and reads the whole body within the deadline. Throws only when no answer arrives. */
async #send(method: Method, path: string, form?: URLSearchParams): Promise<StripeResponse> {
const headers: Record<string, string> = { 'Authorization': `Bearer ${this.#apiKey}`, 'Stripe-Version': STRIPE_API_VERSION };
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) };
if (form !== undefined) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
init.body = form.toString();
}
let response: Response;
let text: string;
try {
response = await fetch(new URL(path, BASE_URL), init);
text = await response.text();
}
catch (error) {
const isTimeout = error instanceof Error && error.name === 'TimeoutError';
const reason = isTimeout ? `did not answer within ${TIMEOUT_MS / 1000} seconds` : 'could not be reached';
// Keep the rejection as the `cause`: when the data source's permissions refused the request,
// Engine finds that there and reports a permission error instead of a failed connection.
throw new ConnectionFailed(`The Stripe API ${reason}.`, { cause: error });
}
return { status: response.status, ok: response.ok, body: parseJson(text), headers: response.headers };
}
}
/** The body of a successful response; any other response becomes an error. */
function checked(response: StripeResponse, method: Method, path: string): unknown {
if (!response.ok) throw toConnectorError(response, method, path);
// A changed API, not something the caller can fix: a plain error, which Engine reports as a failed query.
if (response.body === undefined) throw new Error('Stripe returned a response that is not JSON.');
return response.body;
}
- A permission denial survives the wrap. A
fetchto a host outside the data source's granted permissions rejects with an error namedNotCapable. Engine looks for it anywhere in the thrown error'scausechain, so wrapped with{ cause: error }it's still reported as invalid permissions (2003), with your message nested inside. Wrapped withoutcause, it reads as the wrapping class. See Handle a Denied Request. - The deadline covers the body.
AbortSignal.timeoutalso aborts a body read, so the read sits inside thetry. A timeout rejects with an error namedTimeoutError. - A body that isn't JSON becomes a plain
Error, with a message the caller can read, not aSyntaxErrorfrom deep inside the parser. No class fits a changed API, and Engine reports the plainErroras a failed query.
Handle Data Connector Errors walks through how the Stripe client maps each error status to a class.
Every class takes (message: string, options?: { cause?: unknown }). The message is what Engine reports, so write it for the person reading the error, not for yourself. cause stays on the JavaScript error, where Engine looks only for a permission denial. Engine receives only the code and the message (see Know What the Caller Sees).
Engine recognizes an error by its monospace property, { code }, not by its class. Two bundled copies of the kit, or a connector without the kit, work the same way: a thrown object whose monospace.code is a safe integer is read identically. Use the kit's codes. Engine reports a code it doesn't recognize as a failed query.
Pick an Error Class
| Class | Code | Throw when |
|---|---|---|
QueryRejected | 100 | A valid query or write can't be served as asked (e.g., without a filter the external data source requires, beyond a page window or a read budget, or with a value outside a field's rule). The caller can change the request, so write the message for them |
UniqueConstraintViolation | 1002 | The external data source rejects a write because a value must be unique |
NullConstraintViolation | 1003 | The external data source rejects a write because a required value is missing |
ForeignKeyConstraintViolation | 1004 | The external data source rejects a write because a referenced item doesn't exist, or another item still references it |
InvalidConfiguration | 2001 | The data source's configuration is missing a value or has a wrong one, found before or without asking the external data source (e.g., a malformed URL or an unknown region) |
AccessDenied | 2002 | The external data source refuses this account: its credentials are rejected, expired or revoked, lack a scope, can't reach the resource, or exceed the plan |
CollectionNotFound | 2004 | A collection your connector declared no longer exists in the external data source (e.g., an object type deleted after the data source was introspected). Engine sends only declared collections |
ConnectionFailed | 5001 | The external data source can't be reached, or won't answer for now: a network failure, a timeout, maintenance, or a server error it reports as temporary |
RateLimited | 5002 | The external data source is throttling the connector: a rate limit, or a quota that resets. For a limit only a plan change lifts, throw AccessDenied |
No class reports a missing item. A keyed operation (readOne, updateOne, deleteOne) whose key or guard misses answers { record: null }, which callers see as 404. A read that matches nothing answers no items. See Keyed Operations.
Each code's range says who can fix the failure: 1 to 999 is invalid input and 1000 to 1999 a failure the caller can fix. 2000 to 2999 needs someone with access to the data source or to the external data source's account, and 5000 and up is infrastructure, where a later retry may work.
All nine classes extend the abstract ConnectorError. The module also exports the ErrorCode map, and the ErrorCodeName, ShellErrorCodeName, ThrownErrorCode, ThrownErrorCodeName, ConnectorErrorOptions and ConnectorErrorBrand types.
For anything no class describes, throw a plain Error. Engine reports it as a failed query. The example connectors do this in two cases: a changed API (a successful answer that isn't JSON, has the wrong shape, or holds a value of the wrong type), and a request their declaration never offered. See Tell QueryRejected from a Plain Error.
Read Codes from ErrorCode
ErrorCode maps each name to its code, for code that recognizes an error without instanceof, such as a smoke test that drives the built file. Compare with its entries, never with hard-coded numbers. The kit generates it from Engine's own list of codes:
export const ErrorCode = {
QueryRejected: 100,
UniqueConstraintViolation: 1002,
NullConstraintViolation: 1003,
ForeignKeyConstraintViolation: 1004,
InvalidConfiguration: 2001,
AccessDenied: 2002,
InvalidPermissions: 2003,
CollectionNotFound: 2004,
ConnectionFailed: 5001,
RateLimited: 5002,
MethodNotFound: -32_601,
} as const;
Only Engine's side of the protocol raises InvalidPermissions (a permission denial) and MethodNotFound (a missing handler). The type ShellErrorCodeName names them. The kit has no class for either, and a class's monospace.code is typed to the nine codes a connector throws. Engine reports a code it doesn't recognize as a failed query: 500 "Failed to query the database".
Tell QueryRejected from a Plain Error
Both refuse a request before or instead of answering it. Pick by whose request it is:
QueryRejected: the request fits the declaration, but the external data source can't serve it as asked. That includes shapes Engine composes on its own, whatever the collection declares:notaround an update's field rules, andoracross permission rules. See Handle Requests You Didn't Declare. The caller gets422.- A plain
Error: the request names something the collection never declared. That's an unknown collection or namespace, an operation, a filter field or operator, a sort, a count, a key other than the declared one, or a write. Nothing the caller changes fixes it, so don't useQueryRejected, which tells the caller to change the request. The caller gets500"Failed to query the database".
Engine checks callers' requests against the data source's stored schema, but not the filters it sends to your connector. So a comparison outside your declaration can come from a permission rule's condition or an extension update that narrowed the declaration without a schema refresh. Refuse it rather than drop it: a dropped condition can widen what the caller may touch.
Some undeclared comparisons are expected: Engine's relation and read-back lookups on a key no declaration can carry. Serve them. See Handle Requests You Didn't Declare.
Use InvalidConfiguration and AccessDenied for Configuration
Where you throw them decides what happens:
- From
setup. Engine stops the data source until the extension is reloaded, because a retry can't fix the configuration. The request that started the connector and every later one that reaches it answer422"Invalid extension configuration", with "the extension is deactivated" and your message nested inside; results the cache already holds are still served. - From
preflight. It also stops the data source until the extension is reloaded. Throw it there when the configurationpreflightreads to derive hosts is invalid. - From
queryortestConnection(e.g.,AccessDeniedfor a401from the external data source). Engine fails that request only, with422"Invalid extension configuration". The data source keeps running.
Throw InvalidConfiguration for a value you can tell is wrong without a request, and AccessDenied for credentials the external data source refuses. For a request your connector can't serve, throw QueryRejected: either configuration class tells the caller to check the configuration, which isn't the problem.
Know What Deactivates a Data Source
A failure in setup or preflight stops the data source until the extension is reloaded only when a retry can't fix it:
Thrown from setup or preflight | Effect |
|---|---|
InvalidConfiguration or AccessDenied | Deactivates the data source |
A permission denial, raw or anywhere in the cause chain | Deactivates the data source. From a handler, the same denial fails only that request, with 422 |
setup returning no handler object, or handlers without introspect or query | Deactivates the data source |
Any other class, or a plain Error | Fails that request with 422 "Invalid extension configuration". The next request tries again |
Nothing thrown from introspect, query or testConnection deactivates a data source. A deactivated data source answers every request that reaches its connector, including the one whose start deactivated it, with the error that deactivated it, and "the extension is deactivated" nested inside:
| Reason | Caller's HTTP status and message |
|---|---|
setup or preflight threw InvalidConfiguration or AccessDenied, or setup returned no usable handlers | 422 "Invalid extension configuration" |
A permission denial in setup or preflight | 422 "Invalid extension permissions" |
| The grant doesn't match the permissions the connector requires | 422 "Invalid extension permissions", with code 7001 and the required set in meta.permissions. See Change Permissions |
The connector file fails to load, preflight answers something the protocol doesn't define, the entry's extension is no longer installed, or its query capabilities changed | 503 "Data source is deactivated". See When the Capabilities Change |
Know the Defaults for Unbranded Throws
A thrown value without a monospace property gets a code from what it carries:
| Thrown value | Reported as |
|---|---|
A value with a permission denial anywhere in its cause chain (e.g., a fetch to a host outside the data source's granted permissions), from setup, preflight or a handler | Invalid permissions (2003). This wins over any monospace property. See Network Permissions |
Anything else, from a handler (introspect, query, testConnection) | Internal error (-32603), a failed query |
Anything else, from setup or preflight | 422 "Invalid extension configuration". It doesn't deactivate the data source |
A monospace property that is set but has no safe-integer code is reported as an internal error (-32603), wherever it was thrown. A falsy monospace property (e.g., null) counts as none.
Throw a kit class instead of letting a raw error escape, so Engine can tell a rate limit from a rejected credential or an outage.
Keep Secrets out of Messages
Only the code and the message cross to Engine, and the message reaches the caller in the HTTP error response. Nothing else about the error does: not its cause, its stack, or any other property.
- Never interpolate a credential (a key, token, or request header) into a message. When the external data source's own message echoes a credential, replace it. The Stripe client throws
AccessDeniedwith a fixed message, "Stripe rejected the API key (HTTP 401).", because Stripe's message echoes the key's last characters. - Leave out URLs that identify an account, such as Stripe's
request_log_url, and never copy a whole response body into a message. - Leave out the query string of a request you describe. It can hold the caller's filter values. The Stripe client names the path only: "Stripe answered HTTP 500 to GET /v1/customers."
- Leave out personal data from items, such as a customer's email.
See Keep Secrets out of Messages for the Stripe example.
Know What the Caller Sees
A caller is whoever sent the request to the Monospace API, from an SDK, REST, or Studio. Engine translates each code into its own error before answering. On the REST API, a failed query answers with "message": "Query failed". Engine's message for the code is nested inside it, and your message under "the extension returned an error:":
| Class | Engine's message | Caller's HTTP status |
|---|---|---|
CollectionNotFound | Table does not exist | 404 |
UniqueConstraintViolation | Unique constraint failed | 422 |
NullConstraintViolation | Null constraint failed | 422 |
ForeignKeyConstraintViolation | Foreign key constraint failed | 422 |
QueryRejected | The data source rejected the query | 422 |
A permission denial (2003) | Invalid extension permissions | 422 |
ConnectionFailed | Failed to connect to the database | 500 |
RateLimited | Failed to connect to the database | 500, not 429, and without a Retry-After header |
InvalidConfiguration | Invalid extension configuration | 422 |
AccessDenied | Invalid extension configuration | 422 |
A missing handler (-32601) | Unsupported operation: a protocol method the extension does not implement | 500 |
A plain Error, or a code Engine doesn't recognize | Failed to query the database | 500 |
The Art Institute connector's refusal of a page past its 1,000-result window answers this way:
{
"message": "Query failed",
"source": {
"message": "The data source rejected the query",
"source": {
"message": "the extension returned an error: The Art Institute search API returns only the first 1000 results. Narrow the filter or read an earlier page."
}
}
}
The table covers errors your handlers throw. A request that arrives while another request starts or replaces the connector answers 503 "Data source is restarting".
A connection test (POST /api/{workspace}/sources/data/test) wraps the messages of a failing testConnection in "Failed to connect to data source with the provided configuration" instead of "Query failed". A failure while the connector starts, such as an error thrown from setup, reads "The extension could not be started" instead.
These behaviors don't depend on the status mapping:
- A keyed miss answers
404"No record found". WhenreadOne,updateOneordeleteOneanswers{ record: null }, the REST API answers404. The body is{"message":"Query failed","source":{"message":"No record found"}}. It's not an error your connector throws, so no message of yours is involved. - An undeclared comparison reaches your connector. Engine doesn't check the filters it sends against the called member's declaration, so what your connector throws for one decides what the caller sees. See Tell QueryRejected from a Plain Error.
- A deactivated data source refuses every request that reaches its connector until the extension is reloaded, with the status of the error that deactivated it. A result the cache already holds is still served. This includes the request whose
setupthrewInvalidConfigurationorAccessDenied: the caller sees422, with your message nested inside. See Know What Deactivates a Data Source. - A build that changes the query capabilities deactivates existing data sources when automatic reloading loads it, with
503until the instance restarts or the workspace is rebuilt. See When the Capabilities Change. - Testing a configuration starts a fresh connector. A connection test or schema preview doesn't reuse a data source's connector, so a failed attempt doesn't block the next one, and neither needs a reload.
testConnectionanswering{ ok: false }is reported as a failed connection.- A connector without
testConnectionstill creates a data source, and a connection test answers422code7002, "The connector does not offer a connection test". Engine runstestConnection, when you implement it, before it creates a data source or previews its schema. - Keep messages readable. Engine's own message is generic, and yours carries the specifics. Name the limit and what to change (e.g., "The Art Institute search API returns only the first 1000 results. Narrow the filter or read an earlier page.").
Recognize Errors Engine Raises
Engine fails a request with one of these messages when your connector breaks the protocol. For an answer that doesn't fit the protocol or the declared schema (the first four rows), the caller sees 500 "Invalid extension response", with the message nested inside. A value that doesn't fit an int32 field answers:
{
"message": "Query failed",
"source": {
"message": "Invalid extension response",
"source": {
"message": "The extension returned a record that does not match its declared schema",
"source": {
"message": "Expected a value matching `Int32`, got a string"
}
}
}
}
The two messages about missing handlers carry code -32601:
| Message | Cause |
|---|---|
Missing selected field <field>. A record must carry every selected field, using an explicit null where it has no value | An item in records, or a keyed answer's record, lacks a field listed in select |
Expected a value matching <Type>, got a <kind> | A value doesn't fit its field's declared type |
| Expected an array for a list-typed field | A list field's value isn't an array or null |
<handler> answered with something this protocol does not define: <detail> | An answer has a key or a shape the protocol doesn't define. A keyed operation that answers records reads "unknown field records, expected record" |
<handler> returned no value | A handler returned undefined or null. A keyed miss answers { record: null }, not null |
| preflight must answer synchronously | preflight returned a promise |
| setup did not return a handler object | setup returned something other than an object. It fails at startup and stops the data source until the extension reloads, as a missing introspect or query handler does |
the connector returned no <handlers> handler | setup returned handlers without introspect or query. The message lists each missing one (e.g., "the connector returned no introspect, query handler") |
The answer checks run after your handler returns, so a write your connector already made stays applied when its answer fails them.
See Also
- Handle Data Connector Errors: map failures of the external data source to error classes, step by step
- Data Connector API: handlers, the query wire format, and the answers Engine expects
- Network Permissions: the permission errors a data source can hit
- Errors: the error response shape callers receive from the Monospace API
Operations and Operators
Reference for the operations a data connector collection declares, the filter operators each field type allows, and the rules Engine enforces on a declaration.
Runtime Limits
Hard numbers and fixed rules for data connector extensions at runtime, and the error or behavior each one produces.