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.
| Class | code | Status | Notes |
|---|---|---|---|
ValidationError | validation-error | 400 | Base class. Carries field?, details?, fieldErrors?: FieldError[]. |
DuplicateSkuError extends ValidationError | duplicate-sku | 409 | Typical message: "Product with SKU '<sku>' already exists". |
NotFoundError extends ValidationError | not-found | 404 | See warning below. |
InvalidStatusError extends ValidationError | invalid-status | 400 | See 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_LIMITED | The AI provider's rate limit was reached. |
BUDGET_EXCEEDED | The tenant's daily AI token budget is exhausted — responds 429. Check the remaining budget with GET /products/bulk/ai/budget. |
INVALID_PROMPT | The sent prompt is not valid. |
MODEL_ERROR | Failure on the model provider's side. |
PARSE_ERROR | The model's response could not be parsed. |
TIMEOUT | The AI operation exceeded the time limit. |
INVALID_RESPONSE | The 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_ERROR4. 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
code | Status | Endpoint |
|---|---|---|
bad-request/missing-param | 400 | GET /products/inventory-view |
bad-request/invalid-param | 400 | GET /products/inventory-view |
internal-error/missing-count | 500 | POST /products/query |
bad-request/ambiguous-param | 400 | POST /products/query (you sent filters and query at the same time, when they're mutually exclusive) |
bad-request/missing-param | 400 | GET /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
code | Status | Endpoint |
|---|---|---|
internal-error/media-upload | 500 | POST /products/media/upload |
Async CSV (import/export)
code | Status | Notes |
|---|---|---|
bad-request/missing-s3-key | 400 | Missing s3Key in the body. |
forbidden/invalid-s3-key | 403 | The s3Key does not start with the tenant prefix (uploads/{tenantId}/) — anti-IDOR protection. |
not-found/s3-object | 404 | The referenced object doesn't exist in the bucket. |
bad-request/file-too-large | 400 | The file exceeds the maximum allowed size. |
bad-request/invalid-config | 400 | The submitted import/export configuration is invalid. |
bad-request/invalid-import-file | 400 | The file doesn't have a valid structure to be imported. |
rate-limited | 429 | Upload/operation rate limit reached. |
See Bulk CSV for the full flow of each endpoint.
Bulk operations
code | Status | Endpoint |
|---|---|---|
rollback-failed | 400 | POST /products/bulk/operations/{id}/rollback |
insufficient-stock | 400 | POST /products/{sku}/bom/consume |
consumption-failed (+ errors[]) | 400 | POST /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:
code | Status | When it occurs |
|---|---|---|
EXPORT_IN_PROGRESS | 409 | An export is already in progress for that channel/selection. |
CHANNEL_NOT_FOUND | 404 | The given channelId doesn't exist in the tenant. |
CHANNEL_INACTIVE | 400 | The channel exists but is inactive. |
NO_PRODUCTS | 400 | The selection/filter resolved no product to export. |
PRODUCT_EXPORT_FAILED | 400 | Exporting 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-deep7. 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:
code | Status | When it occurs |
|---|---|---|
MISSING_PARAMETER | 400 | A 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.