Skip to content

Errors & i18n ​

A complete reference for how Orbital-Express handles client errors, 500s, and internationalized messages. Read this before adding a new error code or translation key.


Table of Contents ​

  1. Error Code Structure
  2. Returning Errors from Actions
  3. Errors in Tasks and Sockets
  4. The Error Middleware (middleware/error.js)
  5. Adding a New Error Code
  6. i18n — Key Naming Convention
  7. i18n in Actions
  8. i18n in Tasks
  9. yarn lang
  10. Testing Errors

1. Error Code Structure ​

All client error codes live in one of two places:

FilePurpose
services/error.jsGlobal codes shared across all features (BAD_REQUEST_INVALID_ARGUMENTS, UNAUTHORIZED, etc.)
app/<Feature>/error.jsFeature-specific codes scoped to one feature (ADMIN_BAD_REQUEST_INVALID_LOGIN_CREDENTIALS, etc.)

At startup, services/error.js automatically scans every feature folder under app/ and merges every error.js file it finds into the global ERROR_CODES object. You never need to import a feature error file directly — just use ERROR_CODES from services/error.js everywhere.

Shape of an error code object ​

javascript
ADMIN_BAD_REQUEST_INVALID_LOGIN_CREDENTIALS: {
  error: 'ADMIN.BAD_REQUEST_INVALID_LOGIN_CREDENTIALS',  // machine-readable, used by the frontend to branch
  status: 400,                                            // HTTP status code
  messages: ['ADMIN[invalid_login_credentials]']          // i18n key(s) — index 0 is the default
}
  • error — a dot-namespaced machine-readable string. The frontend (or mobile client) switches on this to show the right UI state. Never change it after shipping — it is part of your API contract.
  • status — the HTTP status code returned by the endpoint when this error fires.
  • messages — an array of i18n translation keys. Most error codes have exactly one. A second entry lets you return an alternate phrasing for the same semantic error without defining a new code.

Global codes (services/error.js) ​

javascript
const ERROR_CODES = {
  BAD_REQUEST_INVALID_ARGUMENTS: {
    error: 'BAD_REQUEST_INVALID_ARGUMENTS',
    status: 400,
    messages: ['GLOBAL[invalid_arguments]']
  },
  UNAUTHORIZED: {
    error: 'UNAUTHORIZED',
    status: 401,
    messages: ['GLOBAL[unauthorized]']
  },
  FORBIDDEN: {
    error: 'FORBIDDEN',
    status: 403,
    messages: ['GLOBAL[forbidden]']
  },
  INTERNAL_SERVER_ERROR: {
    error: 'INTERNAL_SERVER_ERROR',
    status: 500,
    messages: ['GLOBAL[internal_server_error]']
  }
};

Feature codes (app/Admin/error.js) ​

javascript
'use strict';

const LOCAL_ERROR_CODES = {
  // V1Login
  ADMIN_BAD_REQUEST_INVALID_LOGIN_CREDENTIALS: {
    error: 'ADMIN.BAD_REQUEST_INVALID_LOGIN_CREDENTIALS',
    status: 400,
    messages: ['ADMIN[invalid_login_credentials]']
  },

  ADMIN_BAD_REQUEST_ACCOUNT_INACTIVE: {
    error: 'ADMIN.BAD_REQUEST_ACCOUNT_INACTIVE',
    status: 400,
    messages: ['ADMIN[admin_account_inactive]']
  },

  // V1Read
  ADMIN_BAD_REQUEST_ACCOUNT_DOES_NOT_EXIST: {
    error: 'ADMIN.BAD_REQUEST_ACCOUNT_DOES_NOT_EXIST',
    status: 400,
    messages: ['ADMIN[admin_account_does_not_exist]']
  }
};

module.exports = LOCAL_ERROR_CODES;

Naming rule: Prefix every feature error code with the feature name in SCREAMING_SNAKE_CASE (ADMIN_, USER_, ORDER_). The .error string uses dot-namespace (ADMIN.BAD_REQUEST_...). This scoping prevents collisions as the codebase grows.


2. Returning Errors from Actions ​

HTTP actions always use errorResponse() to return a 4xx error. Never call res.status(400).json(...) by hand — that bypasses the standardized error shape.

Signature ​

javascript
const { ERROR_CODES, errorResponse, joiErrorsMessage } = require('../../../services/error');

// Basic usage — uses messages[0] by default
return errorResponse(req, ERROR_CODES.ADMIN_BAD_REQUEST_ACCOUNT_DOES_NOT_EXIST);

// Alternate phrasing — uses messages[1] (the second translation key)
return errorResponse(req, ERROR_CODES.SOME_ERROR_WITH_TWO_MESSAGES, 1);

// Custom string message — bypasses the messages array entirely (common with Joi)
return errorResponse(req, ERROR_CODES.BAD_REQUEST_INVALID_ARGUMENTS, joiErrorsMessage(error));

// Override the HTTP status code
return errorResponse(req, ERROR_CODES.SOME_ERROR, 0, 422);

Return shape ​

errorResponse() returns a plain object — the action returns it, the controller writes it to the response:

javascript
{
  success: false,
  status: 400,
  error: 'ADMIN.BAD_REQUEST_ACCOUNT_DOES_NOT_EXIST',
  message: 'Admin account does not exist.'
}

Full example inside an action ​

javascript
async function V1Read(req) {
  const schema = joi.object({
    id: joi.number().min(1).default(req.admin.id).optional()
  });

  const { error, value } = schema.validate(req.args);
  if (error)
    return errorResponse(req, ERROR_CODES.BAD_REQUEST_INVALID_ARGUMENTS, joiErrorsMessage(error));

  req.args = value;

  try {
    const findAdmin = await models.admin.findByPk(req.args.id, {
      attributes: { exclude: models.admin.getSensitiveData() }
    });

    if (!findAdmin)
      return errorResponse(req, ERROR_CODES.ADMIN_BAD_REQUEST_ACCOUNT_DOES_NOT_EXIST);

    return {
      status: 200,
      success: true,
      admin: findAdmin.dataValues
    };
  } catch (error) {
    throw error; // propagates to next(error) in the controller → middleware/error.js
  }
} // END V1Read

The controller wires next(error) for 500s ​

Actions throw; the controller catches and calls next(error):

javascript
async function V1Read(req, res, next) {
  let method = 'V1Read';

  try {
    const result = await actions[method](req, res);
    return res.status(result.status).json(result);
  } catch (error) {
    return next(error); // passes the thrown error to middleware/error.js
  }
} // END V1Read

Never return res.status(500).json(...) manually. If your action throws (or a Promise rejects inside the try block), catch calls next(error), which hands the error to middleware/error.js for standardized 500 handling. Trust the middleware.

Transactions — rollback before returning an error ​

When you open a Sequelize transaction and hit an error before committing, use errorResponseRollback() to roll back cleanly and return the standard error shape in one call:

javascript
const { errorResponseRollback } = require('../../../services/error');

const t = await models.db.transaction();
try {
  await models.order.create({ ... }, { transaction: t });

  if (someConditionFails)
    return await errorResponseRollback(t, req, ERROR_CODES.ORDER_BAD_REQUEST_SOMETHING_WRONG);

  await t.commit();
  return { status: 201, success: true, order: newOrder };
} catch (error) {
  await t.rollback();
  throw error;
}

3. Errors in Tasks and Sockets ​

Tasks and socket handlers are not request/response functions — they have no res object and no req with i18n attached. The rules differ.

Tasks — throw, never return ​

Inside a task, validation failures and logic errors are thrown. Bull's job runner catches unhandled throws and marks the job as failed. Do not return error objects.

javascript
async function V1ExportTask(job) {
  const schema = joi.object({
    adminId: joi.number().min(1).required()
  });

  const { error, value } = schema.validate(job.data);
  if (error)
    throw new Error(joiErrorsMessage(error)); // throw validation errors

  job.data = value;

  try {
    // ... do work ...
  } catch (error) {
    throw error; // rethrow so Bull marks the job as failed
  }
} // END V1ExportTask

Sockets — throw, never return ​

Socket action handlers follow the same pattern — throw on error; the socket wrapper handles it:

javascript
async function V1Connect(params, socket) {
  try {
    // ... logic ...
  } catch (error) {
    throw error;
  }
} // END V1Connect

i18n in tasks ​

Tasks that need translated strings cannot use req.__() (no request object). Instead, get a local i18n instance and manually set the user's locale from job.data.locale (which the enqueueing action is responsible for passing):

javascript
// services
const lang = require('../../../services/language');

async function V1SomeTask(job) {
  // ...
  const i18n = lang.getLocalI18n();
  i18n.setLocale(job.data.locale || 'en'); // locale passed by the enqueueing action

  const message = i18n.__('USER[some_key]');
  // ...
} // END V1SomeTask

When enqueuing, always pass locale:

javascript
// inside an action
await UserQueue.add('V1SomeTask', {
  userId: req.user.id,
  locale: req.getLocale() // pass current locale to the task
});

4. The Error Middleware (middleware/error.js) ​

middleware/error.js is the last middleware registered in the Express app. It is the single place that handles all unhandled errors (anything passed to next(error)).

What it does:

  1. Emits a structured JSON log line to stdout — parseable by Heroku Papertrail, Datadog Logs, and most log aggregators.
  2. Sends the error to Sentry (if configured) with requestId and user context attached.
  3. Returns a 500 JSON response. In production, the stack trace is omitted; in dev/test, it is included.

Structured log line ​

Every 500 writes one JSON line to stderr:

json
{
  "level": "error",
  "requestId": "019eed95-083e-7065-83c8-7ac1bc009fae",
  "method": "POST",
  "url": "/v1/users/create",
  "userType": "loggedOut",
  "userId": null,
  "errorName": "SequelizeUniqueConstraintError",
  "errorMessage": "email must be unique"
}

The requestId ties this log line to the X-Request-ID response header, so you can grep logs by the ID a client reports in a bug.

HTTP responses ​

javascript
// production response
{
  status: 500,
  success: false,
  error: 'Error',
  message: 'Something went wrong',
  requestId: 'abc-123'
}

// dev / test response — includes stack and request context for debugging
{
  status: 500,
  success: false,
  error: 'Error',
  stack: 'Error: ...\n    at ...',
  message: 'Something went wrong',
  requestId: 'abc-123',
  reqRoute: '/v1/admins/read',
  reqUserType: 'admin',
  reqUserId: 'uuid-here',
  reqArgs: { id: 5 }
}

Sentry integration ​

services/sentry.js is a stub (no-op) by default. To enable Sentry error tracking:

  1. Select Sentry during npx create-orbital-app setup, or manually:
    • yarn add @sentry/node --exact
    • Replace services/sentry.js with the real implementation (see the Sentry integration in create-orbital-app)
  2. Set SENTRY_DSN in your environment

When Sentry is configured, every unhandled error is sent to your Sentry project with the requestId as a searchable tag and the authenticated user attached. No other code changes are needed — middleware/error.js calls sentry.captureException(err, req) automatically.

Adding new user types: When you add a new authenticated user type, add an else if (req.<type>) branch to middleware/error.js so both the log line and Sentry event correctly identify who made the request.


5. Adding a New Error Code ​

Use the add-error-code skill (/add-error-code). It walks through all the steps below automatically. For reference, the manual steps are:

Step 1 — Add the code to the feature's error.js

javascript
// app/Order/error.js
ORDER_BAD_REQUEST_ITEM_OUT_OF_STOCK: {
  error: 'ORDER.BAD_REQUEST_ITEM_OUT_OF_STOCK',
  status: 400,
  messages: ['ORDER[item_out_of_stock]']
}

Step 2 — Add the translation key to the feature's language file

javascript
// app/Order/languages/en.js
'ORDER[item_out_of_stock]': 'That item is currently out of stock.'

If the string should be global (used across multiple features), add it to languages/en.js under a GLOBAL[...] key instead.

Step 3 — Run yarn lang

yarn lang

This compiles all language files into locales/en.json (and other locales if you have them) and validates that every key referenced in every messages array exists. The command will error loudly if a key is missing.

Step 4 — Return it from the action and document it in the JSDoc

javascript
/**
 * ...
 * Errors:
 *   400: BAD_REQUEST_INVALID_ARGUMENTS
 *   400: ORDER_BAD_REQUEST_ITEM_OUT_OF_STOCK   ← add here
 *   401: UNAUTHORIZED
 *   500: INTERNAL_SERVER_ERROR
 */
async function V1Create(req) {
  // ...
  if (item.stock === 0)
    return errorResponse(req, ERROR_CODES.ORDER_BAD_REQUEST_ITEM_OUT_OF_STOCK);
  // ...
} // END V1Create

Step 5 — Write the test

Every error code in the JSDoc must have a corresponding test. See Testing Errors below.


6. i18n — Key Naming Convention ​

Format: NAMESPACE[key_name]

PartRuleExample
NAMESPACEALL_CAPS. Feature name or GLOBAL for shared stringsADMIN, USER, ORDER, GLOBAL
key_nameall_lowercase_with_underscores. No camelCase, no dashesinvalid_login_credentials, item_out_of_stock
javascript
'GLOBAL[unauthorized]'              // global shared string
'ADMIN[invalid_login_credentials]'  // Admin feature string
'USER[profile_not_found]'           // User feature string
'ORDER[item_out_of_stock]'          // Order feature string

Where keys live ​

Key typeFile
Global keys (GLOBAL[...])languages/en.js
Feature keys (ADMIN[...], USER[...], etc.)app/<Feature>/languages/en.js

Never edit locales/en.json directly. That file is compiled output — it is regenerated every time you run yarn lang. Edit the source files (languages/en.js or app/<Feature>/languages/en.js) and run yarn lang to rebuild.

Global language file (languages/en.js) ​

javascript
module.exports = {
  'GLOBAL[language]': 'English',
  'GLOBAL[invalid_arguments]': 'One or more request arguments are invalid.',
  'GLOBAL[unauthorized]': 'You do not have permission to make this request.',
  'GLOBAL[forbidden]': 'You do not have the required role to perform this action.',
  'GLOBAL[internal_server_error]': 'Oops... something went wrong.',
  // ...
};

Feature language file (app/Admin/languages/en.js) ​

javascript
module.exports = {
  // V1Login
  'ADMIN[invalid_login_credentials]': 'The email and/or password you entered is incorrect.',
  'ADMIN[admin_account_inactive]': 'Admin account is inactive.',

  // V1Read
  'ADMIN[admin_account_does_not_exist]': 'Admin account does not exist.',

  // V1ResetPassword
  'ADMIN[reset_email_subject]': 'Your password has been changed. Please confirm.',
};

Use a comment above each key group to link it to the action that uses it — this makes it easy to find and audit keys as actions change.

Adding a key for a new locale ​

If the app supports multiple languages, add the same key to every app/<Feature>/languages/<locale>.js file (and the global languages/<locale>.js). yarn lang validates that all keys exist in all locales and will error if any locale is missing a key.


7. i18n in Actions ​

Actions receive req from Express. The i18n middleware attaches .__() directly to req and res — so inside an action you just call req.__('KEY'). You do not need to require('i18n') or call getLocalI18n() in actions.

javascript
// inside any action — req.__() is already available
const message = req.__('ADMIN[welcome]');

// res.__() works too, but req.__() is the convention

errorResponse(req, ...) calls req.__(key) internally — so when you pass req as the first argument, translation is handled for you automatically. You only need to call req.__() directly when you want to build a custom string outside of errorResponse.

How the locale is chosen ​

The i18n middleware reads the i18n-locale cookie (set at login) or falls back to the Accept-Language header. You can also force a locale for a request via ?lang=es. The active locale is determined before the action runs — all you do is call req.__().


8. i18n in Tasks ​

Background tasks run in the worker process, outside of any HTTP request. There is no req object — you must get a fresh i18n instance manually using getLocalI18n() from services/language.js and set the locale yourself.

Pattern:

javascript
'use strict';

// services
const lang = require('../../../services/language');
const { joiErrorsMessage } = require('../../../services/error');

module.exports = { V1WelcomeEmailTask };

/**
 * Send welcome email to new user
 *
 * @job = {
 *   @data = {
 *     @userId - (NUMBER - REQUIRED): The user id
 *     @locale - (STRING - REQUIRED): The user's locale (e.g. 'en')
 *   }
 * }
 */
async function V1WelcomeEmailTask(job) {
  const schema = joi.object({
    userId: joi.number().min(1).required(),
    locale: joi.string().required()
  });

  const { error, value } = schema.validate(job.data);
  if (error)
    throw new Error(joiErrorsMessage(error));

  job.data = value;

  // get a fresh i18n instance and set the user's locale
  const i18n = lang.getLocalI18n();
  i18n.setLocale(job.data.locale);

  try {
    const subject = i18n.__('USER[welcome_email_subject]');
    // ... send the email using the translated subject ...
  } catch (error) {
    throw error;
  }
} // END V1WelcomeEmailTask

The enqueueing action is responsible for passing locale:

javascript
// inside a V1Create action
await UserQueue.add('V1WelcomeEmailTask', {
  userId: newUser.id,
  locale: req.getLocale() // always pass the current request locale
});

Why getLocalI18n()? Each call returns a fresh, isolated i18n instance. This prevents locale state from one job leaking into another when multiple jobs run concurrently. Never share a single i18n instance across jobs.


9. yarn lang ​

yarn lang is the i18n build and validation command. Run it every time you edit any language file.

yarn lang

What it does:

  1. Reads every languages/en.js (global + all features).
  2. Merges them into locales/en.json (and other locales).
  3. Validates that every translation key referenced anywhere in the codebase exists in every locale file.
  4. Errors and exits non-zero if any key is missing.

yarn test runs yarn lang first — so a missing translation key will fail CI even if you forget to run it manually.

Never edit locales/*.json directly. Your changes will be overwritten the next time yarn lang runs.


10. Testing Errors ​

Every JSDoc error entry must have a test ​

The Errors: block in every action's JSDoc comment is your test checklist. Before an action is done, go through every error listed and confirm there is a test that:

  1. Sends the request in a way that triggers that specific error.
  2. Asserts the correct HTTP status code.
  3. Compares the full response body against errorResponse(i18n, ERROR_CODES.YOUR_ERROR_CODE).
javascript
it('[logged-out] should fail if credentials are incorrect', async () => {
  const res = await request(app)
    .post(routeUrl)
    .send({ email: 'wrong@example.com', password: 'wrongpassword' });

  expect(res.statusCode).toBe(400);
  // compare the full body — not just the status code
  expect(res.body).toEqual(errorResponse(i18n, ERROR_CODES.ADMIN_BAD_REQUEST_INVALID_LOGIN_CREDENTIALS));
});

Using toEqual(errorResponse(...)) instead of toBe(res.body.error) ensures the entire error contract — status, error string, and translated message — is tested. If a translation key is renamed or the error code shape changes, this test catches it.

Test who cannot do something ​

For every role that does NOT have access to an action, there must be a test asserting the correct rejection. At minimum, if an action requires authentication, there is always a Role: Logged Out test asserting a 401:

javascript
describe('Role: Logged Out', () => {
  it('[logged-out] should fail to read admin', async () => {
    const res = await request(app).post(routeUrl).send({});
    expect(res.statusCode).toBe(401);
    expect(res.body).toEqual(errorResponse(i18n, ERROR_CODES.UNAUTHORIZED));
  });
});

Getting i18n in test files ​

Test files need a local i18n instance to build the expected error responses with errorResponse(i18n, ...). Use getLocalI18n() at the top of the describe block:

javascript
const lang = require('../../../../services/language');
const { ERROR_CODES, errorResponse } = require('../../../../services/error');

describe('Admin.V1Login', () => {
  const i18n = lang.getLocalI18n();
  // ...

  it('[logged-out] should fail with bad credentials', async () => {
    const res = await request(app).post(routeUrl).send({ email: 'x@x.com', password: 'bad' });
    expect(res.statusCode).toBe(400);
    expect(res.body).toEqual(errorResponse(i18n, ERROR_CODES.ADMIN_BAD_REQUEST_INVALID_LOGIN_CREDENTIALS));
  });
});

Testing Joi validation errors ​

BAD_REQUEST_INVALID_ARGUMENTS is returned for Joi failures. Because the message is a dynamic string from joiErrorsMessage(), do not compare the full body with toEqual — instead assert the status and error code string:

javascript
it('[admin] should fail if id is not a number', async () => {
  const { token } = await adminLogin(app, routeVersion, request, adminFix[0]);

  const res = await request(app)
    .post(routeUrl)
    .set('authorization', `jwt-admin ${token}`)
    .send({ id: 'not-a-number' });

  expect(res.statusCode).toBe(400);
  expect(res.body.error).toBe(ERROR_CODES.BAD_REQUEST_INVALID_ARGUMENTS.error);
  expect(res.body.success).toBe(false);
});