HeyPuter/puter · error · HttpError

bad_request

bad_request

Error message

Missing `records` array

What it means

`POST /turn/ingest-usage` authenticates successfully but then requires a `records` array in the JSON body — each record describes a TURN egress usage entry to meter. If `records` is missing or not an array, the endpoint returns 400. Individual non-object entries are silently skipped, so only the top-level array shape is validated.

Source

Thrown at src/backend/controllers/peer/PeerController.ts:245

     */
    #ingestUsage = async (req: Request, res: Response): Promise<void> => {
        const cfg = this.config.peers;
        if (!cfg || !cfg.internal_auth_secret) {
            throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
        }
        const expectedSecret = cfg.internal_auth_secret;
        const header = req.headers['x-puter-internal-auth'];
        if (
            !expectedSecret ||
            typeof header !== 'string' ||
            !secretsEqual(header, expectedSecret)
        ) {
            throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
        }

        const { records } = req.body ?? {};
        if (!Array.isArray(records)) {
            throw new HttpError(400, 'Missing `records` array', {
                legacyCode: 'bad_request',
            });
        }

        for (const record of records) {
            if (!record || typeof record !== 'object') continue;
            const egressBytes = Number(record.egressBytes ?? 0);
            if (egressBytes <= 0) continue;

            const userUuid = record.userId
                ? base64urlToUuid(String(record.userId))
                : null;
            if (!userUuid) continue;

            try {
                const user = await this.stores.user.getByUuid(userUuid);
                if (!user) continue;
                const costInMicrocents =

View on GitHub (pinned to 908ec23eda)

Solutions

  1. Ensure the body is `{ records: [...] }` where `records` is a JSON array.
  2. Each record should include `egressBytes` (positive number) and `userId` (base64url UUID).
  3. Set `Content-Type: application/json`.
  4. Validate the payload shape on the sender before posting.

Example fix

// before
await fetch('/turn/ingest-usage', {
  method: 'POST',
  body: JSON.stringify(record), // single object, not array
});

// after
await fetch('/turn/ingest-usage', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'x-puter-internal-auth': secret },
  body: JSON.stringify({ records: [record] }),
});
Defensive patterns

Strategy: validation

Validate before calling

// Validate records shape before posting
if (!Array.isArray(records)) {
  throw new Error('records must be an array');
}
await fetch('/turn/ingest-usage', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-puter-internal-auth': secret,
  },
  body: JSON.stringify({ records }),
});

Type guard

/** @param {unknown} v @returns {v is unknown[]} */
function isRecordArray(v) {
  return Array.isArray(v);
}

Prevention

When it happens

Trigger: The ingestion service posts a body without `records`, or `records` is an object/number/string instead of an array. Each element should have `egressBytes` (number) and `userId` (base64url-encoded UUID).

Common situations: The ingestion service schema changed and stopped sending the array wrapper; a test fixture posts a single record object instead of `{ records: [...] }`; a serialization bug flattens the array.

Related errors


AI-assisted analysis of HeyPuter/puter@908ec23eda (2026-08-12). Data as JSON: /api/errors/49d76a6e64555cd0. Report an issue: GitHub.