О чём это
У нас есть своё приложение в Google Play, бэкенд которого — 1С. Есть релей на 1С для мессенджера. Есть клиент к партнёрскому API 1С. Везде 1С выступает как API, и везде мы наступали на одни и те же грабли.
Общее у них одно: на тесте всё зелено, ломается в проде. Потому что тест ходит по локалке с идеальной связью, а пользователь едет в лифте и жмёт кнопку дважды.
Материал для тех, кто публикует 1С наружу. Если вы это уже проходили — буду рад увидеть в комментариях ваши грабли, у меня список наверняка неполный.
Грабля 1. Тернарный оператор ?() вычисляет обе ветки
Начну с самой обидной, потому что она выглядит как совершенно нормальный код.
Нужно проставить порядковый номер записи: если записей ещё нет — единица, иначе максимум плюс один.
// Так делать НЕЛЬЗЯОб.Seq = ?(Выборка.МаксСеq = Неопределено, 1, Выборка.МаксСеq + 1);
Читается однозначно: «если Неопределено — возьми 1». В большинстве языков вторая ветка при истинном условии просто не выполнится.
В 1С ?() — это функция, а не оператор ветвления. Аргументы вычисляются до вызова, оба. Поэтому Выборка.МаксСеq + 1 посчитается даже тогда, когда МаксСеq равен Неопределено. Результат — исключение прямо в момент вычисления аргумента.
// Так правильноЕсли Выборка.МаксСеq = Неопределено Тогда Об.Seq = 1;Иначе Об.Seq = Выборка.МаксСеq + 1;КонецЕсли;
Почему это видно только в проде. Ветка Неопределено срабатывает ровно один раз в жизни записи — при первом обращении, когда таблица пустая. На тестовой базе данные уже есть, максимум считается, всё летает. А первый живой пользователь получает HTTP 500 на самом первом запросе.
У нас это выстрелило именно так: первый POST в пустой inbox нового устройства падал с 500. Второй и все последующие проходили.
Грабля 2. NULL — это не Неопределено
Прямое продолжение первой, и она хитрее.
Мы починили ?() на Если...Иначе, а 500 остался. Потому что гвард проверял не то значение.
Запрос.Текст ="ВЫБРАТЬ МАКСИМУМ(Seq) КАК М|ИЗ РегистрСведений.Очередь|ГДЕ DeviceId = &DeviceId";
Что вернёт МАКСИМУМ, если под условие DeviceId = &DeviceId не попало ни одной записи?
Не Неопределено. NULL.
Агрегатная функция на пустом наборе даёт NULL — это язык запросов, а не встроенный язык. И NULL = Неопределено — ложь. Это разные значения разных типов.
// Не ловит: NULL ≠ НеопределеноЕсли Выборка.М = Неопределено Тогда ...// Ловит обаЕсли Выборка.М = Неопределено ИЛИ Выборка.М = NULL Тогда ...
Надёжнее убрать проблему в самом запросе:
"ВЫБРАТЬ ЕСТЬNULL(МАКСИМУМ(Seq), 0) КАК М|ИЗ РегистрСведений.Очередь|ГДЕ DeviceId = &DeviceId"
Тогда встроенный язык всегда получает число, и ветвление становится не нужным вовсе.
Симптом в логе — совершенно не про NULL: «Преобразование значения к типу Число». Ищешь ошибку в арифметике, а она в том, что пришло из запроса.
Тонкость: МАКСИМУМ без условия на пустой таблице вернёт Неопределено, а с условием, под которое ничего не попало, — NULL. Отсюда и путаница: код годами работает, потом кто-то добавляет фильтр — и получает 500.
Грабля 3. HTTP 500 после успешной записи — и клиент плодит дубли
Эта дороже всех, потому что бьёт по данным.
Типичный обработчик: сохранить документ, потом отправить push.
Функция ПринятьЗаказ(Запрос) // ... разбор тела, запись Об.Записать(); // c89; данные в базе Push.УведомитьОЗаказе(Об); // d60; здесь исключение Возврат ОтветJSON(200, ...); // сюда не дойдёмКонецФункции
Записать() отработал. Транзакция закрыта. Заказ в базе.
Дальше падает УведомитьОЗаказе — неважно почему: недоступен relay, опечатка в имени метода, протухший токен. Исключение уходит наверх, платформа отдаёт HTTP 500.
Что видит клиент: 500. Что он делает: считает, что заказ не дошёл, и повторяет отправку.
Итог — два одинаковых документа. При плохой связи и трёх ретраях — четыре.
Корень: побочное действие после коммита не обёрнуто, и его сбой роняет основную операцию, которая уже успешно завершилась.
Об.Записать();// push — дело десятое: он не должен решать судьбу заказаПопытка Push.УведомитьОЗаказе(Об);Исключение ЗаписьЖурналаРегистрации("Push", УровеньЖурналаРегистрации.Предупреждение,,, ПодробноеПредставлениеОшибки(ИнформацияОбОшибке()));КонецПопытки;Возврат ОтветJSON(200, ...);
Важно, где именно ставить Попытка. У нас блок стоял, но внутри — вокруг вызова relay. А исключение прилетало раньше, из строки, которая собирала список получателей. Один незащищённый вызов в «голове» процедуры — и весь смысл обёртки теряется.
Правило простое: обёрнуто должно быть всё побочное действие целиком, от первой строки до последней, а не только та его часть, которая кажется опасной.
Отдельно отмечу класс «500 после коммита» как таковой: если операция изменила данные, а потом упала, — клиент обязан считать её неуспешной, потому что по коду ответа он не может отличить «не записалось» от «записалось, но потом что-то ещё сломалось». Разгребать это — уже задача идемпотентности.
Грабля 4. Идемпотентность: ключ, а не «проверка на дубль»
Ретраи — норма жизни мобильного клиента. Связь пропала, ответ не дошёл, приложение повторило. Сервер обязан быть к этому готов.
Частое решение — искать похожий документ:
// ХрупкоЗапрос.Текст = "ВЫБРАТЬ Ссылка ИЗ Документ.Заказ |ГДЕ Контрагент = &К И Сумма = &С И Дата МЕЖДУ &Н И &В";
Ломается на первом же реальном сценарии: клиент действительно заказал то же самое дважды за минуту. Мы такой заказ съедим и не создадим — а он был настоящий.
Работает другое: клиент сам генерирует ключ операции (GUID) и присылает его. Один и тот же ключ — одна и та же операция, сколько бы раз она ни пришла.
Функция СоздатьЗаказ(Запрос) Тело = ПрочитатьJSON(Запрос); Ключ = ПолеСтрокой(Тело, "idempotency_key"); Если ПустаяСтрока(Ключ) Тогда Возврат ОтветОшибка(400, "idempotency_key обязателен"); КонецЕсли; // Уже обрабатывали? Отдаём ТОТ ЖЕ результат, а не ошибку. Существующий = ЗаказПоКлючу(Ключ); Если ЗначениеЗаполнено(Существующий) Тогда Возврат ОтветJSON(200, Новый Структура("id, duplicate", Строка(Существующий.УникальныйИдентификатор()), Истина)); КонецЕсли; Об = Документы.Заказ.СоздатьДокумент(); Об.IdempotencyKey = Ключ; // реквизит + ИНДЕКС по нему // ... заполнение Об.Записать(); Возврат ОтветJSON(200, ...);КонецФункции
Три момента, на которых легко ошибиться.
Повтор — это 200, а не 409. Клиент ретраит, потому что не получил ответ. Ему нужен результат операции, а не сообщение об ошибке. Отдав 409, вы заставите его показать пользователю ошибку на успешно созданном заказе.
Ключ генерирует клиент, а не сервер. Сервер не может отличить ретрай от нового заказа — только клиент знает, что это та же кнопка.
Индекс по реквизиту обязателен. Иначе на каждом заказе будет полный перебор документов, и на десяти тысячах записей API встанет.
Грабля 5. Имя поля, регистр и часовой пояс
Три мелочи, каждая из которых стоила нам вечера.
Имя поля
Приложение слало {"mail": "..."}, сервис читал Тело["email"]. Поле не находится, дальше по коду — обращение к Неопределено, и снова HTTP 500. Ошибка не в логике, а в одной букве, и лог про неё молчит.
Лечится не внимательностью, а тем, что сервис не доверяет телу запроса:
Функция ПолеСтрокой(Стр, Имя, Умолчание = "") Если ТипЗнч(Стр) <> Тип("Соответствие") Тогда Возврат Умолчание; КонецЕсли; Знач = Стр.Получить(Имя); Если Знач = Неопределено ИЛИ Знач = NULL Тогда Возврат Умолчание; КонецЕсли; Возврат Строка(Знач);КонецФункции
Тогда отсутствие поля даёт понятный 400 «поле email обязательно», а не 500 где-то в середине.
Регистр и формат числа
Строка(1784000000) в 1С может дать «1 784 000 000» — с разделителями групп. Уезжает в JSON, на той стороне Atoi спотыкается.
// Строка(Число) — с разделителями. Формат — без.Значение = Формат(ExpiryUnix, "ЧГ=0");
Больно то, что зеркальный тест на Python это не поймает: там f"{int}" разделителей не ставит, и обе стороны «сходятся» ровно до боевого запуска.
Часовой пояс
ТекущаяДата() возвращает локальное время сервера. ТекущаяУниверсальнаяДата() — UTC. API снаружи почти всегда работает в UTC.
Если этого не разделять, граница месяца съезжает: 2026-12-23T20:59:59Z — это 23 декабря 23:59:59 по Москве, то есть ещё декабрь. Фильтр «за декабрь» без конверсии потеряет документы последнего дня.
Правило: внутри базы — локальное, на границе API — UTC, конверсия ровно в одном месте.
Что из этого следует
Ни одна из пяти не про «1С плохой». Все про то, что HTTP-сервис — это граница системы, и на границе перестают действовать привычки, которые внутри работают.
Внутри конфигурации вы знаете, что данные корректны, вызов дойдёт, а транзакция или пройдёт целиком, или откатится. Снаружи ничего этого нет: тело запроса произвольное, связь рвётся, клиент повторяет, часовые пояса разные.
Отсюда четыре правила, которые мы теперь применяем не думая: 1. ?() вычисляет обе ветки — где может быть Неопределено, пишем Если...Иначе. 2. Агрегат под фильтром на пустом наборе даёт NULL — оборачиваем в ЕСТЬNULL. 3. Побочное действие после коммита обёрнуто целиком, иначе оно роняет уже сделанную работу и порождает дубли. 4. Каждая изменяющая операция принимает ключ идемпотентности, повтор отдаёт 200 с тем же результатом.
Список наверняка неполный — это то, на чём обожглись мы. Если ловили другое, напишите в комментариях, дополню.
Подробнее про архитектуру связки мобильного приложения с 1С — HTTP-сервисы против планов обмена, офлайн-режим, версионирование методов — я разбирал отдельно: интеграция мобильного приложения с 1С.
ссылка на оригинал статьи https://habr.com/ru/articles/1060188/