1С как бэкенд мобильного приложения: пять граблей, которые видно только в проде

от автора

О чём это

У нас есть своё приложение в 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/