Разобраться, какие в облаке есть сервисы, — половина дела. Вторая половина — обратиться к ним из кода так, чтобы это было безопасно (без секретов в репозитории) и тестируемо (без реального облака в тестах). Разберём это на четырёх стеках, Spring Boot, Go, Node и Python: переключатель языка над примерами. Прежде чем читать, полезно понимать модель доступа из статьи про Entra ID и RBAC — весь фокус тут в том, откуда приложение берёт права.

переменные окружения AZURE_CLIENT_ID и секрет managed identity токен от платформы вход разработчика az login на ноутбуке

DefaultAzureCredential идёт по цепочке и берёт первый найденный источник: в облаке это удостоверение платформы, на ноутбуке вход через командную строку, а забытая переменная окружения перебивает и то и другое. В боевом контуре источник называют явно, чтобы не платить за перебор.

Обязательно

Azure SDK и надстройки фреймворков

К сервисам Azure обращаются через официальный SDK своего языка; в каждом из них клиент собирается один раз на приложение и получает адрес сервиса из настроек, а учётные данные из единой цепочки DefaultAzureCredential.

В Java это Azure SDK for Java, а в Spring-приложениях удобнее набор стартеров Spring Cloud Azure: он подключает Blob Storage, Service Bus, Key Vault и другие сервисы через конфигурацию и внедрение зависимостей.

@Bean
BlobServiceClient blobServiceClient(@Value("${app.storage.endpoint}") String endpoint) {
    return new BlobServiceClientBuilder()
        .endpoint(endpoint)
        .credential(new DefaultAzureCredentialBuilder().build())
        .buildClient();
}

Стартеры Spring Cloud Azure делают ровно это за вас: достаточно указать spring.cloud.azure.storage.blob.endpoint в конфигурации, и готовый клиент приезжает бином.

В Go это модули github.com/Azure/azure-sdk-for-go/sdk/...: azidentity для учётных данных и по пакету на сервис (azblob, azservicebus, azsecrets). Стартеров нет, клиента собирают в main и передают дальше зависимостью:

func newBlobClient(cfg Config) (*azblob.Client, error) {
	cred, err := azidentity.NewDefaultAzureCredential(nil)
	if err != nil {
		return nil, err
	}
	return azblob.NewClient(cfg.StorageEndpoint, cred, nil)
}

В Node это пакеты @azure/identity и по пакету на сервис (@azure/storage-blob, @azure/service-bus, @azure/keyvault-secrets); клиента создают один раз при старте:

const { DefaultAzureCredential } = require('@azure/identity');
const { BlobServiceClient } = require('@azure/storage-blob');

function blobServiceClient(config) {
  return new BlobServiceClient(config.storageEndpoint, new DefaultAzureCredential());
}

В Python это azure-identity и по пакету на сервис (azure-storage-blob, azure-servicebus, azure-keyvault-secrets); клиента создают один раз при старте:

from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient


def blob_service_client(settings: Settings) -> BlobServiceClient:
    return BlobServiceClient(settings.storage_endpoint, credential=DefaultAzureCredential())

Обратите внимание: адрес хранилища приходит из настроек, а не вписан в код. В тестах туда подставляют локальный эмулятор, в бою — настоящий адрес, и пересобирать ничего не надо.

Управляемые PostgreSQL и Redis вообще не требуют «облачного» SDK: к ним подключаются стандартными драйверами (pgx или JDBC для PostgreSQL, go-redis, Lettuce, ioredis или redis-py для Redis) по адресу managed-кластера — как к любой базе. Пароль при этом можно не заводить вовсе: и Flexible Server, и управляемый Redis умеют аутентификацию через Entra ID, где вместо пароля подставляется временный токен вашей managed identity. Настраивается это один раз, зато из системы уходит ещё один вечный секрет.

DefaultAzureCredential: откуда берутся права

Ключ, положенный в настройки «на время отладки», уезжает в git и живёт там годами — так утекает большинство учётных данных. Поэтому принцип один: никаких секретов в коде, а за получение доступа отвечает DefaultAzureCredential. Важно понимать, что она не «подстраивается под обстановку», а перебирает источники в жёстко заданном порядке и берёт первый, который вообще выдал токен:

  • переменные окружения — AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, если они заданы;
  • managed identity платформы — виртуальной машины, Container Apps, AKS: платформа выдаёт временный токен, в коде ни пароля, ни ключа;
  • учётка разработчика — та, под которой вы вошли через Azure CLI (az login).

Порядок здесь и есть источник самой популярной отладочной загадки. Переменные окружения стоят первыми, поэтому забытые в окружении AZURE_CLIENT_ID и AZURE_CLIENT_SECRET перебивают managed identity, и приложение в облаке ходит в сервисы под чужой учётной записью. Если «в облаке почему-то не те права» — первым делом смотрите, что лежит в переменных окружения.

Один и тот же код работает и локально, и в облаке — меняется только источник учётных данных. Это избавляет от соблазна захардкодить ключ «на время отладки».

Отдельно про конвейер сборки вне Azure. Напрашивается service principal с секретом в хранилище конвейера, но это ровно тот вечный секрет, от которого стоит уходить. У Azure для этого есть федеративные учётные данные (workload identity federation): конвейер предъявляет свой собственный токен, Entra ID меняет его на короткоживущий доступ, и хранить в настройках конвейера нечего. Тот же приём, что OIDC-федерация в AWS, — о ней рассказывает статья про состояние и доставку.

Права на управление и права на данные — разные

Самая частая ошибка доступа в Azure выглядит так: роль выдали, приложение всё равно получает отказ. Причина почти всегда одна.

Роли уровня управления (Reader, Contributor, Owner) дают права на ресурс как объект: увидеть его в списке, изменить настройки, удалить. Они не дают права на данные внутри.

Роли уровня данных — отдельный набор, и именно они нужны приложению:

Что нужно приложениюКакая роль
Читать файлы из хранилищаStorage Blob Data Reader
Писать файлыStorage Blob Data Contributor
Читать секреты из хранилища секретовKey Vault Secrets User
Читать сообщения из очереди служебной шиныAzure Service Bus Data Receiver
Отправлять сообщенияAzure Service Bus Data Sender

То есть Contributor на учётную запись хранения позволяет её настроить и удалить, но не прочитать из неё файл. И наоборот: Storage Blob Data Reader позволяет читать файлы и не позволяет увидеть учётную запись в списке ресурсов.

Отсюда практическое правило: приложению выдают роли данных, и только их, на конкретный ресурс (а не на группу ресурсов или подписку). Права управления приложению обычно не нужны вовсе — если они понадобились, это повод спросить, зачем приложение меняет инфраструктуру.

Проверять удобно так: az role assignment list --assignee <id-удостоверения> --all -o table и глазами посмотреть, есть ли в списке роль со словом Data. Если нет — отказ будет, сколько бы Contributor ни было выдано.

Надстройки фреймворков: настройки вместо кода

Надстройка над библиотеками называется Spring Cloud Azure, и её польза в том, что клиенты и учётные данные собираются из настроек, а не из кода.

spring:
  cloud:
    azure:
      credential:
        managed-identity-enabled: true
        client-id: ${AZURE_CLIENT_ID}          # для удостоверения, назначенного пользователем
      profile:
        tenant-id: ${AZURE_TENANT_ID}
      storage:
        blob:
          account-name: stordersprod
      servicebus:
        namespace: sb-orders-prod
        entity-type: queue
      keyvault:
        secret:
          property-sources:
            - endpoint: https://kv-orders-prod.vault.azure.net/

Что это даёт:

Готовые бины клиентов. BlobServiceClient, клиент служебной шины, клиент хранилища секретов создаются автоматически и внедряются как обычные зависимости — без сборщиков в коде.

Хранилище секретов как источник свойств. Последний блок в примере подключает секреты как обычные настройки Spring: значение db-password из хранилища доступно как ${db-password} в любом месте конфигурации, включая пароль к базе. Никаких обращений к хранилищу в коде.

Слушатель очереди в привычном виде. Есть поддержка обработчиков сообщений служебной шины через привычные аннотации, то есть работа с очередью выглядит как работа с любым другим брокером.

Три вещи, о которых стоит знать заранее. Версия надстройки должна соответствовать версии вашего фреймворка — таблица соответствия есть в документации, и несовпадение проявляется как «свойства не подхватываются». Секреты из хранилища читаются при старте, то есть смена секрета требует перезапуска (или отдельного механизма обновления настроек) — та же оговорка, что и в других облаках. И настройки перекрывают друг друга обычным способом Spring: файл, переменные среды, аргументы — поэтому в боевом контуре значения приходят из окружения, а в файле остаются только имена и адреса.

В Go надстройки над SDK нет, и «настройки вместо кода» делают руками: клиенты собирают в main из конфигурации, которую читают из переменных окружения (envconfig, caarlos0/env или viper), учётные данные один DefaultAzureCredential на процесс, а секреты из Key Vault читают при старте клиентом azsecrets и кладут в ту же структуру конфигурации.

type Config struct {
	StorageEndpoint  string `env:"APP_STORAGE_ENDPOINT,required"`
	ServiceBusNS     string `env:"APP_SERVICEBUS_NAMESPACE,required"`
	KeyVaultEndpoint string `env:"APP_KEYVAULT_ENDPOINT,required"`
}

func loadSecrets(ctx context.Context, cfg Config, cred azcore.TokenCredential) (dbPassword string, err error) {
	client, err := azsecrets.NewClient(cfg.KeyVaultEndpoint, cred, nil)
	if err != nil {
		return "", err
	}
	resp, err := client.GetSecret(ctx, "db-password", "", nil)
	if err != nil {
		return "", err
	}
	return *resp.Value, nil
}

Секреты читаются при старте, то есть смена секрета требует перезапуска или своего механизма обновления — та же оговорка, что и в других облаках. Слушатель очереди служебной шины это цикл receiver.ReceiveMessages из azservicebus в отдельной горутине с явным CompleteMessage после обработки.

В Node стартеров нет, и «настройки вместо кода» это конфигурация из переменных окружения (process.env через dotenv или convict), один DefaultAzureCredential на процесс и чтение секретов из Key Vault при старте.

const { DefaultAzureCredential } = require('@azure/identity');
const { SecretClient } = require('@azure/keyvault-secrets');

const credential = new DefaultAzureCredential();

async function loadConfig() {
  const secrets = new SecretClient(process.env.APP_KEYVAULT_ENDPOINT, credential);
  return {
    storageEndpoint: process.env.APP_STORAGE_ENDPOINT,
    serviceBusNamespace: process.env.APP_SERVICEBUS_NAMESPACE,
    dbPassword: (await secrets.getSecret('db-password')).value,
  };
}

Секреты читаются при старте, смена требует перезапуска или своего механизма обновления. Слушатель очереди служебной шины это receiver.subscribe({ processMessage, processError }) из @azure/service-bus; сообщение подтверждают completeMessage только после успешной обработки.

В Python роль надстройки играет конфигурация через pydantic-settings: значения приходят из переменных окружения, один DefaultAzureCredential на процесс, секреты из Key Vault дочитывают при старте.

from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_storage_endpoint: str
    app_servicebus_namespace: str
    app_keyvault_endpoint: str


settings = Settings()
credential = DefaultAzureCredential()
db_password = SecretClient(settings.app_keyvault_endpoint, credential).get_secret("db-password").value

Секреты читаются при старте, смена требует перезапуска или своего механизма обновления. Слушатель очереди служебной шины это receiver.receive_messages() из azure-servicebus в цикле с complete_message после успешной обработки.

Тесты и локальная разработка

Два разных вопроса, которые часто путают: «как тестировать без облака» и «как разрабатывать, когда доступа в облако нет вовсе».

Эмулятор хранилища в тестах

Для хранилища есть эмулятор — Azurite. Он поднимается контейнером и понимает тот же интерфейс, что настоящее хранилище: блобы, очереди, таблицы.

@Testcontainers
class BlobStorageTest {

    @Container
    static final GenericContainer<?> AZURITE =
            new GenericContainer<>(DockerImageName.parse("mcr.microsoft.com/azure-storage/azurite:latest"))
                    .withExposedPorts(10000);

    static BlobServiceClient client;

    @BeforeAll
    static void setUp() {
        String endpoint = "http://%s:%d/devstoreaccount1"
                .formatted(AZURITE.getHost(), AZURITE.getMappedPort(10000));
        client = new BlobServiceClientBuilder()
                .endpoint(endpoint)
                .connectionString(AZURITE_CONNECTION_STRING)   // у эмулятора фиксированные учётные данные
                .buildClient();
    }

    @Test
    void сохраняетИЧитает() {
        var container = client.createBlobContainer("orders");
        container.getBlobClient("a.txt").upload(BinaryData.fromString("hello"));
        assertThat(container.getBlobClient("a.txt").downloadContent().toString()).isEqualTo("hello");
    }
}
func TestBlobStorage(t *testing.T) {
	ctx := context.Background()
	azuriteC, err := azurite.Run(ctx, "mcr.microsoft.com/azure-storage/azurite:latest")
	testcontainers.CleanupContainer(t, azuriteC)
	if err != nil {
		t.Fatal(err)
	}
	endpoint, err := azuriteC.BlobServiceURL(ctx)
	if err != nil {
		t.Fatal(err)
	}
	cred, _ := azblob.NewSharedKeyCredential(azurite.AccountName, azurite.AccountKey) // у эмулятора фиксированные учётные данные
	client, err := azblob.NewClientWithSharedKeyCredential(endpoint+"/"+azurite.AccountName, cred, nil)
	if err != nil {
		t.Fatal(err)
	}

	_, err = client.CreateContainer(ctx, "orders", nil)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := client.UploadBuffer(ctx, "orders", "a.txt", []byte("hello"), nil); err != nil {
		t.Fatal(err)
	}
	resp, err := client.DownloadStream(ctx, "orders", "a.txt", nil)
	if err != nil {
		t.Fatal(err)
	}
	body, _ := io.ReadAll(resp.Body)
	if string(body) != "hello" {
		t.Fatalf("прочитали %q", body)
	}
}
const { AzuriteContainer } = require('@testcontainers/azurite');
const { BlobServiceClient } = require('@azure/storage-blob');

let azurite, client;

beforeAll(async () => {
  azurite = await new AzuriteContainer('mcr.microsoft.com/azure-storage/azurite:latest').start();
  client = BlobServiceClient.fromConnectionString(azurite.getConnectionString()); // у эмулятора фиксированные учётные данные
});
afterAll(() => azurite.stop());

test('сохраняет и читает', async () => {
  const container = client.getContainerClient('orders');
  await container.create();
  await container.getBlockBlobClient('a.txt').upload('hello', 5);
  const downloaded = await container.getBlobClient('a.txt').downloadToBuffer();
  expect(downloaded.toString()).toBe('hello');
});
from azure.storage.blob import BlobServiceClient
from testcontainers.azurite import AzuriteContainer


def test_saves_and_reads():
    with AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:latest") as azurite:
        client = BlobServiceClient.from_connection_string(azurite.get_connection_string())  # фиксированные учётные данные эмулятора
        container = client.create_container("orders")
        container.upload_blob("a.txt", b"hello")
        assert container.download_blob("a.txt").readall() == b"hello"

Ключевой момент — переключение адреса: в тесте клиент собирается с адресом эмулятора вместо настоящего, и это единственное отличие от боевой конфигурации. Поэтому адрес держат в настройках, а не в коде: тогда тот же код работает и с эмулятором, и с облаком.

У эмулятора фиксированные учётные данные (они одни и те же у всех, это часть его устройства), и это нормально: он не предназначен для чего-либо, кроме тестов.

Чего у эмулятора нет: управляемых удостоверений и ролей (проверка прав в тестах не проверяется), части новых возможностей, точного поведения под нагрузкой. Поэтому им проверяют логику работы с хранилищем, а не настройку доступа.

Для служебной шины полноценного эмулятора долго не было; сейчас есть образ для локального запуска с ограничениями, и часть команд по-прежнему обходится либо подменой интерфейса в коде, либо отдельным пространством имён в облаке под тесты.

Где это применяется

Эта связка — как приложение аутентифицируется через managed identity и ходит в облачные сервисы через SDK — стоит за любым боевым сервисом в Azure. DefaultAzureCredential делает так, что один код работает в любом окружении, а секреты не оседают в репозитории.

Типичные ошибки новичков:

  • кладут строку подключения с ключом в git или в код — классическая утечка;
  • жёстко прописывают учётные данные вместо DefaultAzureCredential — код не переносится между окружениями;
  • тестируют против реального облака вместо Azurite и Testcontainers — тесты медленные и хрупкие;
  • дублируют доступ через ключи там, где хватило бы managed identity.
Дополнительно: при первом чтении можно пропустить

Глубже: что делает перебор источников и почему в проде его сужаютрасширенное

Единая цепочка учётных данных удобна тем, что код одинаков везде. Цена этого удобства — перебор: при первом обращении библиотека по очереди пробует источники, и каждая неудачная попытка это время, а иногда и сетевой запрос.

Порядок примерно такой: переменные окружения → управляемое удостоверение → вход через инструмент разработчика (командная строка Azure, среда разработки) → и так далее. В облаке срабатывает второй пункт, на ноутбуке — третий, и всё, что стоит до сработавшего, отнимает время.

Что из этого следует.

Первый вызов медленнее. Обычно это десятки-сотни миллисекунд, и на старте приложения это незаметно. Проблема появляется там, где источник подвисает: в отдельных окружениях попытка обратиться к службе управляемых удостоверений уходит в таймаут, и запуск приложения затягивается на секунды или десятки секунд. Классика — контейнер вне Azure (на своём железе, в другом облаке, в среде сборки), где такой службы нет.

В боевом контуре источник указывают явно. Если вы знаете, что работаете с управляемым удостоверением, берите именно его — без перебора:

var credential = new ManagedIdentityCredentialBuilder()
        .clientId(System.getenv("AZURE_CLIENT_ID"))   // для удостоверения, назначенного пользователем
        .build();
cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{
	ID: azidentity.ClientID(os.Getenv("AZURE_CLIENT_ID")), // для удостоверения, назначенного пользователем
})
const credential = new ManagedIdentityCredential({ clientId: process.env.AZURE_CLIENT_ID }); // для удостоверения, назначенного пользователем
credential = ManagedIdentityCredential(client_id=os.environ["AZURE_CLIENT_ID"])  # для удостоверения, назначенного пользователем

Токен кэшируется. Один раз полученный токен живёт до истечения срока и обновляется сам, поэтому перебор случается один раз за жизнь процесса — а вот объект учётных данных надо создавать один раз и переиспользовать, а не создавать на каждый вызов.

Часть источников можно отключить. У сборщика цепочки есть флаги, исключающие лишние источники, — это компромисс между единым кодом и предсказуемостью.

Практическая схема, которой пользуются: в локальной разработке — цепочка (работает вход через командную строку), в боевом контуре — явное управляемое удостоверение, выбор между ними по переменной окружения или по профилю приложения.

Глубже: повторы, таймауты и ограничение частотырасширенное

Облачные библиотеки Azure сами повторяют неудачные запросы — и об этом надо знать, чтобы не построить повтор поверх повтора.

Что повторяется само. Ошибки сети, 408, 429 (ограничение частоты) и 5xx. По умолчанию это несколько попыток с нарастающей паузой. Причём для 429 библиотека уважает заголовок с указанием, сколько ждать, — то есть ведёт себя правильнее, чем самописный повтор.

Что не повторяется. Ошибки клиента (400, 403, 404, 409, 412) — повторять их бессмысленно, и это правильно.

Как настроить.

var options = new BlobClientOptions()
        .setRetryOptions(new RequestRetryOptions(
                RetryPolicyType.EXPONENTIAL, 4,        // четыре попытки
                Duration.ofSeconds(10),                // предел на попытку
                Duration.ofMillis(200), Duration.ofSeconds(4), null));
client, err := azblob.NewClient(endpoint, cred, &azblob.ClientOptions{
	ClientOptions: azcore.ClientOptions{
		Retry: policy.RetryOptions{
			MaxRetries:    4,                      // четыре попытки
			TryTimeout:    10 * time.Second,       // предел на попытку
			RetryDelay:    200 * time.Millisecond,
			MaxRetryDelay: 4 * time.Second,
		},
	},
})
const client = new BlobServiceClient(endpoint, credential, {
  retryOptions: {
    maxTries: 4,               // четыре попытки
    tryTimeoutInMs: 10_000,    // предел на попытку
    retryDelayInMs: 200,
    maxRetryDelayInMs: 4_000,
  },
});
client = BlobServiceClient(
    endpoint,
    credential=credential,
    retry_total=4,             # четыре попытки
    connection_timeout=10,     # предел на попытку
    retry_backoff_factor=0.2,
    retry_backoff_max=4,
)

Три правила, которые стоит соблюдать:

Свой повтор сверху не добавляют. Четыре попытки библиотеки плюс три ваших — это двенадцать запросов на одну операцию, и при сбое сервиса это превращается в лавину. Настраивают одно место: либо библиотеку, либо свой механизм с отключёнными встроенными повторами.

Таймаут на попытку обязателен. Без него один зависший запрос держит поток столько, сколько позволит сеть. Предел на попытку плюс число попыток дают предсказуемое худшее время операции — а это то, что нужно для собственных таймаутов и бюджетов.

Ограничение частоты надо видеть. Ответы 429 не видны в приложении (библиотека их проглатывает), но видны как рост задержки. Поэтому включают журналирование библиотеки на уровне предупреждений и смотрят метрики самого сервиса: если 429 много, лечится это не повторами, а поднятием ёмкости или снижением частоты обращений.

Гонять тесты против реального облака — медленно, дорого и требует доступа. Его подменяют локально:

  • для Blob Storage, Storage Queues и таблиц поднимают Azurite — официальный локальный эмулятор хранилища — в контейнере через Testcontainers; клиент переключается на него сменой endpoint. Service Bus Azurite не эмулирует: под него у Microsoft есть отдельный эмулятор, тоже запускаемый контейнером;
  • для PostgreSQL, Redis используют те же Testcontainers с обычными образами — managed-сервис в тестах не нужен, драйвер один и тот же.

Так интеграционные тесты идут быстро и не зависят от сети и облака. Общие подходы к тестированию — в разделе тестирования.

Глубже: если доступа в Azure нет вовсерасширенное

Вход через командную строку требует учётной записи в каталоге организации, а она есть не у всех — например, у подрядчика или у нового сотрудника в первый день. Что делать, чтобы разработка не останавливалась:

Подменять на границе. Весь доступ к облачным сервисам держат за своими интерфейсами (FileStorage, EventPublisher), а в локальном профиле подставляют реализации, которые пишут в локальный каталог и в память. Это самый надёжный вариант: приложение запускается вообще без облака и без контейнеров.

Эмуляторы там, где они есть. Хранилище — эмулятор, база — обычный PostgreSQL в контейнере, кэш — обычный Redis в контейнере. Для большинства приложений это закрывает всё, кроме специфичных сервисов.

Отдельная песочница вместо боевого каталога. Если облако всё-таки нужно, правильный ответ — не выдавать доступ в общий контур, а завести отдельную подписку-песочницу с ограниченным бюджетом и своими учётными записями. Это работа платформенной команды, но просить стоит именно это, а не доступ «посмотреть».

И то, чего делать не стоит: раздавать строки подключения с ключами доступа «на время разработки». Такой ключ живёт вечно, уезжает в историю команд и в чаты, и отзывать его придётся вместе со сменой во всех приложениях.

Коротко

  • Клиент сервиса собирают один раз на приложение, адрес берут из настроек, учётные данные из DefaultAzureCredential; в Spring это стартеры Spring Cloud Azure, в Go, Node и Python по пакету на сервис.
  • К управляемым PostgreSQL и Redis подключаются обычными драйверами по адресу, облачный SDK им не нужен.
  • Цепочка учётных данных пробует переменные окружения, managed identity и вход разработчика по порядку: забытые AZURE_CLIENT_ID и секрет в окружении перебивают удостоверение платформы; конвейеру вне облака делают федерацию, а не секрет.
  • Перебор стоит времени и иногда зависает: в боевом контуре источник указывают явно, объект учётных данных создают один раз, токен кэшируется.
  • Роли управления не дают прав на данные: приложению выдают роли данных на конкретный ресурс, а проверяют список назначений по слову Data.
  • Библиотеки повторяют сеть, 408, 429 и 5xx сами и уважают заголовок задержки: свой повтор сверху не добавляют, таймаут на попытку обязателен, 429 видят по логам библиотеки и росту задержки.
  • Надстройки фреймворка подключают секреты как свойства и слушателей очередей как обработчики, но секреты читаются при старте и требуют перезапуска при смене.
  • Тесты идут на Azurite и Testcontainers через смену адреса; эмулятор не проверяет права и не воспроизводит нагрузку. Без доступа в облако подменяют на границе интерфейсами и берут песочницу, а не строку с ключом.

Что почитать дальше