Skip to main content

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​

StatusError codedetails.appCodeRetried
401, 403AUTH_ERRORno
404APP_ERRORNOT_FOUNDno
409APP_ERRORno
412APP_ERRORPRECONDITION_FAILEDno
400, 422APP_ERRORINVALID_VALUEno
429NETWORK_ERRORQUOTA_EXCEEDEDyes, after Retry-After
5xx, network failureNETWORK_ERRORyes

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 once mapError has replaced that error with your own.
  • isNotFound(error): a 404, or a connector error coded NOT_FOUND.
  • isAlreadyExists(error): coded ALREADY_EXISTS or ALREADY_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:

SituationRaise
The customer's input or configuration is wrongctx.errors.invalidInput("message")
The connection test failednew ctx.errors.ConnectorSdkError(error, { code: "CONFIGURATION_ERROR", message, customerMessage, handledAt: "connector.invocation-context" })
The vendor answered 200 but the lookup found nothingctx.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