Provider errors and quirks
Building a connector, step 7 (HTTP track, 5 of 5). Previous: Write handlers.
ctx.http throws on every non-2xx response, already classified, so most handlers need no error
handling at all. What your connector adds is the vendor's own meaning: which error code means
"already a member", which 200 response is really a failure, which 403 is really a rate limit. That
knowledge goes in three optional files under lib/, which Owlie runs inside ctx.http before your
handler's own catch sees the error.
How errors are classified
| Status | Error code | details.appCode | Retried |
|---|---|---|---|
| 401, 403 | AUTH_ERROR | no | |
| 404 | APP_ERROR | NOT_FOUND | no |
| 409 | APP_ERROR | no | |
| 412 | APP_ERROR | PRECONDITION_FAILED | no |
| 400, 422 | APP_ERROR | INVALID_VALUE | no |
| 429 | NETWORK_ERROR | QUOTA_EXCEEDED | yes, after Retry-After |
| 5xx, network failure | NETWORK_ERROR | yes |
A 409 has no classification on purpose: vendors use it both for "already exists" and for conflicts
such as a lock. The vendor's message is kept in the error's message and details.providerMessage,
and its error code in details.providerCode. After a 401 or 403, Owlie retries once, refreshing the
token first when the authentication option can, before failing.
Test errors with the predicates from @owlie/connectors/sdk/v2:
isHttpStatus(error, 404): the response had exactly that status. It reads the HTTP error itself, so it is false oncemapErrorhas replaced that error with your own.isNotFound(error): a 404, or a connector error codedNOT_FOUND.isAlreadyExists(error): codedALREADY_EXISTSorALREADY_MEMBER. A bare 409 is never enough.applicationCodeOf(error): the error's application code, wherever it is carried.
Catch narrowly. fetchOne turns a 404 into { record: null }, delete treats a 404 as done, Grant
handlers follow Grant writes, and everything else propagates.
connection.testConnection is the one handler that catches everything: it rethrows any failure as a
CONFIGURATION_ERROR with a customerMessage the admin can act on.
Errors you raise yourself
Choose by whose fault it is:
| Situation | Raise |
|---|---|
| The customer's input or configuration is wrong | ctx.errors.invalidInput("message") |
| The connection test failed | new ctx.errors.ConnectorSdkError(error, { code: "CONFIGURATION_ERROR", message, customerMessage, handledAt: "connector.invocation-context" }) |
| The vendor answered 200 but the lookup found nothing | ctx.errors.notFound("User", "message") |
| The vendor's response has the wrong shape (a missing list, a malformed token) | new ctx.errors.ConnectorSdkError({ code: "APP_ERROR", message, customerMessage, handledAt: "connector.invocation-context" }) |
| A vendor outcome you want classified, such as "already a member" | new ctx.errors.ConnectorApplicationError({ code: "ALREADY_MEMBER", resource: "MEMBERSHIP", message }) |
Use invalidInput for a request your handler refuses before sending it, such as a missing required
value or a malformed identifier. Never throw a plain Error from a handler: Owlie reports it as an
INTERNAL_ERROR and drops its message.
lib/map-error.ts: what the vendor's errors mean
Export mapError. Every error ctx.http throws passes through it before your handler sees it; it
receives the error and ctx. Return a replacement error, or nothing to keep the original:
import type { MapError } from "../types.gen.ts";
import { ConnectorApplicationError, isHttpResponseError } from "@owlie/connectors/sdk/v2";
export const mapError: MapError = (error) => {
if (isHttpResponseError(error) && error.details?.providerCode === "already_member") {
return new ConnectorApplicationError({
code: "ALREADY_MEMBER",
resource: "MEMBERSHIP",
message: "Already a member",
});
}
};
This is what lets an add return alreadyPresent() honestly: isAlreadyExists becomes true only for
the answer the vendor documents. Map a documented "not a member" answer to NOT_MEMBER the same way,
and have the remove check applicationCodeOf(error) === "NOT_MEMBER"
(Write handlers). A cancelled request never reaches mapError, a
mapper that throws or returns something other than an error keeps the original, and an error your own
code throws never passes through it.
lib/check-response.ts: failures inside a 200
Some APIs report failure inside a successful response: GraphQL's errors array, or a body like
{ "ok": false, "error": "channel_not_found" }. Export checkResponse; it receives every 2xx response
and ctx, including every page ctx.paginate reads, and throws to fail it. A plain Error thrown
here becomes an APP_ERROR that keeps its message; throw a ConnectorApplicationError to give it a
code:
import type { CheckResponse } from "../types.gen.ts";
import { ConnectorApplicationError } from "@owlie/connectors/sdk/v2";
export const checkResponse: CheckResponse = (response) => {
const body = response.data as { ok?: boolean; error?: string };
if (body?.ok === false) {
throw new ConnectorApplicationError({
code: "PRECONDITION_FAILED",
resource: "REQUEST",
message: String(body.error),
});
}
};
lib/classify-response.ts: rate limits reported as 403
Some vendors report a rate limit with a status that means something else. Google answers a quota
breach with a 403 whose body names rateLimitExceeded; GitHub's secondary limits are 403s. By status
alone, Owlie would refresh the token, retry once and fail permanently. Export classifyResponse: it
sees the status, headers and parsed body of every non-2xx response and returns
{ rateLimited: true } (optionally with retryAfterMs) or nothing:
import type { ClassifyResponse } from "../types.gen.ts";
const RATE_LIMIT_REASONS = new Set(["rateLimitExceeded", "userRateLimitExceeded"]);
export const classifyResponse: ClassifyResponse = ({ status, body }) => {
const reason = (body as { error?: { errors?: { reason?: string }[] } })?.error?.errors?.[0]?.reason;
if (status === 403 && reason !== undefined && RATE_LIMIT_REASONS.has(reason)) return { rateLimited: true };
};
A flagged response is treated like a 429: no token refresh, and the operation is retried later.
Declaring the vendor's rate limits
To make Owlie pace requests before the vendor pushes back, declare http_rate_limits in the manifest:
named buckets (a fixed budget, or limits the vendor reports in headers) and rules that map request
methods and paths to buckets. Use only limits the vendor documents; never invent a quota. The fields
are in Connector manifest. Without a declaration, Owlie still
backs off when the vendor answers 429.
Other quirks
- Encode path segments. Wrap every value you put in a URL path in
encodeURIComponent. - Keep lookups honest. A helper that finds a user by email must fail when it finds nobody, never return a guess.
Next: Test your connector