Back to Arky

Error Handling

Handle API errors, conflicts and retries safely

SDK methods resolve to their declared response type and throw on failure; there is no result wrapper.

import { initialize } from "arky-sdk/storefront";
import { createOrderToken } from "arky-sdk/utils";

const arky = initialize(import.meta.env.PUBLIC_ARKY_PUBLISHABLE_KEY, {
	locale: "en",
	createVisitorIdentity: () => ({
		customer_id: crypto.randomUUID(),
		session_id: crypto.randomUUID(),
	}),
	createSessionId: () => crypto.randomUUID(),
	createId: () => crypto.randomUUID(),
	createOrderToken,
});

Request Errors

import { ArkyApiError } from "arky-sdk/storefront";

try {
	const product = await arky.eshop.product.get({ id: "26daa1d7-617e-497f-a37d-6b92768c3fd8", catalog_id: catalogId });
	console.log(product.key);
} catch (error) {
	if (error instanceof ArkyApiError) {
		console.error(error.statusCode, error.code, error.message, error.validationErrors, error.requestId);
	} else {
		throw error;
	}
}

A refused request throws an ArkyApiError with statusCode, code, message, validationErrors, method, url and an optional requestId. The class is exported from arky-sdk, arky-sdk/storefront and arky-sdk/admin, and instanceof works across all three; isArkyApiError(value) checks a value the same way. A 401 the SDK raises itself, such as a session it can’t refresh, carries GENERAL.UNAUTHENTICATED, and a checkout refused because the quote changed throws a CartPresentationChangedError, an ArkyApiError that carries the new quote. Network failures, aborted requests and unreadable answers throw an ArkyNetworkError (named NetworkError, AbortError or ParseError) whose code is network, aborted or unreadable_answer. Every error the SDK throws at runtime is an ArkyError with a code; map the code to your own text. Server errors (5xx) carry no internal detail; use the request id to find them in Arky’s logs.

Warning

Don’t show raw provider or internal errors to customers, and never log credentials, tokens, signing secrets or full customer payloads.

Conflicts

409 means the request can’t apply to the record as it is now. Its code says why:

CodeCauseWhat to do
GENERAL.CONFLICTa stale expected_updated_at, or another request under an id that is already usedsomeone changed the record, or the same action already applied: reload and decide again; a different action needs a new id
REFERENCE.STILL_REFERENCEDa delete refused because something points at the recordthe message names who points: remove the pointer first, or archive instead
REFERENCE.DELETINGa record that is being deletedit can’t change or be pointed at any more
STORE.DELETINGthe store is being closednothing in it can change
a code ending in _TAKEN, such as PRODUCT.KEY_TAKENanother record already holds a value that must be unique, such as a key or a slugchoose another value, or work with the record that holds it
a named rule, such as PRICE.OVERLAP or CART.OFFER_EXPIREDthe request breaks a business rulethe code and message say which; reloading doesn’t change the answer

Only GENERAL.CONFLICT means “changed since you read it”. A named code is a definite answer about the request itself, so show it to the person and let them change the request.

Retrying Safely

Every create carries an id, and every action carries the updated_at it read, so a retry is safe by construction:

  • Keep the id with the request until the answer confirms it. A retry with the same id and the same request returns the existing record: one order, one refund, one email.
  • Never make a fresh id in a retry loop. A new id is a new business action and can duplicate a charge, an email or a booking.
  • A repeated action finds the record changed and is refused with 409; read the record to see whether your first try applied.

Storefront Flows

The storefront flows that create something keep their own id: a form submission (forms.submit), a sign-in code request (customer.requestCode), a support conversation and its messages (support.conversation.start, sendMessage), a checkout without order_id and order_token, and a free group sign-up (customerGroup.joinFree, newsletter.join). Every id you leave out comes from your createId, and an order token from your createOrderToken. The SDK keeps the pending ids with a fingerprint of the request in localStorage, under a lock shared by the tabs:

  • the same request sent again reuses the id, so a lost answer never makes a second record;
  • a changed request is a new request and gets a new id;
  • a success or a definite refusal clears it. isDefiniteRefusal(error) (from arky-sdk/utils) is true for every 4xx except 408 and 429; a network failure or a 5xx keeps the id for the retry.

Only the fingerprint is kept, never the answers, an email or the chat text. A checkout and a free sign-up also refuse a different request while one is pending, because the pending one may already have placed an order: recover it first. Without localStorage and Web Locks, during SSR or in Node, these flows refuse to start with a DurableRequestError whose code is storage; pass your own id there.

import type { FormValues } from "arky-sdk";
import { isDefiniteRefusal } from "arky-sdk/utils";

const form = await arky.forms.getByKey({ key: "contact-form" });

async function send(values: FormValues): Promise<"sent" | "refused" | "unknown"> {
	try {
		await arky.forms.submit({ form, language: "en", values });
		return "sent";
	} catch (error) {
		return isDefiniteRefusal(error) ? "refused" : "unknown";
	}
}

On "unknown", keep the answers on screen and offer a retry: the same values sent again reuse the kept id, so the message arrives once. On "refused", show the error and let the person change the request.

Admin Creates

An Admin create has no kept id: keep the request yourself until Arky confirms it. The SDK’s durable request helpers keep it in localStorage across reloads and lock it across tabs. They fail closed when storage or locks are unavailable:

import { createAdmin } from "arky-sdk/admin";
import type { CreatePaymentRefundParams } from "arky-sdk";
import {
	clearDurableRequest,
	durableRequestPayload,
	getOrCreateDurableRequest,
	readDurableRequest,
	withDurableRequestLock,
} from "arky-sdk/utils";

const sdk = createAdmin({ apiUrl: "https://api.arky.io" });

const storageKey = `arky:payment-refund:${paymentId}`;

const payment = await withDurableRequestLock(storageKey, "refund", async () => {
	const saved = readDurableRequest(storageKey, "refund");
	const request: CreatePaymentRefundParams = saved
		? (durableRequestPayload(saved) as CreatePaymentRefundParams)
		: {
				store_id: storeId,
				payment_id: paymentId,
				id: crypto.randomUUID(),
				money: { amount: 1999, currency: "eur" },
				reason: "customer_request",
				application: {
					type: "commercial_credit",
					allocations: [{ order_credit_id: creditId, order_credit_allocation_id: allocationId, amount: 1999 }],
				},
				notify_customer: true,
			};
	const durable = saved ?? getOrCreateDurableRequest(storageKey, request, "refund");
	const result = await sdk.eshop.payment.createRefund(request);
	clearDurableRequest(durable, "refund");
	return result;
});

Clear the saved request only after Arky confirmed it. A browser Admin client signs in with the account code flow; never give a browser a secret key.

Unknown Outcomes

When a provider’s answer is lost, Arky never sends again on its own: an email becomes maybe_sent, a payment or refund call unknown. Arky settles it from the provider’s own records or signed events. Don’t create a second payment, refund or email because one is unclear.

Read-Only Retries

Plain retries suit reads only, such as get, find and status reads. Use bounded attempts and a growing delay:

import { isArkyApiError } from "arky-sdk/storefront";

async function getWithRetry<T>(request: () => Promise<T>, attempts = 3): Promise<T> {
	let lastError: unknown;
	for (let attempt = 0; attempt < attempts; attempt += 1) {
		try {
			return await request();
		} catch (error) {
			lastError = error;
			if (isArkyApiError(error) && error.statusCode < 500) throw error;
			if (attempt + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
		}
	}
	throw lastError;
}
Warning

Never wrap a create or an action in this helper with a new id per try. A timeout doesn’t prove that nothing happened.

Application Handling

Map errors at the edge of your app and show a stable message:

import { ArkyApiError } from "arky-sdk/storefront";

async function loadProduct(id: string) {
	try {
		return await arky.eshop.product.get({ id, catalog_id: catalogId });
	} catch (error) {
		if (error instanceof ArkyApiError && error.statusCode === 404) return null;
		throw error;
	}
}