Error Catalog

There is no single error-code convention in this domain

Unlike other Fenicia APIs with a consistent namespace (domain:snake_case), the Products domain mixes at least four different conventions for code, depending on which layer of the code generated the error: typed library errors (plain kebab-case, no namespace), inline per-endpoint codes (namespace/kebab-case), SCREAMING_SNAKE_CASE codes in the channel-publishing flow, and a third UPPERCASE style in the categories lambda. This page groups them by their real origin — it does not pretend a unified catalog exists.

Treat the HTTP status as the primary, reliable signal in this domain; the exact code can vary in shape across different corners of the surface. When you depend on a specific code, verify it against the specific endpoint — this page documents the ones confirmed by the source code, grouped by where they come from.

1. Typed library errors (@fenicia/products-service)

These are the errors produced by the underlying library's validation/repository layer, consumed by lambda-products in the creation, update, deletion, variants, media, materials, and BOM routes.

ClasscodeStatusNotes
ValidationErrorvalidation-error400Base class. Carries field?, details?, fieldErrors?: FieldError[].
DuplicateSkuError extends ValidationErrorduplicate-sku409Typical message: "Product with SKU '<sku>' already exists".
NotFoundError extends ValidationErrornot-found404See warning below.
InvalidStatusError extends ValidationErrorinvalid-status400See warning below.

NotFoundError and InvalidStatusError: declared, no confirmed internal usage

Both classes are declared and exported by the library, but no internal throw of them was found within the audited library source code. If they appear in a real response, the origin would be the consuming lambda (not the library) — don't treat them as a guaranteed response from any specific endpoint until confirmed against real behavior.

DUPLICATE_SKU can be misleading

The MongoDB error translator (translateMongoDuplicateKeyError) labels any unique-index collision as DUPLICATE_SKU, not just the sku one. If your document has another unique index (for example at the variant level) and it collides, the code you receive may still say duplicate-sku even though the real field in conflict is different. Always check field/details on the error, not just the code.

ValidationError.fieldErrors carries an array of per-field errors when validation fails in more than one place at once (for example, a PUT with several invalid fields). The exact shape of each FieldError entry was not verified field by field in this audit — assume at least a field field, but confirm it against a real response before parsing it strictly.

2. AI errors (AIServiceError)

Used by Bulk operations in POST /products/bulk/ai/transform.

code (AI_ERROR_CODES)Notes
RATE_LIMITEDThe AI provider's rate limit was reached.
BUDGET_EXCEEDEDThe tenant's daily AI token budget is exhausted — responds 429. Check the remaining budget with GET /products/bulk/ai/budget.
INVALID_PROMPTThe sent prompt is not valid.
MODEL_ERRORFailure on the model provider's side.
PARSE_ERRORThe model's response could not be parsed.
TIMEOUTThe AI operation exceeded the time limit.
INVALID_RESPONSEThe model's response doesn't have the expected shape.

Every AIServiceError carries a retryable: boolean flag — respect it before retrying automatically.

3. Catalog of declared codes (not all have their own class)

The library also declares these constants, though not all of them have a dedicated error class like the ones in section 1:

ERROR_CODES:  VALIDATION_ERROR, NOT_FOUND, UNAUTHORIZED, FORBIDDEN, INTERNAL_ERROR,
              DUPLICATE_SKU, INVALID_STATUS, INVALID_BINDING, QUOTA_EXCEEDED, SYNC_FAILED
 
HTTP_STATUS:  OK, CREATED, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND,
              CONFLICT, UNPROCESSABLE_ENTITY, INTERNAL_ERROR

4. Inline per-endpoint codes (namespace bad-request/…, forbidden/…, etc.)

Several lambda-products routes build their own code as <category>/<detail> directly in the handler, without going through the library. Confirmed by area:

Queries and inventory

codeStatusEndpoint
bad-request/missing-param400GET /products/inventory-view
bad-request/invalid-param400GET /products/inventory-view
internal-error/missing-count500POST /products/query
bad-request/ambiguous-param400POST /products/query (you sent filters and query at the same time, when they're mutually exclusive)
bad-request/missing-param400GET /products/inventory — ⚠️ this endpoint responds {"error": "Missing required query parameter: locationId"} (a plain string, no code object), inconsistent with the rest of the table.

Media

codeStatusEndpoint
internal-error/media-upload500POST /products/media/upload

Async CSV (import/export)

codeStatusNotes
bad-request/missing-s3-key400Missing s3Key in the body.
forbidden/invalid-s3-key403The s3Key does not start with the tenant prefix (uploads/{tenantId}/) — anti-IDOR protection.
not-found/s3-object404The referenced object doesn't exist in the bucket.
bad-request/file-too-large400The file exceeds the maximum allowed size.
bad-request/invalid-config400The submitted import/export configuration is invalid.
bad-request/invalid-import-file400The file doesn't have a valid structure to be imported.
rate-limited429Upload/operation rate limit reached.

See Bulk CSV for the full flow of each endpoint.

Bulk operations

codeStatusEndpoint
rollback-failed400POST /products/bulk/operations/{id}/rollback
insufficient-stock400POST /products/{sku}/bom/consume
consumption-failed (+ errors[])400POST /products/materials/consumption/consume

5. Channel publishing — SCREAMING_SNAKE_CASE convention

POST /products/export and related routes from Channel publishing use a third convention, literals with no colon or slash namespace:

codeStatusWhen it occurs
EXPORT_IN_PROGRESS409An export is already in progress for that channel/selection.
CHANNEL_NOT_FOUND404The given channelId doesn't exist in the tenant.
CHANNEL_INACTIVE400The channel exists but is inactive.
NO_PRODUCTS400The selection/filter resolved no product to export.
PRODUCT_EXPORT_FAILED400Exporting at least one product failed (check the per-item detail in the response).

6. Marketplace categorization — kebab-case without a namespace

POST /products/marketplace-categories validates exhaustively and returns one of these codes (always 400):

invalid-skus · too-many-skus · invalid-sku-values · invalid-channel-id
invalid-category · invalid-category-path · category-id-too-long
category-name-too-long · category-path-too-deep

7. Categories lambda (/categories/...) — its own convention, different from all the above

This is a separate lambda (lambda-categories) and uses its own convention, so far only seen in practice as a single uppercase literal with no namespace:

codeStatusWhen it occurs
MISSING_PARAMETER400A required query param is missing (q in searches, title in classification, etc.).

No broader code catalog was confirmed on this lambda

The rest of the /categories/... routes that return 404/500 mostly do so with generic handler messages, with no structured code confirmed beyond MISSING_PARAMETER. Endpoint detail in Categories.

8. Authentication and authorization errors (generic, no confirmed domain code)

Every route in this domain returns 401 when there's no resolved tenant and 403 when the API key lacks the required RBAC permission. Unlike the catalog above, this audit did not capture a specific and consistent code string for these two cases in Products (the rest of the domain does not necessarily share the auth:* namespace used by other domains of this API) — treat them by HTTP status, not by code, until one is confirmed.

How to handle this in your client

Since there is no single namespace, the safest strategy is: branch on HTTP status first, and use code only within the specific endpoint you're calling (where you already know which convention applies).

const response = await fetch("https://api.fenicia.io/products/export", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.FENICIA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ channelId: "canal-shopify-mx" }),
});
 
const body = await response.json();
 
if (!response.ok) {
  // On this specific endpoint, the code is SCREAMING_SNAKE_CASE (section 5).
  if (body.code === "EXPORT_IN_PROGRESS") {
    // an export is already running for this channel
  } else {
    console.error(response.status, body.code ?? body.error);
  }
}

Tip

If your integration only needs to retry safely, the HTTP status is sufficient in 100% of this domain's cases: 400/403/404/409 should not be retried without changing the request; 429 should be retried with backoff; 5xx should be retried with exponential backoff.

Next steps