Rest 26.600.0: отложенный batch и Idempotency-Key в REST V3, новые правила для встроек
Ломающее обновление
Удалены или изменены публичные API — прикладной код может перестать работать.
В rest 26.600.0 (сборка от 23.07.2026) 143 файла и +5143/−383 строк. В REST V3 появились отложенный batch со своей таблицей и очередью, идемпотентность по заголовку Idempotency-Key и перечисления, значения которых берутся из кода. Заодно переделали права на встройки и сигнатуры команд встроек. Касается тех, кто пишет свои V3-контроллеры, вызывает placement.* из приложений или создаёт встройки командами модуля. Deprecated нет, публичные методы не удалялись.
Проверьте, как вы создаёте команды встроек
У AddEmbeddingCommand и DeleteEmbeddingCommand из Bitrix\Rest\Public\Command\Application\Embedding переименовали параметры конструктора. Раньше currentUserId означал того, кто действует, а userId означал того, для кого встройка. Теперь действующий пользователь передаётся в userId, а целевой в targetUserId.
Порядок аргументов не менялся, поэтому позиционные вызовы работают как раньше. Именованный вызов со старым currentUserId: сразу падает с Error про неизвестный параметр. Смешанный вызов вида new AddEmbeddingCommand($app, $adminId, $placement, $handler, userId: $managerId) тоже упадёт, потому что userId уже передан вторым позиционным аргументом.
Новый вызов выглядит так:
<?php declare(strict_types=1);
use Bitrix\Main\Loader;
use Bitrix\Rest\Internal\Repository\Application\AppRepository;
use Bitrix\Rest\Public\Command\Application\Embedding\AddEmbeddingCommand;
Loader::requireModule('rest');
$app = (new AppRepository())->getByClientId('local.6650a1b2c3d4e5.12345678');
if ($app === null)
{
throw new \RuntimeException('Приложение не найдено');
}
$result = (new AddEmbeddingCommand(
app: $app,
userId: $adminId, // кто ставит встройку
placement: 'CRM_DEAL_DETAIL_TAB',
handler: 'https://example.com/embed/deal-tab',
targetUserId: $managerId, // для кого; по умолчанию 0, как раньше
))->run();
if (!$result->isSuccess())
{
// $result->getErrorMessages()
}
С внутренним EmbeddingUninstaller::uninstall() хуже, там ошибка может пройти молча. Его четвёртый параметр ?int $userId переименован в ?int $targetUserId, а следом добавлен int $userId = 0 для действующего пользователя. Если вы звали uninstall(..., userId: 5), имея в виду «удалить встройку пользователя 5», теперь пятёрка уйдёт в действующего пользователя, а USER_ID в запрос на удаление не попадёт вовсе. С userId: null получите TypeError.
Поменялось и поведение DeleteEmbeddingCommand при ошибке. Её execute() больше не заворачивает RestException в Result. Снаружи команду вызывают через run(), а AbstractCommand::run() из main оборачивает любое \Exception в CommandException. Раньше run() возвращал Result с ошибкой, теперь бросает CommandException, у которого в getPrevious() лежит исходная RestException. В REST-контроллерах модуля исключение разбирает CommandRunner. AccessException он превращает в AccessDeniedException, RestException с кодом ERROR_CORE во внутреннюю ошибку, остальные в ошибку валидации запроса.
Наследникам RestController: типы, фильтры и select по умолчанию
В Bitrix\Rest\V3\Controller\RestController свойство $responseLanguage стало ?string со значением null, а getResponseLanguage() теперь возвращает string и, если язык равен null, подставляет DefaultLanguage::get() (через ??=). Если наследник переобъявил свойство как string или метод с типом ?string, класс не загрузится.
RestController теперь сам переопределяет getDefaultPreFilters() и getDefaultPostFilters() и кладёт в обе цепочки один и тот же экземпляр IdempotencyFilter. Экземпляр общий, потому что в onBeforeAction фильтр запоминает состояние, которое потом читает onAfterAction. В docblock класса написано, что наследник, забывший parent::, «silently lose idempotency support». Если вы добавляете свои фильтры, делайте это поверх родительских:
<?php declare(strict_types=1);
namespace Vendor\Shop\Rest\Controller;
use Bitrix\Rest\V3\Controller\RestController;
use Vendor\Shop\Rest\Filter\AuditFilter;
final class Order extends RestController
{
protected function getDefaultPreFilters()
{
return [
...parent::getDefaultPreFilters(),
new AuditFilter(),
];
}
}
Ошибки привязки параметров (BinderArgumentException) раньше всегда превращались во внутреннюю ошибку. Теперь это RequiredFieldInRequestException, InvalidRequestFieldTypeException или RequestValidationException. Разбирают их по тексту сообщения исключения, через str_starts_with и регулярку. Если имя параметра определить не удалось или текст не подошёл ни под один шаблон, по-прежнему будет InternalException. Клиенты ваших методов вместо «внутренней ошибки» увидят ошибку валидации, и если они разбирают ответ по коду, это стоит проверить.
OrmRepository без select раньше отдавал в ORM ['*'], теперь собирает выборку из скалярных полей DTO. Если маппинг DTO опирался на колонки, которых в DTO нет, их в выборке не окажется.
Вложенные DTO без #[RelationToOne]/#[RelationToMany] теперь считаются встроенными. Их заполняет MappedBy, а в select работают пути вида address.city. ORM-связь внутри такого встроенного DTO в select даёт InvalidSelectException.
Приложения могут вызывать placement.* без прав администратора
Легаси-методы placement.bind, placement.unbind и placement.get раньше пускали только администратора. Теперь Bitrix\Rest\Api\Placement::checkPermission() пускает и обычного пользователя, если разрешает AppAccessChecker. Для bind нужно право ставить встройки, для unbind право удалять, для get право видеть список. Администратор определяется по user_id из авторизации запроса.
Не-админ может указать в USER_ID только себя или PlacementTable::DEFAULT_USER_ID_VALUE, иначе AccessException. placement.get научился принимать USER_ID, а без него не-админ видит только общие встройки и свои. placement.unbind без USER_ID у не-админа удаляет в тех же пределах. У администратора всё как было.
Одновременно права на встройки ужесточили. Для персонального приложения ставить и удалять встройки может только админ или владелец. Для остальных нужно прежнее право на локальные приложения и вдобавок условие «админ или приложение локальное». Для приложения, которое не персональное и не локальное (например, из маркетплейса), canInstallEmbedding() и canUninstallEmbedding() теперь требуют администратора. Список встроек в V3 без прав администратора показывает только общие и свои.
Если вы наследуете Bitrix\Rest\Api\Placement и переопределяли checkPermission(\CRestServer $server), объявление стало несовместимым: у родителя появился второй необязательный параметр.
Вебхуки без интеграции теперь можно обновить и удалить
UpdateIncomingWebhookCommandHandler и DeleteIncomingWebhookCommandHandler раньше бросали ObjectNotFoundException, если у вебхука нет записи интеграции. Теперь такой вебхук обновляется или удаляется напрямую через PasswordTable и PermissionTable в транзакции. После коммита кеш сбрасывается ещё раз. В комментарии объяснено, что параллельный запрос может успеть закешировать незакоммиченное состояние.
Результат Provider::saveIntegration() и Provider::deleteIntegration() раньше игнорировался, теперь при ошибке бросается PersistenceException с причинами в тексте. Scope системного вебхука менять нельзя, будет ArgumentException. Обновление и удаление пишутся в журнал безопасности и при неудаче, в записи есть поле outcome (success, failure, partialFailure) и тип вебхука.
Рядом похожая правка: UserRepository::save() при неудачном CUser::Update() бросает новый UserNotUpdatedException с текстом LAST_ERROR. Раньше результат обновления не проверялся.
Отложенный batch: пачка команд уходит в очередь
Новый V3-контроллер DeferredBatch принимает пачку команд и выполняет её в фоне. В коде встречаются имена методов rest.deferredbatch.add и rest.deferredbatch.downloadResult, а у контроллера пять действий: add, list, get, delete, downloadResult. Scope у всех глобальный, записи видны только их автору.
addпринимаетfields.commandsв формате обычного V3 batch и проверяет, что у вызывающего есть scope для каждой команды. Неизвестный метод даётINVALID_METHOD, нехватка прав даётINSUFFICIENT_SCOPE.- Запись ложится в
b_rest_deferred_batchсо статусомpending, а в очередь Messengerrest.deferred_batchуходит сообщение с её ID. - Обработчик очереди забирает запись атомарным
UPDATEи выполняет batch. Если воркер умер, запись через 600 секунд можно забрать снова. - Результат сохраняется gzip-JSON-файлом в
b_file, статус становитсяdoneилиerror. downloadResultотдаёт подписанную ссылку на скачивание. Скачать может только автор и только в статусеdone.
Очередь описана в .settings.php модуля: limit 5, total_processing_limit 20, три повтора с задержкой от 30 до 300 секунд. Агент Cleanup раз в сутки удаляет записи done и error старше 30 дней вместе с файлами.
С лимитом выборки похоже на баг. Docblock DeferredModeAwareInterface и DeferredRestApiServer обещают, что в отложенном режиме OrmRepository не применит лимит по умолчанию (PaginationStructure::DEFAULT_LIMIT, в docblock это 50 записей) и списочный метод вернёт всё. Код этого не делает. setDeferredMode(true) вызывается только из OrmActionTrait::getOrmRepositoryByRequest() и только если isOrmDeferredMode() вернул true. Но трейт сам объявляет isOrmDeferredMode() с return false, а метод трейта перекрывает унаследованный RestController::isOrmDeferredMode(), который как раз проверяет сервер. В контроллерах на OrmActionTrait лимит внутри отложенного batch остаётся. Если вы собирались выгрузить так весь список без пагинации, проверьте результат. По коду вы получите только первую страницу.
До обработки в колонке REQUEST_PARAMS лежат auth-параметры исходного запроса (array_merge($queryParams, $this->getServer()->getAuth())). Их обнуляют после выполнения, в коде так и написано: «so secrets do not linger in DB». Записи, которые застряли в pending, агент очистки не трогает. Если очередь на портале не разбирается, они там и останутся вместе с авторизацией. Проверить можно так:
<?php declare(strict_types=1);
use Bitrix\Main\Loader;
use Bitrix\Main\Type\DateTime;
use Bitrix\Rest\Internal\Entity\DeferredBatch\Status;
use Bitrix\Rest\Internal\Model\DeferredBatchTable;
Loader::requireModule('rest');
$stuck = DeferredBatchTable::query()
->setSelect(['ID', 'USER_ID', 'CREATED_AT'])
->where('STATUS', Status::Pending->value)
->where('CREATED_AT', '<', (new DateTime())->add('-1 day'))
->fetchAll();
Классы лежат в Internal, их контракт могут поменять в любом релизе, так что держите такой запрос в диагностических скриптах.
Idempotency-Key: повторный запрос не создаст вторую запись
Клиент V3 может прислать заголовок Idempotency-Key со строкой из печатных ASCII-символов без пробелов длиной до 255, например с UUID. Если запрос с тем же ключом и тем же телом придёт повторно, действие не выполнится. Сервер вернёт сохранённый ответ и заголовок Idempotent-Replayed: true. Тот же ключ с другим телом даст ошибку 422 Unprocessable Entity, кривое значение заголовка даст 400.
Подробности, которые влияют на клиента:
- ключ хранится вместе с приложением, пользователем и методом, так что одинаковый ключ в разных методах не конфликтует;
- тело сравнивается по sha256 сырого входа, поэтому повтор нужно отправлять теми же байтами: JSON, пересобранный с другим порядком ключей, получит 422;
- сохраняются только ответы со статусом 200, ошибку при повторе выполнят заново;
- запись живёт сутки (86400 с), если не задано иное.
Если клиент прислал заголовок, механизм включается сам для всех действий с параметром AddRequest, UpdateRequest или DeleteRequest, в том числе в ваших контроллерах. Управлять можно атрибутами, NotIdempotent сильнее. Ниже набросок, тела методов заменены заглушками:
<?php declare(strict_types=1);
namespace Vendor\Shop\Rest\Controller;
use Bitrix\Rest\V3\Attribute\DtoType;
use Bitrix\Rest\V3\Attribute\Idempotent;
use Bitrix\Rest\V3\Attribute\NotIdempotent;
use Bitrix\Rest\V3\Controller\RestController;
use Bitrix\Rest\V3\Interaction\Request\AddRequest;
use Bitrix\Rest\V3\Interaction\Request\UpdateRequest;
use Bitrix\Rest\V3\Interaction\Response\AddResponse;
use Bitrix\Rest\V3\Interaction\Response\UpdateResponse;
use Vendor\Shop\Rest\Dto\OrderDto;
#[DtoType(OrderDto::class)]
final class Order extends RestController
{
#[Idempotent(ttl: 3600)]
public function addAction(AddRequest $request): AddResponse
{
// создаём заказ; повтор с тем же ключом в течение часа вернёт этот же ответ
throw new \LogicException('Not implemented');
}
#[NotIdempotent]
public function updateAction(UpdateRequest $request): UpdateResponse
{
// тут повторное применение безопасно, кеш ответа не нужен
throw new \LogicException('Not implemented');
}
}
Работает это только на V3-транспорте, AJAX-вызовы и легаси CRestServer фильтр пропускает. Ответы хранятся в PersistentStorageInterface из main. Если хранилище упало, ошибка уйдёт в лог, а действие выполнится без идемпотентности.
Разработчики признают в комментарии, что у хранилища нет атомарного setIfNotExists и два одновременных запроса с одним ключом выполнят действие оба («The second save() wins»). Повтор отсекается, только если первый запрос успел завершиться и сохранить ответ до прихода второго.
DynamicEnum: перечисление, значения которого живут в базе
Для DTO V3 появился атрибут #[DynamicEnum]. Он нужен, когда у поля конечный набор значений, но зашить его в BackedEnum нельзя, потому что набор хранится в базе. Провайдер реализует DynamicEnumProvider и возвращает строки или целые числа:
<?php declare(strict_types=1);
namespace Vendor\Shop\Rest\Enum;
use Bitrix\Main\Loader;
use Bitrix\Rest\V3\Dto\DynamicEnum\DynamicEnumProvider;
use Bitrix\Rest\V3\Dto\DynamicEnum\DynamicEnumType;
use Bitrix\Sale\Internals\StatusTable;
final class OrderStatusEnum implements DynamicEnumProvider
{
public function getType(): DynamicEnumType
{
return DynamicEnumType::String;
}
public function getValues(): array
{
Loader::requireModule('sale');
return array_column(
StatusTable::query()->setSelect(['ID'])->where('TYPE', 'O')->fetchAll(),
'ID',
);
}
}
<?php declare(strict_types=1);
namespace Vendor\Shop\Rest\Dto;
use Bitrix\Rest\V3\Attribute\DynamicEnum;
use Bitrix\Rest\V3\Dto\Dto;
use Vendor\Shop\Rest\Enum\OrderStatusEnum;
final class OrderDto extends Dto
{
public ?int $id;
#[DynamicEnum(OrderStatusEnum::class)]
public string $statusId;
}
Значения проверяются в fields, в filter и в курсоре, а в OpenAPI поле описывается как enum. Ограничения такие:
- PHP-тип свойства должен совпадать с типом перечисления (
stringдляString,intдляInt), совмещать атрибут сBackedEnumнельзя, иначеSystemExceptionпри разборе DTO; - провайдер создаётся через
new $providerClass(), так что конструктор должен обходиться без аргументов; - значения кешируются в managed cache и в статическом массиве процесса.
Когда набор значений изменился, сбросьте кеш:
\Bitrix\Rest\V3\Dto\DynamicEnum\DynamicEnumRegistry::clear(OrderStatusEnum::class);
clear() заодно увеличивает счётчик поколения, и закешированная OpenAPI-документация перегенерируется. Статический кеш он чистит только в текущем процессе, долгоживущий воркер будет помнить старые значения до перезапуска.
Таблица b_rest_deferred_batch
db.diff пуст, но в install/migrations/tables.php появилась таблица b_rest_deferred_batch: USER_ID, STATUS (по умолчанию pending), REQUEST_PARAMS, COMMANDS, SCOPES, RESULT_FILE_ID, ERROR_MESSAGE, CREATED_AT, UPDATED_AT. Индексы построены по (USER_ID, ID) и по (STATUS, CREATED_AT, ID). В install/migrations/agents.php добавлен агент очистки. Скрипта обновления в отчёте нет, поэтому по диффу не видно, как таблица и агент появятся на уже установленном портале. После обновления проверьте это сами.
Мелочи и находки
- В коде есть ссылки на внутренние проектные документы: «ALG-SCOPE-CHECK from SDD-backend-deferred-batch-2026-05-26.md», «spec design doc, Resolution 0.1», «design doc "Trade-off: accepted race window"».
- Docblock
IdempotencyKeyResolverописывает префиксrest.idempotency., а в константеrest.v3.idempotency.. Длина «prefix 20» при этом посчитана по настоящей константе. - Docblock
DeferredRestApiServerобещает настроить пользователя и scope из сохранённых данных. В классе при этом толькоisDeferredMode()и пустойinitRequestScope()плюс пять неиспользуемых импортов. Scope выставляет обработчик очереди черезsetAvailableScopes(), он же пересоздаётglobal $USERкакnew \CUser(). DeferredBatch::deleteAction()сначала удаляет запись, потом файл. Если файл не удалился, клиент получитFILE_DELETE_FAILED, хотя записи уже нет.- Исправлен
DeactivateSystemUserCommandHandler: он искал приложение черезAppTable::getByClientId(), передавая туда числовой ID. ТеперьgetById(), а у ошибки появился кодREST_APPLICATION_NOT_FOUND. - В гриде интеграций кнопку удаления показывали, если ID пользователя совпадал с ID интеграции. Теперь сравнивают с
USER_IDвладельца. Заодно появились подтверждение удаления и сортировка через?by=&order=с белым списком полей. - Степпер
Bitrix\Rest\Update\DeleteApplicationSystemUsers, который удалял или деактивировал системных пользователей приложений, выпотрошен,execute()сразу возвращаетFINISH_EXECUTION. - Ветка в
AppTable, которая срабатывает при включённой опцииallow_create_sys_user, теперь пропускает локальные приложения. Тело условия в диффе не видно, по имени опции это создание системного пользователя. - Новая команда
DeactivateIntegrationsCommandв одной транзакции гасит приложение и вебхуки системного пользователя. На любую ошибку, включая сбой БД, она отвечает текстом «Unknown system user integration type». - Вебхук из hold-списка перегрузки теперь отсекается до запроса в БД, а пароль не строкой сразу даёт
INVALID_CREDENTIALS. - Если авторизация ответила «Https required.», V3 вернёт отдельную
HttpsRequiredException(400) вместо «доступ запрещён». - В настройках модуля появилась политика
rest_incoming_webhook_create_rightsрядом с прежнейrest_incoming_webhook_create_own_rights. - В мобильном меню пунктов маркета больше нет у экстранета и коллаберов.
ControllerData::fromArray()читал ключdto, теперьdtoFqcn. ВSchemaManagerDTO восстанавливаются только для найденного модуля, в комментарии причина:getMethodDescriptions()«is reachable before auth».
Что делать
- Найдите в своём коде
new AddEmbeddingCommand(,new DeleteEmbeddingCommand(и вызовыEmbeddingUninstaller::uninstall()с именованными аргументами и переведите их наuserId/targetUserId. - Проверьте наследников
RestController: тип$responseLanguageиgetResponseLanguage(), вызовparent::вgetDefaultPreFilters()иgetDefaultPostFilters(), DTO с вложенными объектами без атрибутов связи. - Если у ваших V3-методов есть действия, которые нельзя повторять по сохранённому ответу, пометьте их
#[NotIdempotent]. - Если приложение вызывает
placement.*от имени обычного пользователя, проверьте, что оно передаётUSER_IDтолько свой или общий. - После обновления проверьте, что таблица
b_rest_deferred_batchи агентCleanupна месте и что очередьrest.deferred_batchразбирается.