Все ошибки, которые может вернуть Content API, в одном месте: как выглядит ответ, отчего это обычно случается и как починить. API только читает и сам по себе небольшой, поэтому список короткий, а у каждой ошибки есть машиночитаемое поле message — вашему коду не придётся гадать.
Все ошибки одной таблицей
| Статус | message | Если в двух словах |
|---|---|---|
| 400 | invalid_marker | Маркер в адресе — не маркер. |
| 400 | invalid_lang | Код языка в lang записан неправильно. |
| 400 | unknown_lang | Код записан верно, но такого активного языка у сайта нет. |
| 400 | too_many_langs | В lang больше 50 кодов. |
| 401 | invalid_key | Ключа сайта нет, он неправильного вида или отозван. |
| 404 | page_not_found | Нет страницы с таким маркером. |
| 404 | section_not_found | Нет секции с таким маркером. |
| 404 | block_not_found | Нет блока с таким маркером (в этой секции). |
| 404 | post_not_found | Нет видимой статьи блога с таким адресом. |
| 404 | Cannot GET /v1/… | Такого адреса в API нет вообще. |
| 405 | method_not_allowed | Любой метод, кроме GET и HEAD. |
| 429 | rate_limit_exceeded | Слишком много запросов за эту минуту. |
| 5xx | — | Что-то сломалось у нас. |
Как выглядит ошибка
Тело любой ошибки — JSON с полем message. Некоторые ошибки добавляют пару полезных полей: например, unknown_lang подсказывает, какие языки у сайта есть на самом деле.
HTTP/1.1 404 Not Found
Content-Type: application/json
Cache-Control: no-store
{"message":"page_not_found"}Ошибки всегда приходят с Cache-Control: no-store, так что 404 на страницу, которую редактор прямо сейчас создаёт, не застрянет в кэше браузера или CDN. Устранили причину — и следующий запрос получает нормальный ответ.
400 Bad Request
Неправилен сам запрос. Повтор не поможет — нужно исправить адрес.
invalid_marker
{"message":"invalid_marker"}Маркеры страниц, секций и блоков должны подходить под ^[A-Za-z0-9_]{2,40}$: латинские буквы, цифры и подчёркивание, от 2 до 40 символов.
Частые причины:
- Дефис или точка:
/v1/pages/about-us— в маркерах подчёркивание,about_us. - Путь вместо маркера:
hrefстраницы (/about) — это не её маркер (about). - Один символ, больше 40 символов, пробелы или кириллица.
- Кусок адреса от посетителя ушёл в API как есть — кто-то набрал в строке браузера
/о-нас.
Что делать: скопируйте маркер из CRM. Если в ваших адресах дефисы, сопоставляйте их с маркерами в роутере. А если маркер пришёл из адреса посетителя, считайте invalid_marker обычным 404: такой страницы просто нет.
invalid_lang
{"message":"invalid_lang","lang":"EN"}Каждый код в lang должен выглядеть как ^[a-z]{2}(-[A-Za-z]{2,4})?$: две строчные буквы и, если нужно, регион вроде pt-BR. Регистр важен. В поле lang возвращается значение, которое не прошло проверку.
Частые причины:
- Заглавные буквы:
ENвместоen. - Локаль с подчёркиванием от сервера или i18n-библиотеки:
en_US. - Трёхбуквенные коды вроде
engили название целиком —english.
Что делать: используйте ровно те коды, что есть у сайта, — их отдаёт GET /v1/langs. Надёжнее всего приводить то, что даёт ваш фреймворк, к одному из этих кодов.
unknown_lang
{
"message": "unknown_lang",
"lang": "fr",
"unknown": ["fr"],
"available": ["en", "de"]
}Код записан правильно, но среди активных языков сайта его нет. Тело ответа тут действительно полезное: в unknown — все запрошенные коды, которые не подошли, в available — те, которыми можно пользоваться.
Частые причины:
- Редактор выключил язык в CRM, а ваш код по-прежнему просит его по имени.
- Локаль браузера передана как есть:
en-USзаписан правильно, но у сайта языкen. - Код соседа:
uaтам, где у сайтаuk,czтам, гдеcs. - Ключ от другого сайта, у которого свой набор языков.
Что делать: собирайте список языков из GET /v1/langs, а не держите его в коде, или откатывайтесь на первый код из available. Если нужны все языки, просто не передавайте lang. Подробнее — в разделе Языки и запасной язык.
too_many_langs
{"message":"too_many_langs","max":50}За один запрос можно попросить не больше 50 кодов. Если вы сюда попали, скорее всего список собирается из чего-то безразмерного: заголовка, пользовательского ввода, цикла, который всё дописывает и дописывает.
Что делать: чтобы получить все активные языки, вообще не передавайте lang — по умолчанию API именно так и делает.
401 invalid_key
{"message":"invalid_key"}API не принял ключ сайта. Правильный ключ — это pk_ и 32 строчных шестнадцатеричных символа, передаётся в заголовке x-crm-key (или параметром ?key=).
Частые причины:
- Заголовка нет — чаще всего потому, что переменная окружения пуста именно в этом окружении.
- В браузере переменная не попала в сборку (в Next.js туда попадают только
NEXT_PUBLIC_*), и в заголовке буквально написаноundefined. - Кавычки, пробелы или перевод строки, скопированные в
.envвместе с ключом. - Ключ отозвали в CRM.
- Ключ создан только что. Обычно он начинает работать сразу, в худшем случае — через несколько минут. С отзывом ключа то же самое.
Что делать: проверьте ключ в CRM в разделе «Настройки → Ключи контентного API» и прогоните его через curl. Подробности — на странице Ключ сайта.
curl -i "https://back.sitecog.com/content/v1/langs" -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"404 Not Found: такого контента нет
Адрес правильный, ключ правильный, но по этому адресу ничего нет. Что именно не нашлось, подскажет message:
| message | Запрос | Обычные подозреваемые |
|---|---|---|
page_not_found | /v1/pages/:marker | Опечатка или не тот регистр (Home ≠ home), страницу переименовали или удалили в CRM, ключ от другого сайта. |
section_not_found | /v1/sections/:marker | То же самое. Скрытые секции (show: false) при этом отдаются — так что 404 значит, что секции действительно нет. |
block_not_found | /v1/blocks/:marker | Опечатка, или блок лежит не в той секции, которую вы указали в ?section=. |
post_not_found | /v1/blog/:slug | Статья — черновик, запланирована на будущее, или адрес статьи неправильный (только строчные a–z, цифры и дефисы, до 120 символов — на неправильный адрес приходит 404, а не 400). |
{"message":"page_not_found"}Что делать: если маркер зашит в шаблон, сверьте его с CRM. Если он пришёл из адреса посетителя, это вовсе не ошибка в коде, а обычный 404 — покажите свою страницу «не найдено» (в Next.js — вызовом notFound()).
Неизвестный адрес
Если адрес не совпадает ни с одним запросом API, приходит стандартный 404 фреймворка — и выглядит он иначе, чем 404 контента выше:
{
"message": "Cannot GET /v1/page/home",
"error": "Not Found",
"statusCode": 404
}Отличить его легко: есть поля error и statusCode, а message начинается с Cannot GET …. Это всегда ошибка в вашем коде, а не отсутствующий контент.
Частые причины:
- Единственное число вместо множественного:
/v1/page/home,/v1/block/phone. - Забытый
/v1в пути. - Лишние сегменты:
/v1/pages/home/hero— у секций свой запрос,/v1/sections/hero.
Что делать: сверьте адрес со справочником API. Базовый адрес в одной константе (https://back.sitecog.com/content) и сборка путей в одной функции избавляют от большинства таких ошибок.
405 method_not_allowed
{"message":"method_not_allowed"}Content API только читает: он отвечает на GET и HEAD. На POST, PUT, PATCH и DELETE приходит 405.
Частые причины: HTTP-клиент по умолчанию шлёт POST, у формы action указывает на API, или есть надежда сохранить через него контент. Контент правят в CRM или прямо на сайте в режиме Live, а API только читает.
429 rate_limit_exceeded
{"message":"rate_limit_exceeded"}Слишком много запросов за текущую минуту: с вашего IP (300), с вашим ключом (600) или слишком много попыток с неверным ключом с вашего IP (20). Заголовка Retry-After нет: дождитесь следующей минуты, добавив немного случайности. Лимиты, расчёты и готовая функция повторов — на странице Лимиты.
5xx: проблема на нашей стороне
500, 502, 503 или 504 значат, что что-то пошло не так у нас, а не в вашем коде. Именно такие ошибки и стоит повторять: короткая экспоненциальная пауза со случайным разбросом (пример), а если не помогло — покажите последнюю удачную копию из кэша. Сайт, который во время сбоя показывает вчерашний текст, куда лучше сайта, который показывает трассировку стека.
Сетевые ошибки и CORS
Бывает, что ответа нет вовсе — fetch падает с TypeError («Failed to fetch», «fetch failed»). Причины:
- Проблемы с DNS, нет соединения, файрвол на вашем сервере, зависший запрос. Ставьте таймаут:
AbortSignal.timeout(10_000). - CORS в браузере. API разрешает любой источник, методы
GETиOPTIONSи заголовки запросаx-crm-key,content-typeиif-none-match. Добавите любой другой свой заголовок (Authorization, заголовок трассировки от HTTP-клиента) — и браузер заблокирует запрос ещё до отправки, а ваш код увидит сетевую ошибку. Загляните в консоль: браузер напишет, какой заголовок ему не понравился.
Надёжная обработка ошибок на TypeScript
Соберём всё это в один маленький модуль: любой ответ не из 2xx и любой сбой сети превращаются в типизированную ContentApiError со статусом, машиночитаемым кодом и дополнительными полями. Остальному коду остаётся проверять err.code.
const API = 'https://back.sitecog.com/content';
export type ContentErrorCode =
| 'invalid_marker' | 'invalid_lang' | 'unknown_lang' | 'too_many_langs'
| 'invalid_key'
| 'page_not_found' | 'section_not_found' | 'block_not_found' | 'post_not_found'
| 'method_not_allowed' | 'rate_limit_exceeded'
| 'unknown_route' // 404 «Cannot GET …» — неверный адрес
| 'server_error' // 5xx
| 'network_error' // ответа не было вовсе
| 'unexpected'; // что-то, чего мы раньше не видели
const KNOWN = new Set<string>([
'invalid_marker', 'invalid_lang', 'unknown_lang', 'too_many_langs', 'invalid_key',
'page_not_found', 'section_not_found', 'block_not_found', 'post_not_found',
'method_not_allowed', 'rate_limit_exceeded',
]);
type ErrorBody = {
message?: string;
lang?: string;
unknown?: string[];
available?: string[];
max?: number;
};
export class ContentApiError extends Error {
readonly status: number; // HTTP-статус, 0 — если ответа не было
readonly code: ContentErrorCode;
readonly lang?: string; // invalid_lang, unknown_lang
readonly unknown?: string[]; // unknown_lang: коды, которые не подошли
readonly available?: string[]; // unknown_lang: коды, которыми можно пользоваться
readonly max?: number; // too_many_langs
constructor(status: number, code: ContentErrorCode, body: ErrorBody = {}, cause?: unknown) {
super(`Content API ${status || 'network'}: ${body.message ?? code}`, { cause });
this.name = 'ContentApiError';
this.status = status;
this.code = code;
this.lang = body.lang;
this.unknown = body.unknown;
this.available = body.available;
this.max = body.max;
}
/** Есть смысл повторить позже: сеть, лимит, наши 5xx */
get retryable() {
return this.status === 0 || this.status === 429 || this.status >= 500;
}
/** Такого контента нет — показываем свою страницу 404 */
get notFound() {
return this.code.endsWith('_not_found') || this.code === 'invalid_marker';
}
}
function toCode(status: number, message?: string): ContentErrorCode {
if (status >= 500) return 'server_error';
if (message && KNOWN.has(message)) return message as ContentErrorCode;
if (status === 404 && message?.startsWith('Cannot ')) return 'unknown_route';
return 'unexpected';
}
export async function contentFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let res: Response;
try {
res = await fetch(API + path, {
...init,
headers: { ...(init.headers as Record<string, string>), 'x-crm-key': process.env.CRM_KEY! },
signal: init.signal ?? AbortSignal.timeout(10_000),
});
} catch (cause) {
throw new ContentApiError(0, 'network_error', {}, cause);
}
if (res.ok) return (await res.json()) as T;
// Тело ошибки — JSON, но прокси по дороге может ответить HTML-ом, так что осторожно
const body: ErrorBody = await res.json().catch(() => ({}));
throw new ContentApiError(res.status, toCode(res.status, body.message), body);
}А вот как это выглядит в живом коде:
// app/[lang]/[marker]/page.tsx
import { notFound, redirect } from 'next/navigation';
import { contentFetch, ContentApiError } from '@/lib/content-api';
type Props = { params: Promise<{ lang: string; marker: string }> };
export default async function Page({ params }: Props) {
const { lang, marker } = await params;
try {
const page = await contentFetch<PageData>(`/v1/pages/${marker}?lang=${lang}`, {
next: { revalidate: 60 },
});
return <PageView page={page} lang={lang} />;
} catch (err) {
if (err instanceof ContentApiError) {
if (err.notFound) notFound();
if (err.code === 'unknown_lang' && err.available?.length) {
redirect(`/${err.available[0]}/${marker}`);
}
console.error(err.code, err.status, err.message); // invalid_key, unknown_route… — баг, который надо чинить
}
throw err; // error.tsx покажет «что-то пошло не так»
}
}import { contentFetch, ContentApiError } from './content-api';
let lastGood: unknown = null;
export async function loadHome() {
try {
lastGood = await contentFetch('/v1/pages/home?lang=en');
} catch (err) {
if (err instanceof ContentApiError && err.retryable && lastGood) {
// 429, 5xx или нет сети: продолжаем показывать то, что уже есть
console.warn('Content API временно капризничает:', err.code);
} else {
throw err;
}
}
return lastGood;
}