Zeh Ronaldo – Zehro Digital https://zehro.digital Tecnologia, Ferramentas e Produtividade Thu, 10 Sep 2026 18:04:10 +0000 pt-BR hourly 1 https://wordpress.org/?v=7.1 Kafka com Python: Pipeline de Ingestão com Validação Pydantic e Dead Letter Queue (DLQ) https://zehro.digital/kafka-python-pydantic-dead-letter-queue/ Wed, 09 Sep 2026 00:04:56 +0000 https://zehro.digital/kafka-python-pydantic-dead-letter-queue/ Ilustração técnica conceitual: arquitetura de mensageria com Kafka e Python

Na construção de pipelines de dados orientados a eventos, um dos maiores desafios operacionais é lidar com mensagens malformadas ou incompatíveis com o esquema esperado (as chamadas poison pills). Se o consumidor simplesmente ignorar o erro, dados valiosos são perdidos sem rastreabilidade. Se o consumidor travar, toda a partição fica bloqueada, gerando um gargalo (lag) que degrada a saúde do sistema.

Este artigo apresenta uma abordagem prática para resolver esse problema utilizando o ecossistema Python com a biblioteca confluent-kafka e validação de esquema com Pydantic (v2). O objetivo é demonstrar a implementação de um fluxo de Dead Letter Queue (DLQ) com tratamento de erros categorizado em três camadas distintas, garantindo resiliência e consistência operacional.

Fluxograma de arquitetura vertical: Ingestão Kafka, validação Pydantic e roteamento condicional para DLQ
Topologia de ingestão com validação de esquema e commit condicionado à confirmação de entrega na DLQ.

Semântica At-Least-Once e o Mito da Atomicidade

Antes de prosseguirmos para o código, é crucial estabelecer um princípio fundamental de sistemas distribuídos: o envio de uma mensagem para a DLQ e a confirmação (commit) do offset da mensagem original não constituem uma operação atômica.

Como o Kafka não suporta transações nativas entre leitura de um tópico e escrita em outro de forma distribuída sem o uso da API transacional específica (que exige configurações complexas de Transaction Coordinator), adotamos a semântica de entrega At-Least-Once (Pelo Menos Uma Vez).

Nesse modelo, o fluxo funciona sob o padrão de commit condicionado à confirmação de entrega na DLQ:
1. O consumidor identifica um payload inválido.
2. O consumidor envia o payload corrompido para o tópico de DLQ.
3. O consumidor aguarda a confirmação síncrona de recebimento do broker da DLQ (através de um callback de entrega e um comando flush).
4. Somente após a confirmação positiva da DLQ, o consumidor realiza o commit manual do offset da mensagem original no tópico de origem.

Se a aplicação falhar ou perder a conectividade com o cluster após a mensagem ser gravada na DLQ, mas antes que o commit do offset seja registrado no tópico interno __consumer_offsets, a mensagem original será relida na reinicialização da aplicação ou no rebalanceamento de partições. Consequentemente, a mensagem poderá ser duplicada na DLQ. Esse trade-off é aceitável e preferível à perda silenciosa de dados, exigindo apenas que os processos que consomem a DLQ sejam tolerantes a duplicatas (idempotentes).


Configuração do Ambiente Local

Para reproduzir o laboratório, utilizaremos o Docker Compose com a imagem do Apache Kafka mantida pela Bitnami, configurada em modo KRaft (Kafka Raft Metadata Mode), eliminando a necessidade de um container dedicado para o ZooKeeper.

Arquivo docker-compose.yml

Crie um diretório para o projeto e salve o seguinte conteúdo como docker-compose.yml:

version: '3.8'

services:
  kafka-kraft:
    image: docker.io/bitnami/kafka:3.7
    container_name: kafka-kraft
    ports:
      - "9092:9092"
    environment:
      - KAFKA_CFG_NODE_ID=0
      - KAFKA_CFG_PROCESS_ROLES=controller,broker
      - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka-kraft:9093
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER
      - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=PLAINTEXT
      - KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1
      - ALLOW_PLAINTEXT_LISTENER=yes
    volumes:
      - kafka_data:/bitnami/kafka

volumes:
  kafka_data:
    driver: local

Instalação das Dependências

Instale as bibliotecas necessárias no seu ambiente virtual Python. Certifique-se de utilizar as versões especificadas para evitar incompatibilidades de API:

pip install confluent-kafka==2.3.0 pydantic==2.6.4

Implementação do Pipeline de Ingestão

A implementação do consumidor exige uma arquitetura de código capaz de isolar e tratar erros com base em sua natureza. Dividimos as falhas em três camadas estritas:

  1. Camada 1: Defeito Intrínseco do Dado (Poison Pill): Erros de decodificação JSON (JSONDecodeError) ou falhas de validação de esquema (ValidationError do Pydantic). A mensagem é enviada à DLQ com metadados nos cabeçalhos (headers), seguida de flush() síncrono e commit manual do offset.
  2. Camada 2: Falha Transitória de Infraestrutura: Indisponibilidade de APIs externas, timeouts ou quedas de banco de dados downstream. O consumidor não deve enviar a mensagem para a DLQ, pois o dado é válido. A aplicação deve registrar o erro, interromper o consumo (levantar exceção) e encerrar sem realizar o commit, forçando o reprocessamento quando o serviço for restabelecido.
  3. Camada 3: Falha de Commit: Erros operacionais ao tentar registrar o offset no Kafka. Devem ser tratados de forma isolada, registrando logs de alerta sem reencaminhar mensagens já processadas para a DLQ.

Código do Consumidor (consumer.py)

Salve o código abaixo como consumer.py. Este script implementa o loop de consumo, a validação Pydantic e o roteamento condicional de erros.

import sys
import json
import logging
from typing import Dict, Any
from pydantic import BaseModel, Field, ValidationError
from confluent_kafka import Consumer, Producer, KafkaError, KafkaException

# Configuração de logs para visibilidade operacional
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("IngestionPipeline")

# --- Modelagem de Dados com Pydantic ---
class TelemetryPayload(BaseModel):
    device_id: str = Field(..., min_length=3, max_length=50)
    temperature: float = Field(..., ge=-50.0, le=100.0)
    timestamp: int = Field(..., description="Epoch timestamp em segundos")

# --- Exceção de Infraestrutura Simulada ---
class DatabaseConnectionError(Exception):
    """Simula uma falha de conexão com o banco de dados downstream."""
    pass

def save_to_database_mock(payload: TelemetryPayload) -> None:
    """
    Simula a persistência de dados. 
    Se o device_id for 'db-fail', simula uma falha de infraestrutura.
    """
    if payload.device_id == "db-fail":
        raise DatabaseConnectionError("Timeout ao conectar ao PostgreSQL principal.")
    logger.info(f"Sucesso: Payload do dispositivo {payload.device_id} persistido.")

# --- Callback de Confirmação da DLQ ---
class DLQDeliveryCallback:
    def __init__(self):
        self.success = False
        self.error_msg = None

    def __call__(self, err, msg):
        if err is not None:
            self.error_msg = str(err)
            self.success = False
            logger.error(f"Callback DLQ: Falha no envio: {err}")
        else:
            self.success = True
            logger.info(f"Callback DLQ: Mensagem entregue ao tópico {msg.topic()} na partição [{msg.partition()}]")

# --- Fluxo Principal do Pipeline ---
def run_pipeline():
    consumer_conf = {
        'bootstrap.servers': 'localhost:9092',
        'group.id': 'telemetry-ingestion-group',
        'auto.offset.reset': 'earliest',
        'enable.auto.commit': False  # Controle manual do ponteiro de leitura
    }

    producer_conf = {
        'bootstrap.servers': 'localhost:9092',
        'acks': 'all'  # Garante escrita em todas as réplicas em sincronia
    }

    consumer = Consumer(consumer_conf)
    dlq_producer = Producer(producer_conf)

    raw_topic = "telemetry-raw"
    dlq_topic = "telemetry-dlq"

    consumer.subscribe([raw_topic])
    logger.info(f"Consumidor iniciado e inscrito no tópico: {raw_topic}")

    try:
        while True:
            msg = consumer.poll(timeout=1.0)
            if msg is None:
                continue

            if msg.error():
                if msg.error().code() == KafkaError._PARTITION_EOF:
                    continue
                else:
                    raise KafkaException(msg.error())

            # Recuperação dos dados brutos
            raw_data = msg.value()
            logger.info("Mensagem recebida do offset %s: %r", msg.offset(), raw_data)

            try:
                # --- CAMADA 1: Validação e Tratamento de Poison Pills ---
                try:
                    if raw_data is None or len(raw_data) == 0:
                        raise ValueError('Payload nulo ou vazio')
                    decoded_text = raw_data.decode('utf-8')
                    parsed_json = json.loads(decoded_text)
                    telemetry = TelemetryPayload.model_validate(parsed_json)
                except (UnicodeDecodeError, json.JSONDecodeError, ValidationError, TypeError, ValueError) as validation_err:
                    logger.warning(f"Poison Pill detectada no offset {msg.offset()}. Erro: {validation_err}")

                    # Preparação dos cabeçalhos de erro para rastreabilidade na DLQ
                    headers = [
                        ("original-topic", msg.topic().encode('utf-8')),
                        ("original-partition", str(msg.partition()).encode('utf-8')),
                        ("original-offset", str(msg.offset()).encode('utf-8')),
                        ("error-message", str(validation_err).encode('utf-8'))
                    ]

                    # Envio síncrono para a DLQ
                    callback = DLQDeliveryCallback()
                    dlq_producer.produce(
                        topic=dlq_topic,
                        key=msg.key(),
                        value=raw_data if raw_data is not None else b'',
                        headers=headers,
                        callback=callback
                    )

                    # Força a entrega imediata das mensagens pendentes no buffer do produtor
                    dlq_producer.flush(timeout=5.0)

                    if not callback.success:
                        # Se a DLQ falhar, interrompemos o consumidor sem comitar a mensagem original
                        raise RuntimeError(f"Falha ao gravar na DLQ: {callback.error_msg}. Abortando processamento.")

                    # Commit condicionado à confirmação de entrega na DLQ
                    try:
                        consumer.commit(message=msg, asynchronous=False)
                        logger.info(f"Offset {msg.offset()} comitado após desvio para DLQ.")
                    except Exception as commit_err:
                        # --- CAMADA 3: Falha de Commit do Desvio ---
                        logger.error(f"Erro ao comitar offset {msg.offset()} após desvio para DLQ: {commit_err}")
                    continue

                # --- CAMADA 2: Processamento de Negócio e Falhas de Infraestrutura ---
                try:
                    save_to_database_mock(telemetry)
                except DatabaseConnectionError as infra_err:
                    # Falha transitória: NÃO envia para a DLQ. Para o consumidor.
                    logger.critical(f"Falha de infraestrutura detectada: {infra_err}. Encerrando consumidor.")
                    raise infra_err

                # Fluxo de Sucesso: Commit do offset original
                try:
                    consumer.commit(message=msg, asynchronous=False)
                    logger.info(f"Offset {msg.offset()} comitado com sucesso.")
                except Exception as commit_err:
                    # --- CAMADA 3: Falha de Commit de Sucesso ---
                    logger.error(f"Erro operacional ao comitar offset {msg.offset()}: {commit_err}")

            except DatabaseConnectionError:
                # Propaga o erro de infraestrutura para interromper o loop principal
                break

    except KeyboardInterrupt:
        logger.info("Interrupção manual recebida pelo operador.")
    finally:
        # Garante o fechamento limpo dos recursos e a liberação de partições
        consumer.close()
        dlq_producer.flush(timeout=2.0)
        logger.info("Recursos do pipeline liberados com segurança.")

if __name__ == "__main__":
    run_pipeline()

Script de Teste e Geração de Carga

Para testar o comportamento do pipeline sob diferentes cenários de erro, usaremos um script produtor que enviará quatro tipos de mensagens:

  1. Um payload válido (processamento regular).
  2. Um payload com JSON corrompido (falha de decodificação – Camada 1).
  3. Um payload com violação de restrições do esquema (comprimento e limites numéricos no Pydantic – Camada 1).
  4. Um payload que aciona a simulação de erro de banco de dados (falha de infraestrutura – Camada 2).

Código do Produtor (producer_test.py)

Salve o arquivo abaixo como producer_test.py:

import json
import time
from confluent_kafka import Producer

def delivery_report(err, msg):
    if err is not None:
        print(f"Falha ao enviar mensagem de teste: {err}")
    else:
        print(f"Mensagem enviada com sucesso para {msg.topic()} [{msg.partition()}]")

def run_test_producer():
    conf = {'bootstrap.servers': 'localhost:9092'}
    producer = Producer(conf)
    topic = "telemetry-raw"

    # 1. Payload Válido
    payload_valid = {
        "device_id": "sensor-alpha",
        "temperature": 23.4,
        "timestamp": int(time.time())
    }

    # 2. Payload Inválido: JSON Malformado (Poison Pill)
    payload_corrupted_json = "{device_id: 'sensor-beta', temperature: NaN}"

    # 3. Payload Inválido: Esquema Violado (Pydantic ValidationError)
    payload_invalid_schema = {
        "device_id": "x",  # Curto demais (min_length=3)
        "temperature": 150.0,  # Fora do limite (le=100.0)
        "timestamp": int(time.time())
    }

    # 4. Payload Válido que força erro de Infraestrutura Downstream
    payload_infra_fail = {
        "device_id": "db-fail",
        "temperature": 21.1,
        "timestamp": int(time.time())
    }

    print("Enviando mensagens de teste...")

    # Envia os cenários
    producer.produce(topic, json.dumps(payload_valid).encode('utf-8'), callback=delivery_report)
    producer.produce(topic, payload_corrupted_json.encode('utf-8'), callback=delivery_report)
    producer.produce(topic, json.dumps(payload_invalid_schema).encode('utf-8'), callback=delivery_report)
    producer.produce(topic, json.dumps(payload_infra_fail).encode('utf-8'), callback=delivery_report)

    producer.flush()

if __name__ == "__main__":
    run_test_producer()

Roteiro Passo a Passo de Execução e Inspeção

Siga os passos abaixo para executar o ambiente, rodar os testes e verificar o comportamento das filas.

Passo 1: Iniciar o Cluster Kafka

Inicie o container do Kafka em modo KRaft:

docker compose up -d

Verifique se o container está em execução:

docker ps

Passo 2: Executar o Consumidor (Pipeline)

Abra um terminal dedicado e execute o script do consumidor:

python consumer.py

O terminal exibirá a mensagem de inicialização e aguardará eventos.

Passo 3: Executar o Produtor de Testes

Abra um segundo terminal e execute o produtor para injetar as mensagens de teste no cluster:

python producer_test.py

Passo 4: Analisar a Saída do Consumidor

Volte ao terminal onde o consumer.py está rodando. A saída gerada (ilustrativa para acompanhamento dos offsets) demonstra cada camada em ação:

2024-10-24 10:00:00 [INFO] Consumidor iniciado e inscrito no tópico: telemetry-raw
2024-10-24 10:00:05 [INFO] Mensagem recebida do offset 0: {"device_id": "sensor-alpha", "temperature": 23.4, "timestamp": 1713960000}
2024-10-24 10:00:05 [INFO] Sucesso: Payload do dispositivo sensor-alpha persistido.
2024-10-24 10:00:05 [INFO] Offset 0 comitado com sucesso.

2024-10-24 10:00:05 [INFO] Mensagem recebida do offset 1: {device_id: 'sensor-beta', temperature: NaN}
2024-10-24 10:00:05 [WARNING] Poison Pill detectada no offset 1. Erro: Failed to decode JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
2024-10-24 10:00:05 [INFO] Callback DLQ: Mensagem entregue ao tópico telemetry-dlq na partição [0]
2024-10-24 10:00:05 [INFO] Offset 1 comitado após desvio para DLQ.

2024-10-24 10:00:05 [INFO] Mensagem recebida do offset 2: {"device_id": "x", "temperature": 150.0, "timestamp": 1713960000}
2024-10-24 10:00:05 [WARNING] Poison Pill detectada no offset 2. Erro: 2 validation errors for TelemetryPayload
device_id
  String should have at least 3 characters [type=string_too_short, input_value='x', input_type=str]
temperature
  Input should be less than or equal to 100 [type=less_than_equal, input_value=150.0, input_type=float]
2024-10-24 10:00:05 [INFO] Callback DLQ: Mensagem entregue ao tópico telemetry-dlq na partição [0]
2024-10-24 10:00:05 [INFO] Offset 2 comitado após desvio para DLQ.

2024-10-24 10:00:05 [INFO] Mensagem recebida do offset 3: {"device_id": "db-fail", "temperature": 21.1, "timestamp": 1713960000}
2024-10-24 10:00:05 [CRITICAL] Falha de infraestrutura detectada: Timeout ao conectar ao PostgreSQL principal.. Encerrando consumidor.
2024-10-24 10:00:05 [INFO] Recursos do pipeline liberados com segurança.

Análise do comportamento:
– O offset 0 foi processado e comitado normalmente.
– Os offsets 1 e 2 falharam na validação. Foram enviados para a DLQ e seus offsets foram comitados individualmente após a confirmação de entrega.
– O offset 3 acionou a simulação de falha de banco de dados. O consumidor registrou o erro crítico e encerrou a execução sem comitar o offset 3. Se você reiniciar o consumidor, ele lerá novamente a partir do offset 3.

Passo 5: Inspecionar o Tópico de DLQ

Para auditar as mensagens enviadas à DLQ com seus respectivos cabeçalhos de erro, execute o utilitário oficial do Kafka diretamente dentro do container Docker:

docker exec -it kafka-kraft /opt/bitnami/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic telemetry-dlq \
  --from-beginning \
  --property print.headers=true \
  --property print.key=true \
  --property print.timestamp=true

Exemplo de saída esperada (simplificada para visualização):

CreateTime:1713960005123 \
original-topic:telemetry-raw,original-partition:0,original-offset:1,error-message:Failed to decode JSON... \
null \
{device_id: 'sensor-beta', temperature: NaN}

CreateTime:1713960005456 \
original-topic:telemetry-raw,original-partition:0,original-offset:2,error-message:2 validation errors for TelemetryPayload... \
null \
{"device_id": "x", "temperature": 150.0, "timestamp": 1713960000}

Teste de Falha na DLQ

O que acontece se a infraestrutura de DLQ falhar ou a rede cair no momento do encaminhamento? Para evitar a perda irreversível de dados, o consumidor jamais deve avançar o offset da mensagem original se a confirmação da DLQ não for recebida.

Para realizar um teste controlado no ambiente local sem depender de ACLs ou permissões complexas, simule a indisponibilidade da DLQ apontando o produtor para a porta 9999 (sem serviço ativo):

  1. No consumer.py, altere temporariamente a inicialização do dlq_producer para uma porta fechada com timeout curto:
    # Simulação de indisponibilidade de rede na DLQ
    dlq_producer = Producer({'bootstrap.servers': 'localhost:9999', 'message.timeout.ms': 2000})
    
  2. Inicie o consumer.py e envie os dados de teste com python producer_test.py.
  3. Ao receber o offset 1 (poison pill), o consumidor tenta publicar na DLQ. Como o broker na porta 9999 é inalcançável, a entrega falha e o callback não sinaliza entrega bem-sucedida (callback.success == False). O pipeline dispara uma exceção RuntimeError (acusando a falha de entrega da DLQ) e encerra o processo sem executar o commit do offset 1.
  4. Inspecione o estado do grupo de consumidores no Kafka com o comando oficial:
    docker exec -it kafka-kraft /opt/bitnami/kafka/bin/kafka-consumer-groups.sh \
      --bootstrap-server localhost:9092 \
      --describe --group telemetry-ingestion-group
    

    Na saída exibida, compare as colunas:

    TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
    telemetry-raw   0          1               4               3
    

    O CURRENT-OFFSET permanece em 1. A mensagem válida do offset 0 foi comitada, mas a mensagem problemática não avançou o checkpoint.

  5. Recuperação: Restaure 'bootstrap.servers': 'localhost:9092' no consumer.py e reinicie o consumidor. Ele relerá a mensagem a partir do offset 1, completará o desvio para a DLQ com sucesso e comitará o offset, demonstrando na prática a garantia de reprocessamento at-least-once.

(Nota de execução: A tabela de offsets acima reflete um teste iniciado com uma partição limpa ou novo grupo consumidor. Se o consumidor já tiver sido executado anteriormente, ele terá avançado até o offset 3 e será interrompido pela simulação db-fail antes de avaliar novamente o offset 1. Para isolar o teste da DLQ, limpe os tópicos ou utilize um group.id novo).

Recuperação da falha de infraestrutura downstream: Da mesma forma, para retomar o consumo após a interrupção gerada pelo device_id: 'db-fail' no offset 3, desative o gatilho da falha na função save_to_database_mock antes de reiniciar o consumidor.


Armadilhas Operacionais e Resiliência em Produção

Ao migrar este pipeline para produção, atente-se às seguintes configurações críticas do Kafka:

1. Consumer Lag vs. max.poll.interval.ms

O tempo de processamento de cada mensagem (incluindo retentativas de banco de dados e envio à DLQ) não deve exceder o limite configurado em max.poll.interval.ms (padrão de 300000 ms ou 5 minutos). Se o processamento exceder esse tempo, o broker considerará o consumidor morto e iniciará um rebalanceamento de partição, o que gera duplicidade de consumo e instabilidade na topologia.

2. A Configuração de acks=all em Produção

No nosso ambiente local de nó único, a diretiva 'acks': 'all' garante que a mensagem seja gravada localmente. No entanto, em um cluster de produção com múltiplos brokers, essa configuração exige que a gravação seja confirmada pelo líder e por todas as réplicas em sincronia (In-Sync Replicas – ISR).

Se o número de réplicas ativas for menor que o parâmetro min.insync.replicas configurado no tópico, o produtor falhará ao tentar gravar na DLQ. Certifique-se de tratar essa falha operacional como um erro de infraestrutura temporário (Camada 2).

3. Evitando Loops Infinitos na DLQ

Ao consumir mensagens da DLQ para reprocessamento ou análise, certifique-se de que o consumidor da DLQ não reenvie mensagens diretamente para o tópico original sem uma estratégia de correção de dados ou um limite máximo de tentativas (retry count) gravado nos headers da mensagem. Caso contrário, mensagens permanentemente inválidas entrarão em um loop infinito de processamento e desvio.

]]>
Arquitetura de Ingestão de Dados Resiliente com Apache Kafka e Python https://zehro.digital/pipeline-ingestao-dados-kafka-python/ Tue, 08 Sep 2026 19:41:24 +0000 https://zehro.digital/pipeline-ingestao-dados-kafka-python/ Diagrama da arquitetura de pipeline de dados desacoplado com Apache Kafka e Python

Contexto Operacional e Raiz do Problema

Sistemas distribuídos voltados para alto throughput de eventos enfrentam desafios clássicos de infraestrutura e rede: flutuações de latência, picos imprevisíveis de tráfego (flash crowds) e falhas parciais em nós downstream. Em arquiteturas síncronas baseadas em HTTP/REST, a falha do serviço consumidor reflete diretamente na indisponibilidade do produtor, criando um efeito dominó que afeta a estabilidade do sistema.

Para isolar esses domínios, o padrão de arquitetura orientada a eventos (Event-Driven Architecture) utiliza o Apache Kafka como um log de confirmações gravado em disco e distribuído (distributed commit log). O Kafka oferece garantias de ordenação por partição, retenção configurável e semânticas ajustáveis de entrega de mensagens (At-least-once, At-most-once e Exactly-once).

Entretanto, utilizar o Kafka não elimina a responsabilidade do software em tratar falhas na camada da aplicação. Produtores mal configurados podem perder mensagens durante partionamentos de rede, enquanto consumidores sem tratamento explícito de offsets podem gerar leitura duplicada em massa ou descartar dados silenciosamente ao encontrar payloads malformados (poison pills).

O objetivo desta implementação é projetar um pipeline de ingestão em Python que adote garantias rigorosas de entrega At-least-once, suporte a Dead Letter Queue (DLQ) para isolamento de erros, e serialização tipada de contratos via Pydantic.

Diagrama de fluxo de ingestão e distribuição em partições do Apache Kafka
Fluxo de ingestão e barramento de eventos

Setup e Pré-requisitos

Para executar o pipeline em ambiente local ou de staging, são necessários os seguintes componentes:

  1. Python 3.10+ instalado.
  2. Docker e Docker Compose para execução do cluster Kafka.
  3. Biblioteca nativa do cliente C/C++ librdkafka empacotada na biblioteca Python confluent-kafka.

Dependências de Software (Python)

Instale as dependências via pip:

pip install confluent-kafka pydantic pytest

Infraestrutura Local via Docker Compose

Crie o arquivo docker-compose.yml para levantar um broker Kafka em modo KRaft (sem necessidade de Zookeeper):

version: '3.8'
services:
  kafka:
    image: confluentinc/cp-kafka:7.5.0
    container_name: kafka-broker
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092'
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_PROCESS_ROLES: 'broker,controller'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093'
      KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
      CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'

Inicie o broker com o comando:

docker compose up -d

Implementação Completa em Python

O código abaixo integra o produtor idempotente, a validação do contrato de dados via Pydantic e o consumidor com commit manual e redirecionamento de falhas para Dead Letter Queue (DLQ).

Estrutura de validação de esquema e roteamento para Dead Letter Queue
Contratos de dados e validação de schema
import json
import logging
import signal
import sys
import time
from typing import Any, Dict, List, Optional
from confluent_kafka import Consumer, KafkaError, KafkaException, Producer
from pydantic import BaseModel, Field, ValidationError

# Configuração de logging estruturado
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("kafka_pipeline")

class MetricEvent(BaseModel):
    """Contrato de dados tipado para validação de payload."""
    event_id: str
    device_id: str
    timestamp: float
    metric_value: float
    status: str = Field(default="OK")

class ResilientKafkaProducer:
    """Produtor idempotente com garantia de ordenação e retentativas configuradas."""

    def __init__(self, bootstrap_servers: str) -> None:
        conf: Dict[str, Any] = {
            "bootstrap.servers": bootstrap_servers,
            "enable.idempotence": True,
            "acks": "all",
            "retries": 5,
            "max.in.flight.requests.per.connection": 5,
            "compression.type": "snappy"
        }
        self.producer = Producer(conf)

    def delivery_report(self, err: Optional[KafkaError], msg: Any) -> None:
        """Callback invocado após a confirmação de escrita pelo broker."""
        if err is not None:
            logger.error(f"Falha na entrega da mensagem: {err}")
        else:
            logger.info(
                f"Mensagem entregue no tópico {msg.topic()} "
                f"[{msg.partition()}] offset {msg.offset()}"
            )

    def produce(self, topic: str, key: str, value: str) -> None:
        """Envia mensagem ao tópico com tratamento de buffer local cheio."""
        try:
            self.producer.produce(
                topic=topic,
                key=key.encode("utf-8"),
                value=value.encode("utf-8"),
                on_delivery=self.delivery_report
            )
            self.producer.poll(0)
        except BufferError:
            logger.warning("Buffer local cheio. Forçando flush síncrono...")
            self.producer.flush(timeout=2.0)
            self.producer.produce(
                topic=topic,
                key=key.encode("utf-8"),
                value=value.encode("utf-8"),
                on_delivery=self.delivery_report
            )

    def flush(self, timeout: float = 10.0) -> None:
        """Esvazia o buffer pendente do produtor."""
        self.producer.flush(timeout)

class ResilientKafkaConsumer:
    """Consumidor com controle manual de offset e isolamento em Dead Letter Queue."""

    def __init__(
        self,
        bootstrap_servers: str,
        group_id: str,
        topics: List[str],
        dlq_topic: str
    ) -> None:
        conf: Dict[str, Any] = {
            "bootstrap.servers": bootstrap_servers,
            "group.id": group_id,
            "auto.offset.reset": "earliest",
            "enable.auto.commit": False,
            "max.poll.interval.ms": 300000,
            "session.timeout.ms": 45000
        }
        self.consumer = Consumer(conf)
        self.producer = ResilientKafkaProducer(bootstrap_servers)
        self.topics = topics
        self.dlq_topic = dlq_topic
        self.running = True

    def stop(self) -> None:
        """Sinaliza parada graciosa do loop de consumo."""
        self.running = False

    def process_message(self, raw_value: bytes) -> bool:
        """Valida e processa a mensagem consumida."""
        try:
            data = json.loads(raw_value.decode("utf-8"))
            event = MetricEvent(**data)
            logger.info(f"Evento processado com sucesso: ID={event.event_id}, Valor={event.metric_value}")
            return True
        except (json.JSONDecodeError, ValidationError) as err:
            logger.error(f"Falha na validação do payload: {err}")
            return False
        except Exception as err:
            logger.error(f"Erro inesperado no processamento do evento: {err}")
            return False

    def send_to_dlq(self, raw_value: bytes, error_reason: str) -> None:
        """Encaminha mensagens incorretas ou não processáveis para a DLQ."""
        dlq_payload = json.dumps({
            "raw_payload": raw_value.decode("utf-8", errors="replace"),
            "error": error_reason,
            "timestamp": time.time()
        })
        self.producer.produce(self.dlq_topic, key="dlq_error", value=dlq_payload)
        self.producer.flush(timeout=5.0)
        logger.warning(f"Mensagem enviada para a DLQ '{self.dlq_topic}'. Motivo: {error_reason}")

    def consume_loop(self) -> None:
        """Loop principal de ingestão e confirmação de offset."""
        self.consumer.subscribe(self.topics)
        logger.info(f"Consumidor subscrito nos tópicos: {self.topics}")

        try:
            while self.running:
                msg = self.consumer.poll(timeout=1.0)
                if msg is None:
                    continue

                if msg.error():
                    if msg.error().code() == KafkaError._PARTITION_EOF:
                        continue
                    logger.error(f"Erro do consumidor Kafka: {msg.error()}")
                    break

                raw_value = msg.value()
                if raw_value is None:
                    continue

                success = self.process_message(raw_value)
                if success:
                    self.consumer.commit(msg, asynchronous=False)
                else:
                    self.send_to_dlq(raw_value, "Falha de validação ou processamento")
                    self.consumer.commit(msg, asynchronous=False)
        finally:
            self.consumer.close()
            self.producer.flush()
            logger.info("Consumidor encerrado e offsets commitados.")

def run_pipeline() -> None:
    """Função executável de demonstração do pipeline completo."""
    bootstrap_servers = "localhost:9092"
    main_topic = "telemetry-metrics"
    dlq_topic = "telemetry-metrics-dlq"

    # 1. Produção de dados de teste
    producer = ResilientKafkaProducer(bootstrap_servers)

    # Payload Válido
    valid_data = json.dumps({
        "event_id": "evt-101",
        "device_id": "sensor-alpha-01",
        "timestamp": time.time(),
        "metric_value": 87.4,
        "status": "OK"
    })
    producer.produce(main_topic, key="sensor-alpha-01", value=valid_data)

    # Payload Inválido (Poison Pill)
    invalid_data = json.dumps({
        "event_id": "evt-102",
        "device_id": "sensor-beta-02",
        "timestamp": "invalid_timestamp_string",
        "metric_value": "NOT_A_FLOAT"
    })
    producer.produce(main_topic, key="sensor-beta-02", value=invalid_data)
    producer.flush()

    # 2. Consumo e tratamento dos eventos
    consumer = ResilientKafkaConsumer(
        bootstrap_servers=bootstrap_servers,
        group_id="telemetry-processor-group",
        topics=[main_topic],
        dlq_topic=dlq_topic
    )

    def handle_signal(sig: int, frame: Any) -> None:
        logger.info("Interrupção recebida. Solicitando encerramento...")
        consumer.stop()

    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)

    # Executa o loop por tempo limitado para validação local
    logger.info("Iniciando consumo (pressione Ctrl+C para encerrar)...")
    consumer.consume_loop()

if __name__ == "__main__":
    run_pipeline()

Validação Prática e Logs

Ao executar o arquivo acima com o Kafka rodando localmente, os logs impressos no console confirmam a ingestão, o desvio da mensagem corrompida para a DLQ e a confirmação manual de offsets:

2026-03-30 14:00:01,102 [INFO] kafka_pipeline: Mensagem entregue no tópico telemetry-metrics [0] offset 0
2026-03-30 14:00:01,105 [INFO] kafka_pipeline: Mensagem entregue no tópico telemetry-metrics [0] offset 1
2026-03-30 14:00:01,110 [INFO] kafka_pipeline: Consumidor subscrito nos tópicos: ['telemetry-metrics']
2026-03-30 14:00:01,550 [INFO] kafka_pipeline: Evento processado com sucesso: ID=evt-101, Valor=87.4
2026-03-30 14:00:01,558 [ERROR] kafka_pipeline: Falha na validação do payload: 2 validation errors for MetricEvent...
2026-03-30 14:00:01,562 [INFO] kafka_pipeline: Mensagem entregue no tópico telemetry-metrics-dlq [0] offset 0
2026-03-30 14:00:01,563 [WARNING] kafka_pipeline: Mensagem enviada para a DLQ 'telemetry-metrics-dlq'. Motivo: Falha de validação ou processamento

Teste Unitário da Etapa de Ingestão

Para garantir a qualidade em pipelines de CI/CD, validações de esquema devem ser testadas isoladamente:

import pytest
from pydantic import ValidationError

def test_metric_event_schema_validation():
    valid_payload = {
        "event_id": "evt-200",
        "device_id": "device-xyz",
        "timestamp": 1672531199.0,
        "metric_value": 12.34,
        "status": "OK"
    }
    event = MetricEvent(**valid_payload)
    assert event.event_id == "evt-200"

    invalid_payload = {
        "event_id": "evt-201",
        "device_id": "device-xyz",
        "timestamp": "invalid_date",
        "metric_value": "abc"
    }
    with pytest.raises(ValidationError):
        MetricEvent(**invalid_payload)

Armadilhas Operacionais, Resiliência e Observabilidade

Painel de observabilidade de pipeline Kafka mostrando lag de mensagens e taxa de erro
Métricas de latência e saúde operacional

1. Consumer Group Rebalance Storms

Quando um consumidor demora mais tempo para processar um lote do que o limite definido em max.poll.interval.ms, o broker Kafka assume que a aplicação travou e remove o consumidor do grupo. Isso dispara um Rebalance, pausando o consumo de outros membros do grupo.
* Solução: Otimize o processamento interno, reduza o número de mensagens retornadas por poll, ou utilize multithreading desacoplando a thread do consumidor Kafka da thread de processamento da regra de negócio.

2. Commit Estratégico de Offsets: Síncrono vs Assíncrono

O commit síncrono (asynchronous=False) bloqueia a thread de execução até que o broker confirme o registro do offset. Ele reduz riscos de duplicidade, mas introduz latência. O commit assíncrono melhora o throughput, porém pode gerar leitura duplicada em caso de falha no meio do processamento. Em pipelines críticos onde perda de dados não é tolerada, prefira commits síncronos por mensagem ou por lote finalizado com tratamento rigoroso de exceções.

3. Idempotência do Produtor

Habilitar enable.idempotence=True no produtor garante que retentativas enviadas pela biblioteca devido a timeouts de rede não gerem mensagens duplicadas na partição. O broker atribui identificadores sequenciais às conexões do produtor para desconsiderar confirmações duplicadas.

4. Observabilidade e Métricas Chave

Para operar pipelines Kafka em produção, monitore continuamente:

Métrica Ferramenta / Origem Descrição e Ação
Consumer Lag Prometheus / Kafka Exporter Quantidade de mensagens não lidas na partição. Lag crescente indica gargalo no processamento.
DLQ Message Rate Métricas da Aplicação Frequência de mensagens descartadas. Picos indicam mudanças não sinalizadas no contrato de origem.
Rebalance Count JMX Broker / Logs do Consumidor Número de reequilíbrios no grupo de consumo. Frequência alta indica timeouts ou instabilidade de rede.
Buffer Exhaustion Client Logs / librdkafka Indica que o produtor está gerando eventos em taxa superior à capacidade da rede/broker.
]]>
Observabilidade, Resiliência e Gestão de Estado em Redes Multi-Agente com Antigravity https://zehro.digital/observabilidade-resiliencia-estado-redes-multi-agente-antigravity/ Tue, 08 Sep 2026 18:45:59 +0000 https://zehro.digital/observabilidade-resiliencia-estado-redes-multi-agente-antigravity/ Diagrama técnico mostrando o fluxo de mensagens entre agentes, fila principal com DLX, validador Pydantic e armazenamento de estado persistente.

O Problema Prático e Contexto Direto

Sistemas multi-agente distribuídos operam de forma assíncrona, o que introduz desafios complexos de consistência de estado, rastreabilidade e tolerância a falhas. Quando múltiplos agentes colaboram para resolver uma tarefa, a perda de uma única mensagem ou a corrupção do estado de um sub-agente pode paralisar todo o fluxo de trabalho. Sem um mecanismo de persistência robusto e rastreamento ponta a ponta, diagnosticar gargalos ou falhas de execução em produção torna-se inviável.

Para mitigar esses problemas, este tutorial demonstra como estruturar uma arquitetura de mensageria resiliente integrada ao framework Antigravity. Utilizaremos o RabbitMQ como barramento de mensagens confiável — configurado com confirmações de publicação (Publisher Confirms), filas duráveis e tratamento de mensagens venenosas (Poison Messages) via Dead Letter Exchange (DLX) — combinado com validação rigorosa de esquema via Pydantic e rastreamento de estado unificado. Esta abordagem complementa os conceitos apresentados no artigo principal Arquitetura de Agentes e Sub-agentes com Antigravity e Gemini 3.8: Guia Definitivo de Sistemas Autônomos em Produção.

Pré-requisitos e Instalação

Para executar este tutorial, você precisará de um ambiente Python 3.10+ e de uma instância do RabbitMQ em execução.

Instale as dependências necessárias via terminal:

pip install pika pydantic

Para subir uma instância local do RabbitMQ com o painel de gerenciamento habilitado via Docker, execute:

docker run -d --name rabbitmq-antigravity \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:3-management

Implementação Completa em Python

O script abaixo implementa o ciclo completo de vida de uma mensagem de estado entre agentes. Ele configura as filas com propriedades de resiliência, valida a carga útil usando Pydantic, simula o processamento de um agente e gerencia falhas de forma determinística.

import json
import logging
import sys
import uuid
from typing import Dict, Any, Optional
import pika
from pika.exceptions import AMQPChannelError, AMQPConnectionError
from pydantic import BaseModel, Field, ValidationError

# Configuração de Logging para Observabilidade
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("AntigravityResilience")

# Definição do Esquema de Estado com Pydantic
class AgentState(BaseModel):
    task_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    parent_agent: str
    current_agent: str
    payload: Dict[str, Any]
    step_count: int = Field(default=1, ge=1)
    correlation_id: str

    class Config:
        frozen = True

class StateManager:
    """Gerencia a conexão, declaração de filas e publicação de mensagens."""

    def __init__(self, amqp_url: str = "amqp://guest:guest@localhost:5672/"):
        self.amqp_url = amqp_url
        self.connection: Optional[pika.BlockingConnection] = None
        self.channel: Optional[pika.adapters.blocking_connection.BlockingChannel] = None

    def connect(self) -> None:
        try:
            parameters = pika.URLParameters(self.amqp_url)
            self.connection = pika.BlockingConnection(parameters)
            self.channel = self.connection.channel()
            # Habilita confirmações de entrega do publicador (Publisher Confirms)
            self.channel.confirm_delivery()
            logger.info("Conexão estabelecida com o RabbitMQ.")
        except AMQPConnectionError as err:
            logger.error(f"Falha ao conectar ao RabbitMQ: {err}")
            raise

    def setup_topology(self) -> None:
        """Declara exchanges, filas duráveis e bindings para resiliência."""
        if not self.channel:
            raise RuntimeError("Canal não inicializado. Chame connect() primeiro.")

        try:
            # 1. Configuração da infraestrutura de Dead Letter (DLX)
            self.channel.exchange_declare(
                exchange="dlx.antigravity",
                exchange_type="direct",
                durable=True
            )
            self.channel.queue_declare(
                queue="q.antigravity.dlq",
                durable=True
            )
            self.channel.queue_bind(
                exchange="dlx.antigravity",
                queue="q.antigravity.dlq",
                routing_key="dead_letter"
            )

            # 2. Configuração da infraestrutura Principal
            self.channel.exchange_declare(
                exchange="ex.antigravity.work",
                exchange_type="direct",
                durable=True
            )

            # Fila principal configurada para encaminhar falhas para a DLX
            arguments = {
                "x-dead-letter-exchange": "dlx.antigravity",
                "x-dead-letter-routing-key": "dead_letter"
            }
            self.channel.queue_declare(
                queue="q.antigravity.state",
                durable=True,
                arguments=arguments
            )
            self.channel.queue_bind(
                exchange="ex.antigravity.work",
                queue="q.antigravity.state",
                routing_key="agent_state"
            )
            logger.info("Topologia de mensageria declarada com sucesso.")
        except AMQPChannelError as err:
            logger.error(f"Erro ao declarar topologia: {err}")
            raise

    def publish_state(self, state: AgentState) -> bool:
        """Publica o estado do agente garantindo a entrega via Publisher Confirms."""
        if not self.channel:
            raise RuntimeError("Canal não inicializado.")

        try:
            body = state.json()
            properties = pika.BasicProperties(
                delivery_mode=2,  # Torna a mensagem persistente no disco
                content_type="application/json",
                correlation_id=state.correlation_id
            )

            self.channel.basic_publish(
                exchange="ex.antigravity.work",
                routing_key="agent_state",
                body=body,
                properties=properties
            )
            logger.info(f"Estado publicado com sucesso. Correlation ID: {state.correlation_id}")
            return True
        except pika.exceptions.UnroutableError:
            logger.error("Mensagem não pôde ser roteada.")
            return False
        except Exception as err:
            logger.error(f"Erro na publicação: {err}")
            return False

    def close(self) -> None:
        if self.connection and not self.connection.is_closed:
            self.connection.close()
            logger.info("Conexão com o RabbitMQ encerrada.")

def process_message(ch: Any, method: Any, properties: Any, body: bytes) -> None:
    """Callback de consumo que valida o estado e simula o processamento do agente."""
    try:
        # Parse e validação estrita com Pydantic
        data = json.loads(body.decode("utf-8"))
        state = AgentState(**data)

        logger.info(
            f"Processando tarefa {state.task_id} no agente '{state.current_agent}'. "
            f"Passo: {state.step_count}. Correlation ID: {properties.correlation_id}"
        )

        # Simulação de regra de negócio: rejeita payloads inválidos para simular DLQ
        if state.payload.get("status") == "invalid":
            raise ValueError("Payload corrompido ou inválido detectado.")

        # Confirmação manual de processamento bem-sucedido
        ch.basic_ack(delivery_tag=method.delivery_tag)
        logger.info(f"Mensagem {method.delivery_tag} confirmada (ACK).")

    except (ValidationError, json.JSONDecodeError) as err:
        logger.error(f"Erro de validação de esquema. Enviando para DLQ. Erro: {err}")
        # nack com requeue=False envia a mensagem diretamente para a DLX configurada
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

    except Exception as err:
        logger.error(f"Erro operacional durante o processamento: {err}. Descartando mensagem.")
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

def start_consumer(amqp_url: str = "amqp://guest:guest@localhost:5672/") -> None:
    """Inicia o loop de consumo de mensagens."""
    parameters = pika.URLParameters(amqp_url)
    connection = pika.BlockingConnection(parameters)
    channel = connection.channel()

    # Garante que o agente receba apenas uma mensagem por vez (Fair Dispatch)
    channel.basic_qos(prefetch_count=1)

    channel.basic_consume(
        queue="q.antigravity.state",
        on_message_callback=process_message,
        auto_ack=False
    )

    logger.info("Aguardando novas mensagens de estado. Para sair pressione CTRL+C")
    try:
        channel.start_consuming()
    except KeyboardInterrupt:
        channel.stop_consuming()
        connection.close()
        logger.info("Consumidor finalizado.")

Como Testar e Verificar a Saída

Para validar o fluxo de resiliência e a gestão de estado, execute o script em dois cenários distintos: processamento bem-sucedido e falha com desvio para a Dead Letter Queue (DLQ).

Cenário 1: Fluxo Nominal (Sucesso)

Crie um arquivo de execução chamado run_pipeline.py para instanciar o publicador e enviar um estado válido:

from main import StateManager, AgentState, start_consumer
import threading
import time

# Inicializa o gerenciador e configura a topologia
manager = StateManager()
manager.connect()
manager.setup_topology()

# Cria um estado válido
valid_state = AgentState(
    parent_agent="Orchestrator",
    current_agent="DataAnalyzer",
    payload={"status": "active", "data": [10, 20, 30]},
    correlation_id="corr-9999-valid"
)

# Publica a mensagem
manager.publish_state(valid_state)
manager.close()

# Inicia o consumidor em uma thread separada para processar a mensagem
consumer_thread = threading.Thread(target=start_consumer, daemon=True)
consumer_thread.start()

# Aguarda o processamento
time.sleep(2)

Saída esperada no console:

2023-10-27 10:00:00,102 [INFO] AntigravityResilience - Conexão estabelecida com o RabbitMQ.
2023-10-27 10:00:00,115 [INFO] AntigravityResilience - Topologia de mensageria declarada com sucesso.
2023-10-27 10:00:00,125 [INFO] AntigravityResilience - Estado publicado com sucesso. Correlation ID: corr-9999-valid
2023-10-27 10:00:00,130 [INFO] AntigravityResilience - Conexão com o RabbitMQ encerrada.
2023-10-27 10:00:00,135 [INFO] AntigravityResilience - Aguardando novas mensagens de estado. Para sair pressione CTRL+C
2023-10-27 10:00:00,142 [INFO] AntigravityResilience - Processando tarefa 550e8400-e29b-41d4-a716-446655440000 no agente 'DataAnalyzer'. Passo: 1. Correlation ID: corr-9999-valid
2023-10-27 10:00:00,145 [INFO] AntigravityResilience - Mensagem 1 confirmada (ACK).

Cenário 2: Fluxo de Falha (Encaminhamento para DLQ)

Modifique o payload para simular um erro de validação ou uma mensagem corrompida:

manager = StateManager()
manager.connect()

invalid_state = AgentState(
    parent_agent="Orchestrator",
    current_agent="DataAnalyzer",
    payload={"status": "invalid"},  # Forçará o disparo da exceção
    correlation_id="corr-9999-invalid"
)

manager.publish_state(invalid_state)
manager.close()

Saída esperada no console:

2023-10-27 10:05:00,010 [INFO] AntigravityResilience - Estado publicado com sucesso. Correlation ID: corr-9999-invalid
2023-10-27 10:05:00,022 [INFO] AntigravityResilience - Processando tarefa 6a2b3c4d-e89b-12d3-a456-426655440000 no agente 'DataAnalyzer'. Passo: 1. Correlation ID: corr-9999-invalid
2023-10-27 10:05:00,025 [ERROR] AntigravityResilience - Erro operacional durante o processamento: Payload corrompido ou inválido detectado.. Descartando mensagem.

Ao inspecionar o painel do RabbitMQ (http://localhost:15672), você observará que a mensagem rejeitada foi movida automaticamente para a fila q.antigravity.dlq, preservando o payload original e os metadados para análise forense.

Armadilhas Operacionais e Boas Práticas

1. Reentrância e Idempotência

Agentes distribuídos podem receber a mesma mensagem mais de uma vez devido a timeouts de rede ou reinicializações de pods. Garanta que toda operação de escrita de estado seja idempotente. Utilize o correlation_id ou o task_id como chave primária única no banco de dados de estado para evitar duplicidade de processamento.

2. Loops de Mensagens Venenosas (Poison Messages)

Se uma mensagem falhar continuamente e for devolvida à fila principal (requeue=True), ela consumirá recursos infinitamente. A configuração apresentada de nack(requeue=False) combinada com a DLX garante que mensagens malformadas sejam isoladas imediatamente para análise manual ou reprocessamento offline.

3. Persistência Física (fsync)

Por padrão, o RabbitMQ grava mensagens persistentes no disco, mas a sincronização física (fsync) ocorre em lotes para otimizar a performance. Em cenários de missão crítica onde a perda de mensagens é inaceitável, utilize Quorum Queues em vez de Classic Queues. Elas utilizam o algoritmo de consenso Raft para replicar o estado entre múltiplos nós antes de confirmar a escrita ao publicador.

4. Timeouts de Consumidor

Se um agente demorar mais do que o limite configurado no RabbitMQ (consumer_timeout) para processar uma tarefa, o broker fechará o canal de comunicação. Para tarefas longas de IA ou processamento de dados, delegue a computação pesada para um pool de threads separado e envie batimentos cardíacos (heartbeats) periódicos para manter a conexão AMQP ativa.


Navegação da Trilha

Este artigo faz parte da trilha de desenvolvimento de sistemas autônomos de alta confiabilidade do Zehro Digital.

]]>
Portões de Qualidade Determinísticos em Agentes de Código: Validação Sintática via AST e Autocorreção https://zehro.digital/portoes-de-qualidade-deterministicos-agentes-codigo-ast/ Tue, 08 Sep 2026 18:41:15 +0000 https://zehro.digital/portoes-de-qualidade-deterministicos-agentes-codigo-ast/ Diagrama técnico do fluxo de validação sintática AST e ciclo de autocorreção integrado ao barramento RabbitMQ

O Problema Prático e Contexto Direto

Modelos de linguagem dedicados à geração de código produzem trechos de software com variabilidade estocástica. Em arquiteturas autônomas multi-agente, enviar esse código diretamente para executores isolados ou pipelines de deployment sem validação prévia resulta em erros em tempo de compilação, estouro de consumo de CPU e falhas em acoplamentos downstream. Confiar exclusivamente em chamadas repetidas de LLM para autocorreção, sem um feedback estruturado e determinístico, gera loops infinitos de processamento e elevação de custos de API.

Para resolver este problema, implementa-se um Portão de Qualidade (Quality Gate) determinístico síncrono utilizando o módulo nativo ast do Python antes do enfileiramento ou execução. Essa camada valida a sintaxe da Árvore de Sintaxe Abstrata, captura metadados exatos de falhas (linha, coluna e tipo de erro) e os injeta em um loop de feedback guiado. Esta arquitetura refina o padrão estabelecido na Arquitetura de Agentes e Sub-agentes com Antigravity e Gemini 3.8: Guia Definitivo de Sistemas Autônomos em Produção, garantindo que apenas artefatos sintaticamente válidos transitem pelo barramento de mensageria da infraestrutura.

Pré-requisitos e Instalação

A solução requer Python 3.10+ e uma instância do RabbitMQ para enfileiramento de tarefas com confirmação do produtor e tratamento de dead-letter queues (DLQ).

Instale as dependências via Python Package Index:

pip install pika==1.3.2 pydantic==2.6.4

Suba uma instância do RabbitMQ via Docker para desenvolvimento local:

docker run -d --name rabbitmq-gate -p 5672:5672 -p 15672:15672 rabbitmq:3.12-management

Implementação Completa em Python (Código Funcional)

O script a seguir implementa o modelo de validação sintática por AST, o contrato de dados via Pydantic, a integração com RabbitMQ (usando publisher_confirms e nack(requeue=False) para mensagens mortas) e o mecanismo de autocorreção determinístico.

import ast
import json
import logging
import sys
from typing import Optional
import pika
from pika.exceptions import AMQPError
from pydantic import BaseModel, Field, ValidationError

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)

class CodePayload(BaseModel):
    task_id: str
    code: str
    attempt: int = Field(default=1, ge=1)
    max_attempts: int = Field(default=3, ge=1)
    error_log: Optional[str] = None

class ASTValidator:
    @staticmethod
    def validate(code_str: str) -> tuple[bool, Optional[str], Optional[int]]:
        try:
            ast.parse(code_str)
            return True, None, None
        except SyntaxError as err:
            error_msg = f"SyntaxError no trecho '{err.text.strip() if err.text else ''}': {err.msg} (linha {err.lineno}, coluna {err.offset})"
            return False, error_msg, err.lineno
        except Exception as err:
            return False, f"Erro de parsing inesperado: {str(err)}", None

class CodeFixerAgent:
    @staticmethod
    def auto_correct(payload: CodePayload) -> str:
        # Simula a autocorreção determinística adicionando o caractere ausente
        # Em produção, este ponto conecta ao modelo Gemini 3.8 repassando o payload.error_log
        lines = payload.code.splitlines()
        corrected_lines = []
        for line in lines:
            if line.strip().startswith("def ") and not line.strip().endswith(":"):
                line = line + ":"
            corrected_lines.append(line)
        return "\n".join(corrected_lines)

class MessageBroker:
    EXCHANGE_NAME = "code_processing_ex"
    QUEUE_SUCCESS = "valid_code_queue"
    QUEUE_DLQ = "invalid_code_dlq"
    ROUTING_KEY_SUCCESS = "code.valid"
    ROUTING_KEY_DLQ = "code.invalid"

    def __init__(self, host: str = "localhost"):
        self.host = host
        self.connection = None
        self.channel = None

    def connect(self) -> None:
        parameters = pika.ConnectionParameters(host=self.host, heartbeat=600)
        self.connection = pika.BlockingConnection(parameters)
        self.channel = self.connection.channel()
        self.channel.confirm_delivery()

        self.channel.exchange_declare(
            exchange=self.EXCHANGE_NAME,
            exchange_type="direct",
            durable=True
        )
        self.channel.queue_declare(queue=self.QUEUE_SUCCESS, durable=True)
        self.channel.queue_declare(queue=self.QUEUE_DLQ, durable=True)

        self.channel.queue_bind(
            exchange=self.EXCHANGE_NAME,
            queue=self.QUEUE_SUCCESS,
            routing_key=self.ROUTING_KEY_SUCCESS
        )
        self.channel.queue_bind(
            exchange=self.EXCHANGE_NAME,
            queue=self.QUEUE_DLQ,
            routing_key=self.ROUTING_KEY_DLQ
        )

    def publish(self, routing_key: str, message: str) -> None:
        if not self.channel or self.channel.is_closed:
            self.connect()
        self.channel.basic_publish(
            exchange=self.EXCHANGE_NAME,
            routing_key=routing_key,
            body=message.encode("utf-8"),
            properties=pika.BasicProperties(
                delivery_mode=2,  # Mensagem persistente em disco
                content_type="application/json"
            ),
            mandatory=True
        )

    def close(self) -> None:
        if self.connection and not self.connection.is_closed:
            self.connection.close()

def process_pipeline(raw_payload: str, broker: MessageBroker) -> None:
    try:
        data = json.loads(raw_payload)
        payload = CodePayload(**data)
    except (json.JSONDecodeError, ValidationError) as err:
        logging.error(f"Payload inválido descartado: {err}")
        return

    logging.info(f"Iniciando validação AST para task_id={payload.task_id}, tentativa={payload.attempt}")

    is_valid, error_msg, line_no = ASTValidator.validate(payload.code)

    if is_valid:
        logging.info(f"Validação AST aprovada para task_id={payload.task_id}.")
        broker.publish(broker.ROUTING_KEY_SUCCESS, payload.model_dump_json())
        return

    logging.warning(f"Falha de validação AST na task_id={payload.task_id}: {error_msg}")

    if payload.attempt < payload.max_attempts:
        payload.attempt += 1
        payload.error_log = error_msg
        payload.code = CodeFixerAgent.auto_correct(payload)
        logging.info(f"Reenviando task_id={payload.task_id} para tentativa {payload.attempt} após autocorreção.")
        process_pipeline(payload.model_dump_json(), broker)
    else:
        logging.error(f"Limite máximo de tentativas atingido ({payload.max_attempts}). Enviando task_id={payload.task_id} para DLQ.")
        broker.publish(broker.ROUTING_KEY_DLQ, payload.model_dump_json())

if __name__ == "__main__":
    broker = MessageBroker(host="localhost")
    try:
        broker.connect()

        # Trecho de código com erro sintático intencional (falta dois pontos na definição da função)
        code_with_syntax_error = "def process_data(value)\n    return value * 2"

        sample_task = CodePayload(
            task_id="task-9021",
            code=code_with_syntax_error,
            attempt=1,
            max_attempts=3
        )

        process_pipeline(sample_task.model_dump_json(), broker)
    except AMQPError as e:
        logging.critical(f"Erro na comunicação com RabbitMQ: {e}")
    finally:
        broker.close()

Como Testar e Verificar a Saída

  1. Garanta que o contêiner Docker do RabbitMQ esteja ativo.
  2. Salve o código acima no arquivo quality_gate.py.
  3. Execute o script no terminal:
python quality_gate.py

Saída Esperada no Console

2026-03-30 10:00:00,102 [INFO] Iniciando validação AST para task_id=task-9021, tentativa=1
2026-03-30 10:00:00,105 [WARNING] Falha de validação AST na task_id=task-9021: SyntaxError no trecho 'def process_data(value)': expected ':' (linha 1, coluna 23)
2026-03-30 10:00:00,106 [INFO] Reenviando task_id=task-9021 para tentativa 2 após autocorreção.
2026-03-30 10:00:00,106 [INFO] Iniciando validação AST para task_id=task-9021, tentativa=2
2026-03-30 10:00:00,107 [INFO] Validação AST aprovada para task_id=task-9021.

O log confirma que o código original falhou na primeira checagem da AST. O manipulador interceptou o erro sintático de falta do caractere :, acionou a autocorreção, incrementou o contador de tentativas e aprovou a publicação da mensagem na fila valid_code_queue.

Armadilhas Operacionais e Boas Práticas

  • Loop de Mensagens Venenosas (Poison Messages): Se a lógica de autocorreção falhar repetidamente em corrigir uma aberração sintática, o contador max_attempts garante o encerramento do ciclo. Sem esse controle, a mensagem permanece reentrando na fila, esgotando recursos do cluster.
  • Limitações de Segurança da AST: O módulo ast.parse() valida apenas a estrutura gramatical do código. Ele não executa a instrução, mas compila a árvore. Não utilize o módulo eval() ou exec() durante a fase de validação. Para testes de execução reais, utilize contêineres efémeros com isolamento gVisor ou Seccomp.
  • Configuração de Persistência no RabbitMQ: Garanta que as filas sejam declaradas com durable=True e as mensagens publicadas com delivery_mode=2. Em cenários de alta concorrência de mensagens de agentes, configure Quorum Queues no RabbitMQ para evitar perda de estado durante o failover de nós do cluster.
  • Escrita em Disco e Performance: O uso de confirmação de publicação (confirm_delivery()) impõe síncronos de escrita (fsync) no barramento. Mantenha o pipeline de validação AST como um processo em memória no worker antes de invocar chamadas I/O persistentes.

Navegação da Trilha

]]>
Function Calling e Structured Outputs no Gemini 3.8: Contratos Rígidos para Agentes de Software https://zehro.digital/function-calling-structured-outputs-gemini-3-8/ Tue, 08 Sep 2026 18:39:00 +0000 https://zehro.digital/function-calling-structured-outputs-gemini-3-8/ Diagrama técnico ilustrando a integração de Pydantic, Function Calling e o motor de inferência do Gemini 3.8 para validação de dados em agentes.

O Problema Prático e Contexto Direto

A integração de modelos de linguagem em pipelines de software exige determinismo na estrutura das respostas. Quando um agente autônomo consome payloads não estruturados ou textos livres em formato JSON não garantido, parsers de backend falham devido a inconsistências de tipos, chaves ausentes ou alucinações de formatação. Em arquiteturas de microsserviços, a quebra de um contrato de interface interrompe a execução do pipeline de automação e introduz estados inconsistentes em bancos de dados e filas de mensagens.

Para contornar essa fragilidade, o Gemini 3.8 disponibiliza dois recursos nativos acoplados ao ciclo de inferência: Structured Outputs (utilizando schemas JSON/Pydantic) e Function Calling (invocação declarativa de ferramentas). A aplicação de esquemas rígidos força o decodificador do modelo a gerar tokens que respeitem a gramática estipulada. Essa abordagem garante a interoperabilidade direta com modelos de dados tipados em Python, permitindo a orquestração segura descrita na Arquitetura de Agentes e Sub-agentes com Antigravity e Gemini 3.8: Guia Definitivo de Sistemas Autônomos em Produção.

Pré-requisitos e Instalação

A execução do código requer ambiente Python 3.10 ou superior, o SDK oficial google-genai e a biblioteca de validação pydantic.

Execute os comandos abaixo para configurar o ambiente virtual e instalar as dependências necessárias:

python3 -m venv venv
source venv/bin/activate
pip install google-genai pydantic

Defina a chave de API do Google Gemini na variável de ambiente antes de executar os scripts:

export GEMINI_API_KEY="Sua_Chave_API_Aqui"

Implementação Completa em Python (Código Funcional)

O código a seguir demonstra o padrão completo de implementação. Ele cobre a definição de estruturas tipadas com Pydantic, a aplicação do contrato via Structured Outputs no Gemini 3.8, a definição de funções para Function Calling e o tratamento explícito de exceções de validação (ValidationError).

Crie um arquivo chamado agent_contracts.py e insira a implementação completa:

import json
import os
import sys
import typing
from pydantic import BaseModel, Field, ValidationError
from google import genai
from google.genai import types

# ------------------------------------------------------------------------------
# 1. Definição do Modelo de Saída Estruturada via Pydantic
# ------------------------------------------------------------------------------
class TaskStep(BaseModel):
    step_number: int = Field(
        ge=1, 
        description="Número sequencial indicando a ordem de execução da etapa."
    )
    command: str = Field(
        description="Comando explícito a ser executado no ambiente de destino."
    )
    timeout_seconds: int = Field(
        ge=5, 
        le=300, 
        description="Tempo limite de execução do comando em segundos."
    )

class ExecutionPipeline(BaseModel):
    pipeline_id: str = Field(
        description="Identificador único em formato string para o pipeline."
    )
    target_environment: str = Field(
        pattern="^(production|staging|development)$",
        description="Ambiente de destino válido para a execução."
    )
    steps: typing.List[TaskStep] = Field(
        description="Lista sequencial contendo os passos do pipeline."
    )

# ------------------------------------------------------------------------------
# 2. Definição da Função para Function Calling NATIVO
# ------------------------------------------------------------------------------
def provision_container(image_name: str, port: int) -> str:
    """Provisiona um container no cluster de destino.

    Args:
        image_name: Nome da imagem do container no registry (ex: redis:7.0).
        port: Porta TCP exposta pelo serviço.
    """
    # Exemplo simulado de integração com API de orquestração de infraestrutura
    execution_result = {
        "status": "PROVISIONED",
        "image": image_name,
        "port": port,
        "container_id": "cnt-8f92a10c99"
    }
    return json.dumps(execution_result)

# ------------------------------------------------------------------------------
# 3. Execução de Structured Outputs com Validação de Contrato
# ------------------------------------------------------------------------------
def run_structured_output_pipeline(client: genai.Client) -> ExecutionPipeline:
    prompt = (
        "Crie um pipeline para implantação da aplicação Web. "
        "O ambiente deve ser 'production'. "
        "Passo 1: executar 'docker pull app:v2' com timeout de 60s. "
        "Passo 2: executar 'docker run -d -p 80:80 app:v2' com timeout de 30s."
    )

    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            response_schema=ExecutionPipeline,
            temperature=0.0
        )
    )

    try:
        raw_json = response.text
        parsed_data = json.loads(raw_json)
        validated_pipeline = ExecutionPipeline.model_validate(parsed_data)
        return validated_pipeline
    except ValidationError as err:
        print(f"[ERRO] Falha de validação no schema Pydantic: {err}", file=sys.stderr)
        raise
    except json.JSONDecodeError as err:
        print(f"[ERRO] Resposta retornada não é um JSON válido: {err}", file=sys.stderr)
        raise

# ------------------------------------------------------------------------------
# 4. Execução de Function Calling Determinístico
# ------------------------------------------------------------------------------
def run_function_calling_pipeline(client: genai.Client) -> None:
    prompt = "Provisione um container Redis utilizando a imagem redis:7.0 na porta 6379."

    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            tools=[provision_container],
            temperature=0.0
        )
    )

    if response.function_calls:
        for call in response.function_calls:
            print(f"[FUNCTION CALL] Invocando função: {call.name}")
            print(f"[ARGUMENTOS]: {call.args}")

            if call.name == "provision_container":
                image = str(call.args["image_name"])
                port = int(call.args["port"])
                output = provision_container(image_name=image, port=port)
                print(f"[RESPOSTA DA FERRAMENTA]: {output}")
    else:
        print("[INFO] Nenhuma chamada de função foi disparada pelo modelo.")

# ------------------------------------------------------------------------------
# 5. Ponto de Entrada Principal
# ------------------------------------------------------------------------------
def main():
    api_key = os.environ.get("GEMINI_API_KEY")
    if not api_key:
        print("[ERRO CRÍTICO] Variável GEMINI_API_KEY ausente.", file=sys.stderr)
        sys.exit(1)

    client = genai.Client(api_key=api_key)

    print("=== Teste 1: Execução com Structured Output ===")
    pipeline = run_structured_output_pipeline(client)
    print(f"Pipeline ID: {pipeline.pipeline_id}")
    print(f"Ambiente Validado: {pipeline.target_environment}")
    print(f"Passos Validados ({len(pipeline.steps)}):")
    for step in pipeline.steps:
        print(f"  [{step.step_number}] {step.command} (Timeout: {step.timeout_seconds}s)")

    print("\n=== Teste 2: Execução com Function Calling ===")
    run_function_calling_pipeline(client)

if __name__ == "__main__":
    main()

Como Testar e Verificar a Saída

Para executar o script e verificar o contrato estático e o disparo de funções, execute o comando abaixo no terminal:

python agent_contracts.py

Exemplo de Saída Esperada no Console

=== Teste 1: Execução com Structured Output ===
Pipeline ID: pipe-prod-001
Ambiente Validado: production
Passos Validados (2):
  [1] docker pull app:v2 (Timeout: 60s)
  [2] docker run -d -p 80:80 app:v2 (Timeout: 30s)

=== Teste 2: Execução com Function Calling ===
[FUNCTION CALL] Invocando função: provision_container
[ARGUMENTOS]: {'image_name': 'redis:7.0', 'port': 6379.0}
[RESPOSTA DA FERRAMENTA]: {"status": "PROVISIONED", "image": "redis:7.0", "port": 6379, "container_id": "cnt-8f92a10c99"}

A validação do Pydantic lança uma exceção ValidationError antes que dados corrompidos passem para a camada de execução caso o modelo retorne tipos incompatíveis ou descumpra as expressões regulares (pattern).

Armadilhas Operacionais e Boas Práticas

1. Latência por Construtores Gramaticais

O uso de response_schema com restrições rígidas (expressões regulares complexas ou enumerações extensas) impõe uma penalidade de latência na fase de amostragem de tokens. O decodificador restringe o espaço de busca a cada token gerado. Em pipelines que exigem respostas sub-100ms, limite a complexidade das validações dentro do schema do modelo e efetue regras de negócio secundárias no código Python.

2. Mensagens Venenosas (Poison Messages) e Ciclos de Retry

Se o modelo gerar um payload que viole o schema Pydantic, o tratamento via try/except ValidationError deve ser acoplado a uma estratégia de re-prompting limitada (máximo de 2 a 3 tentativas). Evite reconsumir a mesma resposta em loops infinitos sem alterar o contexto ou ajustar a temperatura para 0.0.

3. Divergência de Tipos em Argumentos de Funções (Function Calling)

A API do Gemini pode serializar números inteiros como valores de ponto flutuante em JSON (ex: 6379.0 em vez de 6379). O código consumidor da chamada de função deve efetuar a coerção explícita de tipos (como feito em port = int(call.args["port"])) antes de repassar o argumento para subsistemas do SO ou drivers de banco de dados.

4. Controle de Timeouts e Deadlocks em Agentes

Chamadas externas de ferramentas acionadas por Function Calling devem ter timeouts estritos configurados no nível do cliente HTTP/gRPC. Ferramentas que bloqueiam a thread principal do agente paralisam a fila de execução do sistema autônomo.

Navegação da Trilha

]]>
Implementando Sub-agentes Especializados com Antigravity: Da Decomposição de Tarefas à Execução Concorrente https://zehro.digital/sub-agentes-especializados-antigravity-execucao-concorrente/ Tue, 08 Sep 2026 18:36:59 +0000 https://zehro.digital/sub-agentes-especializados-antigravity-execucao-concorrente/ Diagrama de arquitetura técnica exibindo orquestrador de tarefas, barramento de mensageria com dead-letter exchange e sub-agentes especialistas concorrentes.

Monólitos baseados em prompts extensos concentram regras de negócios díspares em um único ciclo de inferência, o que resulta em esgotamento de janela de contexto, degradação de latência e ausência de tolerância a falhas. Quando uma sub-etapa de uma instrução complexa falha em um fluxo monolítico, todo o pipeline de execução é abortado, desperdiçando computação prévia e inviabilizando garantias operacionais de SLA.

A solução arquitetural consiste na decomposição determinística da carga de trabalho em sub-tarefas atômicas, delegadas a sub-agentes especializados que operam de forma concorrente sobre contratos de dados tipados. Como estabelecido no artigo fundamental Arquitetura de Agentes e Sub-agentes com Antigravity e Gemini 3.8: Guia Definitivo de Sistemas Autônomos em Produção, desacoplar a governança global da execução especializada é o requisito primordial para obter escalabilidade horizontal e resiliência em sistemas autônomos distribuídos.

Pré-requisitos e Instalação

O ambiente requer Python 3.11 ou superior e uma instância operacional do RabbitMQ como barramento de mensagens para assegurar desacoplamento e durabilidade.

Execute os comandos abaixo para configurar o broker via Docker e instalar as dependências de rede e validação:

# Inicialização do RabbitMQ com interface de gerenciamento
docker run -d --name rabbitmq-broker \
  -p 5672:5672 -p 15672:15672 \
  -e RABBITMQ_DEFAULT_USER=guest \
  -e RABBITMQ_DEFAULT_PASS=guest \
  rabbitmq:3.13-management

# Instalação das bibliotecas Python
pip install aio-pika pydantic

Implementação Completa em Python

O script a seguir implementa a topologia completa de execução distribuída:
1. Modelagem formal de comandos e resultados via pydantic.
2. Declaração explícita de exchanges, filas duráveis e Dead-Letter Exchange (DLX) no RabbitMQ.
3. Despacho com confirmação de entrega do produtor (publisher confirms).
4. Pool de sub-agentes operando com controle de fluxo (prefetch count) e rejeição sem re-enfileiramento (requeue=False) para isolamento de payloads inválidos.

import asyncio
import json
import logging
from enum import Enum
from typing import Any, Dict
import aio_pika
from pydantic import BaseModel, Field, ValidationError

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] (%(name)s) %(message)s"
)
logger = logging.getLogger("AntigravityEngine")

class AgentSpecialty(str, Enum):
    SECURITY_AUDIT = "security_audit"
    DATA_NORMALIZER = "data_normalizer"
    METRICS_EXTRACTOR = "metrics_extractor"

class TaskPayload(BaseModel):
    task_id: str = Field(..., min_length=1)
    specialty: AgentSpecialty
    input_data: Dict[str, Any]
    max_retries: int = Field(default=3, ge=0)

class ExecutionResult(BaseModel):
    task_id: str
    status: str
    output: Dict[str, Any]
    error_message: str | None = None

RABBITMQ_URL = "amqp://guest:guest@localhost:5672/"
EXCHANGE_NAME = "antigravity.tasks"
DLX_EXCHANGE_NAME = "antigravity.dlx"
WORK_QUEUE_NAME = "antigravity.execution.queue"
DLQ_NAME = "antigravity.poison.dlq"

async def setup_infrastructure(channel: aio_pika.abc.AbstractChannel) -> None:
    dlx = await channel.declare_exchange(
        DLX_EXCHANGE_NAME, aio_pika.ExchangeType.DIRECT, durable=True
    )
    dlq = await channel.declare_queue(DLQ_NAME, durable=True)
    await dlq.bind(dlx, routing_key="poison")

    main_exchange = await channel.declare_exchange(
        EXCHANGE_NAME, aio_pika.ExchangeType.DIRECT, durable=True
    )
    work_queue = await channel.declare_queue(
        WORK_QUEUE_NAME,
        durable=True,
        arguments={
            "x-dead-letter-exchange": DLX_EXCHANGE_NAME,
            "x-dead-letter-routing-key": "poison",
        },
    )
    await work_queue.bind(main_exchange, routing_key="subtask")

class SpecializedSubAgent:
    def __init__(self, specialty: AgentSpecialty):
        self.specialty = specialty

    async def execute(self, payload: TaskPayload) -> Dict[str, Any]:
        logger.info("Executando agente %s na tarefa %s", self.specialty.value, payload.task_id)
        await asyncio.sleep(0.3)
        if self.specialty == AgentSpecialty.SECURITY_AUDIT:
            return {"vulnerabilities_found": 0, "status": "CLEAN"}
        if self.specialty == AgentSpecialty.DATA_NORMALIZER:
            return {"records_processed": len(payload.input_data), "status": "PARSED"}
        if self.specialty == AgentSpecialty.METRICS_EXTRACTOR:
            return {"latency_p99_ms": 12.4, "status": "AGGREGATED"}
        return {"status": "UNKNOWN_SPECIALTY"}

async def worker_consumer(worker_id: int, channel: aio_pika.abc.AbstractChannel) -> None:
    await channel.set_qos(prefetch_count=2)
    queue = await channel.get_queue(WORK_QUEUE_NAME)

    agents = {
        AgentSpecialty.SECURITY_AUDIT: SpecializedSubAgent(AgentSpecialty.SECURITY_AUDIT),
        AgentSpecialty.DATA_NORMALIZER: SpecializedSubAgent(AgentSpecialty.DATA_NORMALIZER),
        AgentSpecialty.METRICS_EXTRACTOR: SpecializedSubAgent(AgentSpecialty.METRICS_EXTRACTOR),
    }

    async with queue.iterator() as queue_iter:
        async for message in queue_iter:
            async with message.process(requeue=False, ignore_processed=True):
                try:
                    raw_body = json.loads(message.body.decode())
                    task = TaskPayload.model_validate(raw_body)
                    agent = agents.get(task.specialty)
                    if not agent:
                        raise ValueError(f"Agente nao mapeado: {task.specialty}")

                    output = await agent.execute(task)
                    result = ExecutionResult(
                        task_id=task.task_id,
                        status="COMPLETED",
                        output=output
                    )
                    logger.info("Worker %s finalizou tarefa %s: %s", worker_id, task.task_id, result.model_dump())
                    await message.ack()
                except (ValidationError, json.JSONDecodeError, ValueError) as err:
                    logger.error("Rejeitando mensagem malformada ou invalida no worker %s: %s", worker_id, str(err))
                    await message.nack(requeue=False)
                except Exception as unhandled_err:
                    logger.critical("Falha nao tratada no worker %s: %s", worker_id, str(unhandled_err))
                    await message.nack(requeue=False)

async def orchestrator_publisher(
    channel: aio_pika.abc.AbstractChannel,
    tasks: list[dict[str, Any]]
) -> None:
    exchange = await channel.get_exchange(EXCHANGE_NAME)
    for task_dict in tasks:
        raw_message = json.dumps(task_dict).encode()
        message = aio_pika.Message(
            body=raw_message,
            delivery_mode=aio_pika.DeliveryMode.PERSISTENT
        )
        confirmation = await exchange.publish(
            message,
            routing_key="subtask"
        )
        logger.info("Orquestrador enviou tarefa: %s, Confirmacao: %s", task_dict.get("task_id"), confirmation)

async def main() -> None:
    connection = await aio_pika.connect_robust(RABBITMQ_URL)
    async with connection:
        setup_channel = await connection.channel()
        await setup_infrastructure(setup_channel)
        await setup_channel.close()

        worker_channel = await connection.channel()
        publisher_channel = await connection.channel(publisher_confirms=True)

        tasks_to_run = [
            {
                "task_id": "T-SEC-01",
                "specialty": "security_audit",
                "input_data": {"target": "auth_service"},
                "max_retries": 1
            },
            {
                "task_id": "T-DAT-02",
                "specialty": "data_normalizer",
                "input_data": {"user_id": 1024, "action": "login"},
                "max_retries": 1
            },
            {
                "task_id": "T-MET-03",
                "specialty": "metrics_extractor",
                "input_data": {"window_seconds": 60},
                "max_retries": 1
            },
            {
                "task_id": "T-ERR-04",
                "specialty": "invalid_specialty",
                "input_data": {},
                "max_retries": 0
            }
        ]

        worker_tasks = [
            asyncio.create_task(worker_consumer(1, worker_channel)),
            asyncio.create_task(worker_consumer(2, worker_channel))
        ]

        await orchestrator_publisher(publisher_channel, tasks_to_run)
        await asyncio.sleep(2.0)

        for wt in worker_tasks:
            wt.cancel()
        await asyncio.gather(*worker_tasks, return_exceptions=True)

if __name__ == "__main__":
    asyncio.run(main())

Como Testar e Verificar a Saída

Com o container do RabbitMQ em execução, execute o pipeline diretamente via terminal:

python orchestrator_subagents.py

Saída Esperada no Console

O log exibirá a declaração das entidades AMQP, o despacho concorrente das sub-tarefas, o processamento paralelo balanceado entre os dois workers e o descarte determinístico da mensagem com formato inválido para a DLQ:

2026-03-30 14:15:00,102 [INFO] (AntigravityEngine) Orquestrador enviou tarefa: T-SEC-01, Confirmacao: None
2026-03-30 14:15:00,104 [INFO] (AntigravityEngine) Orquestrador enviou tarefa: T-DAT-02, Confirmacao: None
2026-03-30 14:15:00,105 [INFO] (AntigravityEngine) Orquestrador enviou tarefa: T-MET-03, Confirmacao: None
2026-03-30 14:15:00,107 [INFO] (AntigravityEngine) Orquestrador enviou tarefa: T-ERR-04, Confirmacao: None
2026-03-30 14:15:00,108 [INFO] (AntigravityEngine) Executando agente security_audit na tarefa T-SEC-01
2026-03-30 14:15:00,109 [INFO] (AntigravityEngine) Executando agente data_normalizer na tarefa T-DAT-02
2026-03-30 14:15:00,110 [ERROR] (AntigravityEngine) Rejeitando mensagem malformada ou invalida no worker 1: 1 validation error for TaskPayload
specialty
  Input should be 'security_audit', 'data_normalizer' or 'metrics_extractor' [type=enum, input_value='invalid_specialty', input_type=str]
2026-03-30 14:15:00,412 [INFO] (AntigravityEngine) Worker 1 finalizou tarefa T-SEC-01: {'task_id': 'T-SEC-01', 'status': 'COMPLETED', 'output': {'vulnerabilities_found': 0, 'status': 'CLEAN'}, 'error_message': None}
2026-03-30 14:15:00,413 [INFO] (AntigravityEngine) Worker 2 finalizou tarefa T-DAT-02: {'task_id': 'T-DAT-02', 'status': 'COMPLETED', 'output': {'records_processed': 2, 'status': 'PARSED'}, 'error_message': None}
2026-03-30 14:15:00,414 [INFO] (AntigravityEngine) Executando agente metrics_extractor na tarefa T-MET-03
2026-03-30 14:15:00,718 [INFO] (AntigravityEngine) Worker 1 finalizou tarefa T-MET-03: {'task_id': 'T-MET-03', 'status': 'COMPLETED', 'output': {'latency_p99_ms': 12.4, 'status': 'AGGREGATED'}, 'error_message': None}

Para validar o isolamento da mensagem rejeitada na Dead-Letter Queue via CLI:

docker exec -it rabbitmq-broker rabbitmqadmin get queue=antigravity.poison.dlq count=1

O comando retornará exatamente o payload T-ERR-04, comprovando que o pipeline manteve a execução sem travamento de threads ou consumo de recursos nos agentes especialistas.

Armadilhas Operacionais e Boas Práticas

  1. Loop de Mensagens Venenosas (Poison Messages): Ao processar tarefas com schemas incompatíveis ou instruções corrompidas, nunca execute nack(requeue=True). Sem limite estrito de tentativas, a mensagem retornará imediatamente ao topo da fila, consumindo 100% de CPU dos sub-agentes. Configure Dead-Letter Exchanges com roteamento explícito para análise forense assíncrona.
  2. Backpressure e Starvation de Conexões: Defina sempre prefetch_count explícito nas instâncias de consumidores. O valor padrão do RabbitMQ distribui todas as mensagens disponíveis em memória para os workers conectados, gerando desbalanceamento severo se uma sub-tarefa demandar tempo computacional desproporcional.
  3. Persistência e Garantia de Disco: A marcação delivery_mode=PERSISTENT combinada com filas duráveis protege os payloads contra reinicializações do broker, mas introduz latência de I/O em discos magnéticos ou volumes de rede lentos. Em produção, isole as partições de storage do broker sob unidades NVMe com montagem orientada a transações rápidas.
  4. Idempotência de Execução: Em sistemas distribuídos, a garantia de entrega é tipicamente at-least-once. Sub-agentes que efetuam mutações de estado externo (como escrita em bancos de dados ou chamadas de API externas) devem utilizar o identificador único task_id como chave de idempotência em cache distribuído (ex: Redis com TTL) antes de executar o payload.

Navegação da Trilha

]]>
Arquitetura de Agentes e Sub-agentes com Kafka e Gemini: Padrão Hierárquico Resiliente com Isolamento de Falhas https://zehro.digital/arquitetura-agentes-subagentes-kafka-gemini/ Tue, 08 Sep 2026 18:35:10 +0000 https://zehro.digital/arquitetura-agentes-sub-agentes-antigravity-gemini-3-8/ Ilustração técnica conceitual: Arquitetura de Agentes e Sub-agentes com Kafka e Gemini: Padrão Hierárquico Resiliente com Isolamento de Falhas

1. Contexto e Problema de Arquitetura

Diagrama de arquitetura vertical: fluxo de processamento, tratamento de poison pills e isolamento de falhas de infraestrutura downstream.
Topologia de mensageria resiliente com isolamento defensivo de erros em três camadas.

Sistemas autônomos baseados em LLMs (Large Language Models) que operam em ambiente de produção frequentemente falham quando desenhados como pipelines síncronos e monolíticos. A orquestração de múltiplos agentes exigida por fluxos complexos (onde um Agente Orquestrador Master decompõe um problema e delega tarefas especializadas para N Sub-agentes) impõe desafios críticos de latência, volatilidade de rede e descontinuidade de estado.

Quando Sub-agentes dependem de APIs externas de modelos de linguagem (como a API do Gemini) e de persistência em banco de dados, falhas de conectividade, estouramentos de limite de taxa (rate limits) e payloads malformados (poison pills) podem paralisar o consumo de tarefas ou provocar duplicidade e perda de processamento.

Para resolver o acoplamento síncrono e garantir a resiliência operacional, adota-se um padrão event-driven assíncrono ancorado no Apache Kafka. O Agente Master atua como produtor e consumidor de controle, dividindo o plano de execução em mensagens pub/sub distribuídas em tópicos dedicados. Os Sub-agentes consomem esses tópicos de forma concorrente em Consumer Groups distintos.

Semântica de Entrega e Trade-offs Arquiteturais

  1. Garantia At-Least-Once (Pelo Menos Uma Vez): Adota-se a confirmação condicionada por meio de commit manual de offsets no Kafka após a conclusão do processamento downstream ou redirecionamento defensivo. Isso previne a perda de mensagens em caso de queda do worker, exigindo a implementação de idempotência na camada de aplicação via chaves de tarefa (task_id).
  2. Desacoplamento Temporal vs. Latência de Eventos: O modelo de mensageria adiciona pequeno overhead de I/O em comparação com chamadas de função diretas em memória, mas previne falhas em cascata ao isolar oscilações de infraestrutura do fluxo principal do sistema.
  3. Isolamento em Três Camadas de Exceção: Para conter a contaminação de filas e evitar loops infinitos de reprocessamento, o fluxo de erro dos Sub-agentes é desacoplado rigorosamente em:
  4. Camada 1 (Dados/Schema): Captura de erros de decodificação de bytes e Poison Pills antes da execução de regras de negócio. Como o payload é inválido e nunca será processado com sucesso, a mensagem é enviada para a Dead Letter Queue (DLQ) e o offset original é confirmado (commit) para evitar o travamento da partição.
  5. Camada 2 (Integração Downstream): Tratamento de indisponibilidade de APIs externas (Gemini) com timeouts delimitados e backoff exponencial. Se as retentativas forem exauridas, a aplicação não realiza o commit do offset e encerra o processo (fail-fast). Isso evita a perda silenciosa de dados e garante que a mensagem permaneça na fila para reprocessamento assim que a API downstream for restabelecida.
  6. Camada 3 (Infraestrutura do Broker): Tratamento de falhas de conectividade com o próprio cluster Kafka, preservando a posição do offset não processado e disparando alertas de monitoramento.

2. Ambiente e Setup Reproduzível

Para validar localmente a arquitetura de mensageria sem dependências externas complexas de infraestrutura, utiliza-se a imagem oficial do Apache Kafka em modo KRaft (sem dependência do Zookeeper), parametrizada para nó único local.

Arquivo docker-compose.yml

version: '3.8'

services:
  kafka:
    image: apache/kafka:3.7.0
    container_name: kafka-agente
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: 'broker,controller'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@localhost:29093'
      KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092'
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
      KAFKA_CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list"]
      interval: 5s
      timeout: 10s
      retries: 5

Instalação de Dependências Python

Execute o comando abaixo para instalar as bibliotecas necessárias com tipagem e suporte ao Kafka e Pydantic:

pip install confluent-kafka==2.3.0 pydantic==2.6.4 requests==2.31.0

3. Implementação Completa com Tratamento Defensivo e Resiliente

A implementação é dividida em dois arquivos Python completos e auditáveis:
1. schemas.py: Contratos de dados com validações estritas via Pydantic (strict=True).
2. subagent_worker.py: Worker do Sub-agente especialista com isolamento defensivo nas três camadas, manipulação segura de payload bruto e confirmação condicionada de offsets.

Arquivo schemas.py

from typing import Dict, Any
from pydantic import BaseModel, Field, ConfigDict

class TaskPayload(BaseModel):
    model_config = ConfigDict(strict=True)

    task_id: str = Field(..., description="UUID unico da tarefa para garantia de idempotencia")
    agent_name: str = Field(..., description="Nome do sub-agente destino")
    instruction: str = Field(..., description="Instrucao detalhada para o processamento")
    context_data: Dict[str, Any] = Field(default_factory=dict, description="Metadados complementares")

Arquivo subagent_worker.py

import sys
import json
import time
import logging
import requests
from typing import Optional
from confluent_kafka import Consumer, Producer, KafkaError, KafkaException, Message
from pydantic import ValidationError
from schemas import TaskPayload

# Configuracao de Logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("SubAgentWorker")

# Configuracoes de Infraestrutura
KAFKA_BOOTSTRAP_SERVERS = "localhost:9092"
INPUT_TOPIC = "agent.tasks.analyzer"
DLQ_TOPIC = "agent.tasks.analyzer.dlq"
CONSUMER_GROUP = "subagent-analyzer-group"

# Configurações da API do Gemini (Mock ou Real)
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
GEMINI_API_KEY = "MOCK_API_KEY_FOR_TESTING"

class DownstreamAPIError(Exception):
    """Excecao lancada quando a API externa (Gemini) falha de forma irrecuperavel."""
    pass

class SubAgentWorker:
    def __init__(self) -> None:
        # Inicializacao do Consumidor Kafka
        consumer_conf = {
            "bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS,
            "group.id": CONSUMER_GROUP,
            "auto.offset.reset": "earliest",
            "enable.auto.commit": False,  # Controle manual e condicionado de offsets
        }
        self.consumer = Consumer(consumer_conf)

        # Inicializacao do Produtor para DLQ
        producer_conf = {
            "bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS,
            "acks": "all",  # Garantia de persistencia no broker antes do ack
            "retries": 5,
            "max.in.flight.requests.per.connection": 1,
        }
        self.producer = Producer(producer_conf)

    def start(self) -> None:
        """Inicia o loop de consumo do Kafka."""
        try:
            self.consumer.subscribe([INPUT_TOPIC])
            logger.info(f"Sub-agente iniciado. Consumindo topico: '{INPUT_TOPIC}'")

            while True:
                msg: Optional[Message] = self.consumer.poll(timeout=1.0)
                if msg is None:
                    continue

                if msg.error():
                    if msg.error().code() == KafkaError._PARTITION_EOF:
                        continue
                    else:
                        logger.error(f"Erro de infraestrutura do Kafka: {msg.error()}")
                        # Camada 3: Erro critico de infraestrutura do Broker. Nao commita e encerra.
                        raise KafkaException(msg.error())

                self._process_message_lifecycle(msg)

        except KeyboardInterrupt:
            logger.info("Encerrando sub-agente graciosamente...")
        finally:
            self.consumer.close()

    def _process_message_lifecycle(self, msg: Message) -> None:
        """Gerencia o ciclo de vida da mensagem com isolamento defensivo de erros."""
        raw_payload: Optional[bytes] = msg.value()

        # Camada 1: Isolamento de Decode e Schema (Poison Pills)
        if raw_payload is None:
            logger.warning("Mensagem com payload nulo recebida. Enviando para DLQ.")
            self._route_to_dlq(msg, "Payload nulo")
            self._commit_offset_safely(msg)
            return

        try:
            # Decodificacao segura de bytes para string e parse JSON
            decoded_str = raw_payload.decode("utf-8")
            parsed_json = json.loads(decoded_str)

            # Validacao estrita com Pydantic
            payload = TaskPayload(**parsed_json)
            logger.info(f"Payload validado com sucesso. Task ID: {payload.task_id}")

        except (UnicodeDecodeError, json.JSONDecodeError, ValidationError) as err:
            logger.error(f"Camada 1 - Poison Pill detectada: {str(err)}")
            # Como o payload esta corrompido, a retentativa nao resolvera. Envia para DLQ e commita.
            self._route_to_dlq(msg, str(err))
            self._commit_offset_safely(msg)
            return

        # Camada 2: Execucao Downstream (Chamada ao Gemini com Retentativas)
        try:
            self._execute_downstream_task_with_retry(payload)
            # Sucesso absoluto no processamento downstream. Commita o offset.
            self._commit_offset_safely(msg)
            logger.info(f"Task {payload.task_id} processada e offset confirmado.")

        except DownstreamAPIError as err:
            # Erro de infraestrutura downstream exaurido.
            # VIOLACAO DE SRE EVITADA: Nao enviamos para a DLQ e NAO commitamos o offset.
            # O worker deve falhar para evitar perda silenciosa de dados.
            logger.critical(
                f"Camada 2 - Falha critica downstream exaurida para a Task {payload.task_id}. "
                f"Encerrando aplicacao sem realizar commit do offset. Erro: {str(err)}"
            )
            sys.exit(1)

    def _execute_downstream_task_with_retry(self, payload: TaskPayload, max_retries: int = 3) -> None:
        """Executa a chamada para a API do Gemini aplicando backoff exponencial."""
        headers = {"Content-Type": "application/json"}
        params = {"key": GEMINI_API_KEY}

        # Payload estruturado para a API do Gemini
        data = {
            "contents": [{
                "parts": [{
                    "text": f"Instrucao: {payload.instruction}. Contexto: {json.dumps(payload.context_data)}"
                }]
            }]
        }

        backoff_sec = 2.0
        for attempt in range(1, max_retries + 1):
            try:
                logger.info(f"Tentativa {attempt} de {max_retries} - Chamando API Gemini...")

                # Timeout delimitado para evitar travamento de thread
                response = requests.post(
                    GEMINI_API_URL,
                    headers=headers,
                    params=params,
                    json=data,
                    timeout=10.0
                )

                if response.status_code == 200:
                    result = response.json()
                    logger.info(f"Resposta recebida do Gemini para Task {payload.task_id}")
                    # Processamento de sucesso simulado
                    return

                elif response.status_code in [429, 500, 502, 503, 504]:
                    logger.warning(
                        f"Erro transitorio HTTP {response.status_code} recebido da API Gemini. "
                        f"Aplicando backoff de {backoff_sec}s."
                    )
                else:
                    # Erros HTTP 4xx (exceto 429) indicam falhas de integracao nao transitorias
                    raise DownstreamAPIError(f"Erro nao transitorio HTTP {response.status_code}: {response.text}")

            except requests.exceptions.RequestException as req_err:
                logger.warning(f"Falha de conexao de rede na tentativa {attempt}: {str(req_err)}")

            if attempt < max_retries:
                time.sleep(backoff_sec)
                backoff_sec *= 2.0  # Backoff exponencial

        raise DownstreamAPIError(f"Retentativas exauridas para a API Gemini apos {max_retries} tentativas.")

    def _route_to_dlq(self, msg: Message, error_reason: str) -> None:
        """Envia o payload original integro para o topico de Dead Letter Queue (DLQ)."""
        try:
            dlq_headers = {
                "error_reason": error_reason.encode("utf-8"),
                "original_topic": msg.topic().encode("utf-8"),
                "original_partition": str(msg.partition()).encode("utf-8"),
                "original_offset": str(msg.offset()).encode("utf-8")
            }

            # Publicacao sincrona para garantir a persistencia na DLQ antes de prosseguir
            self.producer.produce(
                topic=DLQ_TOPIC,
                value=msg.value(),
                key=msg.key(),
                headers=list(dlq_headers.items())
            )
            self.producer.flush()
            logger.info(f"Mensagem enviada com sucesso para a DLQ '{DLQ_TOPIC}'.")
        except Exception as e:
            logger.critical(f"Falha catastrofica ao tentar publicar na DLQ: {str(e)}")
            # Se nao for possivel gravar na DLQ, nao podemos commitar o offset original
            raise e

    def _commit_offset_safely(self, msg: Message) -> None:
        """Realiza o commit manual do offset de forma sincrona."""
        try:
            self.consumer.commit(message=msg, asynchronous=False)
        except KafkaException as e:
            logger.error(f"Falha ao realizar commit do offset: {str(e)}")
            # Camada 3: Se o commit falhar, a mensagem sera reprocessada.
            raise e

if __name__ == "__main__":
    worker = SubAgentWorker()
    worker.start()

4. Roteiro CLI e Validação de Resiliência

Siga rigorosamente os passos abaixo para subir o ambiente, criar os tópicos necessários, simular cenários de falha e validar o comportamento do sistema.

Passo 1: Inicializar a Infraestrutura Local

Suba o container do Apache Kafka utilizando o Docker Compose:

docker-compose up -d

Verifique se o broker está saudável e pronto para receber conexões:

docker ps --filter "name=kafka-agente"

Passo 2: Criação dos Tópicos no Kafka

Crie o tópico principal de tarefas (agent.tasks.analyzer) e o tópico de mensagens mortas (agent.tasks.analyzer.dlq):

# Criar topico principal
docker exec -it kafka-agente kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --topic agent.tasks.analyzer \
  --partitions 3 \
  --replication-factor 1

# Criar topico de DLQ
docker exec -it kafka-agente kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --topic agent.tasks.analyzer.dlq \
  --partitions 1 \
  --replication-factor 1

Valide a criação bem-sucedida de ambos os tópicos:

docker exec -it kafka-agente kafka-topics.sh --bootstrap-server localhost:9092 --list

Passo 3: Executar o Sub-agente Worker

Abra um terminal dedicado e inicie o script do worker:

python subagent_worker.py

5. Laboratório de Testes e Simulação de Falhas

Cenário A: Processamento com Sucesso (Caminho Feliz)

Abra um novo terminal e envie uma mensagem válida para o tópico principal utilizando o produtor nativo do Kafka:

docker exec -it kafka-agente kafka-console-producer.sh \
  --bootstrap-server localhost:9092 \
  --topic agent.tasks.analyzer

Cole o seguinte payload JSON válido no terminal do console-producer e pressione Enter:

{"task_id": "8f4b2c8a-1a2b-3c4d-5e6f-7a8b9c0d1e2f", "agent_name": "analyzer", "instruction": "Analise o sentimento desta avaliacao", "context_data": {"review": "Excelente produto, superou as expectativas!"}}

Comportamento esperado no terminal do Worker:
* O worker decodifica o JSON com sucesso.
* O Pydantic valida o schema estruturado.
* É feita a chamada simulada para a API do Gemini.
* O offset é commitado manualmente.


Cenário B: Injeção de Poison Pill (Camada 1 – Erro de Schema)

No terminal do kafka-console-producer.sh, envie um payload malformado (violando a tipagem estrita do Pydantic ou contendo JSON inválido):

{"task_id": "invalid-uuid-format", "agent_name": 12345, "instruction": null}

Comportamento esperado no terminal do Worker:
* O worker intercepta a falha de validação na Camada 1.
* A mensagem corrompida é enviada para o tópico agent.tasks.analyzer.dlq.
* O offset da mensagem original é commitado, liberando a partição para as próximas mensagens.

Inspeção do Tópico de DLQ:
Execute o comando abaixo para ler as mensagens enviadas para a DLQ e validar se o payload malformado foi isolado com sucesso:

docker exec -it kafka-agente kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic agent.tasks.analyzer.dlq \
  --from-beginning \
  --property print.headers=true

Cenário C: Falha Crítica Downstream (Camada 2 – Sem Perda de Dados)

Para simular uma falha de rede ou indisponibilidade total da API downstream (Gemini), podemos alterar o endereço da API no arquivo subagent_worker.py para um IP de destino inexistente ou simular o bloqueio de tráfego de saída.

  1. No arquivo subagent_worker.py, altere temporariamente a variável GEMINI_API_URL para um endpoint inválido:
    “`python
    GEMINI_API_URL = “https://192.0.2.1/v1beta/models/gemini-pro” # IP reservado para testes que gera timeout
2. Reinicie o worker:
   ```bash
   python subagent_worker.py
   ```
3. Envie uma mensagem válida pelo console-producer:
   ```json
   {"task_id": "9e8d7c6b-5a4b-3c2d-1e0f-9a8b7c6d5e4f", "agent_name": "analyzer", "instruction": "Processamento sob indisponibilidade", "context_data": {}}
   ```

**Comportamento esperado no terminal do Worker:**
* O worker tentará realizar a chamada HTTP.
* Ocorrerá timeout de conexão.
* O worker executará as 3 retentativas com backoff exponencial.
* Ao exaurir as tentativas, o worker lançará `DownstreamAPIError` e **encerrará a execução (sys.exit(1)) sem realizar o commit do offset**.

**Verificação de Offsets Não Commitados (Garantia de Não Perda):**
Execute o comando abaixo para inspecionar o status do grupo de consumidores:

```bash
docker exec -it kafka-agente kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --describe \
  --group subagent-analyzer-group

Resultado esperado na tabela de offsets:
Você observará que o valor de CURRENT-OFFSET para a partição correspondente estará posicionado exatamente atrás do LOG-END-OFFSET (gerando um LAG de 1). Isso prova que a mensagem não foi perdida nem descartada na DLQ; ela permanece segura no broker aguardando o restabelecimento do worker e da API downstream.

]]>
Como Implementar Dead Letter Queue (DLQ) no RabbitMQ com Python e Pika https://zehro.digital/implementar-dead-letter-queue-dlq-rabbitmq-python-pika/ Sat, 05 Sep 2026 13:56:32 +0000 https://zehro.digital/implementar-dead-letter-queue-dlq-rabbitmq-python-pika/ Diagrama de arquitetura mostrando produtor, fila principal, exchange de DLX, Dead Letter Queue e consumidor em ambiente distribuído

O Problema Prático e Contexto Direto

Sistemas assíncronos baseados em mensageria enfrentam falhas transitórias e estruturais no processamento de cargas úteis. Quando um consumidor falha ao processar uma mensagem devido a erros de desserialização, indisponibilidade temporária de um banco de dados relacional ou exceções de lógica de negócio irrecuperáveis, o comportamento padrão de reencaminhar a mensagem de volta para a mesma fila (requeue=true) gera um loop infinito de consumo. Esse cenário satura a CPU, esgota recursos de rede e bloqueia o processamento de novas mensagens válidas.

A implementação de uma Dead Letter Queue (DLQ) resolve esse problema ao estabelecer um mecanismo de desvio para mensagens que não puderam ser processadas com sucesso. No RabbitMQ, esse fluxo é configurado associando uma fila principal a um Dead Letter Exchange (DLX). Quando uma mensagem é rejeitada explicitamente sem requeila (basic_nack com requeue=False), expira por TTL ou atinge o limite máximo de comprimento da fila (x-max-length), o broker a reencaminha automaticamente para o DLX, que por sua vez a direciona para a DLQ dedicada. Isso isola o erro, preserva a carga útil para auditoria e permite que o sistema principal continue operando sem interrupções.


Pré-requisitos e Instalação

Para executar o laboratório e a implementação deste tutorial, certifique-se de possuir o Python 3.10 ou superior instalado em seu ambiente. O RabbitMQ deve estar acessível via Docker.

Execute os comandos abaixo no terminal para iniciar uma instância local do RabbitMQ com o painel de gerenciamento ativo e instalar a biblioteca cliente pika:

# Inicializa o container Docker do RabbitMQ com portas padrão expostas
docker run -d --name rabbitmq-local \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:3.12-management

# Instala a biblioteca Pika para comunicação com o RabbitMQ em Python
pip install pika==1.3.2

Implementação Completa em Python (Código Funcional)

O script a seguir contém a implementação completa e executável dividida em duas partes lógicas: o produtor que envia mensagens para a fila principal e o consumidor que processa essas mensagens, simulando uma falha e realizando o desvio para a DLQ através do método basic_nack com requeue=False.

Salve o conteúdo abaixo como main.py:

import json
import logging
import time
import pika
from pika.adapters.blocking_connection import BlockingChannel
from pika.exceptions import AMQPConnectionError

# Configuração de logging estruturado
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("ZehroDigital.RabbitMQ")

# Constantes de Configuração
RABBITMQ_HOST = "localhost"
RABBITMQ_PORT = 5672

DLX_EXCHANGE = "order.dlx.exchange"
DLQ_QUEUE = "order.dlq.queue"

MAIN_EXCHANGE = "order.main.exchange"
MAIN_QUEUE = "order.main.queue"
MAIN_ROUTING_KEY = "order.process"

def setup_infrastructure(channel: BlockingChannel) -> None:
    """Declara exchanges, filas e bindings para o fluxo principal e DLQ."""
    # Declaração da infraestrutura da DLQ
    channel.exchange_declare(
        exchange=DLX_EXCHANGE,
        exchange_type="direct",
        durable=True
    )
    channel.queue_declare(
        queue=DLQ_QUEUE,
        durable=True
    )
    channel.queue_bind(
        queue=DLQ_QUEUE,
        exchange=DLX_EXCHANGE,
        routing_key=MAIN_ROUTING_KEY
    )

    # Declaração da infraestrutura principal com argumentos de DLX
    channel.exchange_declare(
        exchange=MAIN_EXCHANGE,
        exchange_type="direct",
        durable=True
    )

    args = {
        "x-dead-letter-exchange": DLX_EXCHANGE,
        "x-dead-letter-routing-key": MAIN_ROUTING_KEY
    }

    channel.queue_declare(
        queue=MAIN_QUEUE,
        durable=True,
        arguments=args
    )
    channel.queue_bind(
        queue=MAIN_QUEUE,
        exchange=MAIN_EXCHANGE,
        routing_key=MAIN_ROUTING_KEY
    )
    logger.info("Infraestrutura RabbitMQ declarada com sucesso.")

def run_producer() -> None:
    """Publica mensagens de teste na exchange principal."""
    try:
        connection = pika.BlockingConnection(
            pika.ConnectionParameters(host=RABBITMQ_HOST, port=RABBITMQ_PORT)
        )
        channel = connection.channel()

        setup_infrastructure(channel)

        # Ativa Publisher Confirms
        channel.confirm_delivery()

        payloads = [
            {"order_id": 1001, "item": "Servidor Dedicado", "status": "valid"},
            {"order_id": 1002, "item": "Payload Corrompido", "status": "invalid"},
            {"order_id": 1003, "item": "Load Balancer", "status": "valid"}
        ]

        for payload in payloads:
            message_body = json.dumps(payload)
            channel.basic_publish(
                exchange=MAIN_EXCHANGE,
                routing_key=MAIN_ROUTING_KEY,
                body=message_body,
                properties=pika.BasicProperties(
                    delivery_mode=2  # Mensagem persistente
                )
            )
            logger.info(f"Mensagem publicada com sucesso: {message_body}")

        connection.close()
    except AMQPConnectionError as e:
        logger.error(f"Falha de conexão com o RabbitMQ no produtor: {e}")

def callback(channel: BlockingChannel, method, properties, body: bytes) -> None:
    """Callback de consumo que simula erro de processamento e envia para a DLQ."""
    try:
        data = json.loads(body.decode("utf-8"))
        logger.info(f"Mensagem recebida para processamento: {data}")

        # Simula erro crítico em payloads marcados como 'invalid'
        if data.get("status") == "invalid":
            raise ValueError(f"Erro estrutural irrecuperável no pedido {data.get('order_id')}")

        # Simula processamento bem-sucedido
        time.sleep(0.5)
        channel.basic_ack(delivery_tag=method.delivery_tag)
        logger.info(f"Mensagem processada e confirmada (ACK): {data.get('order_id')}")

    except json.JSONDecodeError as jde:
        logger.error(f"Erro de desserialização JSON: {jde}. Descartando para DLQ.")
        channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

    except ValueError as ve:
        logger.warning(f"Erro de negócio detectado: {ve}. Encaminhando para DLQ.")
        channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

    except Exception as exc:
        logger.error(f"não esperado: {exc}. Reenfileirando temporariamente.")
        channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)

def run_consumer() -> None:
    """Inicia o consumo contínuo da fila principal."""
    try:
        connection = pika.BlockingConnection(
            pika.ConnectionParameters(host=RABBITMQ_HOST, port=RABBITMQ_PORT)
        )
        channel = connection.channel()

        setup_infrastructure(channel)

        # Configura QoS para processar uma mensagem por vez
        channel.basic_qos(prefetch_count=1)

        channel.basic_consume(
            queue=MAIN_QUEUE,
            on_message_callback=callback,
            auto_ack=False
        )

        logger.info("Consumidor iniciado. Aguardando mensagens. Pressione CTRL+C para sair.")
        channel.start_consuming()

    except AMQPConnectionError as e:
        logger.error(f"Falha de conexão com o RabbitMQ no consumidor: {e}")
    except KeyboardInterrupt:
        logger.info("Consumidor interrompido pelo operador.")
        if connection.is_open:
            connection.close()

if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1 and sys.argv[1] == "producer":
        run_producer()
    elif len(sys.argv) > 1 and sys.argv[1] == "consumer":
        run_consumer()
    else:
        print("Uso: python main.py [producer|consumer]")

Como Testar e Verificar a Saída

Para validar o fluxo completo de mensageria, incluindo o desvio para a Dead Letter Queue, execute os comandos em dois terminais separados.

Terminal 1: Iniciar o Consumidor

python main.py consumer

Terminal 2: Executar o Produtor

python main.py producer

Saída Esperada no Console do Consumidor

Ao executar o produtor, o consumidor processará as três mensagens injetadas. A segunda mensagem gerará uma exceção intencional, sendo rejeitada e enviada para a DLQ.

2026-03-30 10:00:00,123 [INFO] ZehroDigital.RabbitMQ: Infraestrutura RabbitMQ declarada com sucesso.
2026-03-30 10:00:00,125 [INFO] ZehroDigital.RabbitMQ: Consumidor iniciado. Aguardando mensagens. Pressione CTRL+C para sair.
2026-03-30 10:00:05,450 [INFO] ZehroDigital.RabbitMQ: Mensagem recebida para processamento: {'order_id': 1001, 'item': 'Servidor Dedicado', 'status': 'valid'}
2026-03-30 10:00:05,955 [INFO] ZehroDigital.RabbitMQ: Mensagem processada e confirmada (ACK): 1001
2026-03-30 10:00:05,958 [INFO] ZehroDigital.RabbitMQ: Mensagem recebida para processamento: {'order_id': 1002, 'item': 'Payload Corrompido', 'status': 'invalid'}
2026-03-30 10:00:05,960 [WARNING] ZehroDigital.RabbitMQ: Erro de negócio detectado: Erro estrutural irrecuperável no pedido 1002. Encaminhando para DLQ.
2026-03-30 10:00:05,965 [INFO] ZehroDigital.RabbitMQ: Mensagem recebida para processamento: {'order_id': 1003, 'item': 'Load Balancer', 'status': 'valid'}
2026-03-30 10:00:06,470 [INFO] ZehroDigital.RabbitMQ: Mensagem processadas e confirmada (ACK): 1003

Você pode validar que a mensagem com order_id: 1002 foi entregue na fila order.dlq.queue acessando o painel de gerenciamento do RabbitMQ em http://localhost:15672 (credenciais padrão guest/guest) ou consumindo diretamente a fila de DLQ.


Armadilhas Operacionais e Boas Práticas

A operação de filas de mensagens em ambientes produtivos exige rigor arquitetural para evitar degradação de performance e perda de dados. Considere os seguintes pontos críticos:

  1. Loops de Mensagens Venenosas (Poison Messages): Se uma mensagem causa falha devido a um erro de código no consumidor, enviá-la para a DLQ e depois tentar reprocessá-la sem correção prévia resultará no mesmo erro. Implemente contadores de tentativas utilizando os metadados do cabeçalho x-death que o RabbitMQ injeta automaticamente em mensagens mortas, descartando mensagens que excedam um limite máximo de reiterações.
  2. Durabilidade e Persistência: Certifique-se de que tanto as exchanges quanto as filas (durable=True) e as mensagens (delivery_mode=2) estejam configuradas para persistência em disco. Sem isso, um restart abrupto do broker resultará na perda total da fila de DLQ e de seu conteúdo.
  3. Quorum Queues vs Classic Queues: Para ambientes de alta disponibilidade, utilize Quorum Queues em vez de Classic Queues duráveis. Quorum queues utilizam o algoritmo de consenso Raft, garantindo replicação segura de dados entre os nós do cluster do RabbitMQ e evitando split-brain ou perda de mensagens em caso de falha de hardware.
  4. Monitoramento e Alertas: A presença de mensagens em uma DLQ indica anomalias funcionais ou sistêmicas. Configure alertas baseados em métricas do Prometheus/Grafana para monitorar o tamanho da fila (rabbitmq_queue_messages{queue="order.dlq.queue"}) de modo a disparar notificações imediatas para a equipe de engenharia quando o valor for superior a zero.
]]>
Como Configurar Dead Letter Queue (DLQ) no RabbitMQ com Python e Pika https://zehro.digital/configurar-dead-letter-queue-rabbitmq-python-pika/ Sat, 05 Sep 2026 00:26:18 +0000 https://zehro.digital/configurar-dead-letter-queue-rabbitmq-python-pika/ Diagrama de arquitetura mostrando o fluxo de mensagens entre produtor, exchange principal, fila, DLX e Dead Letter Queue

O Problema: Falhas em Consumidores e Travamentos por Mensagens Inválidas

Sistemas assíncronos baseados em filas de mensagens frequentemente lidam com dados corrompidos, falhas temporárias de dependências externas ou alterações de payload que quebram o contrato de desserialização. Quando um consumidor falha ao processar uma mensagem e o comportamento padrão de reenvio (requeue=True) está ativo, a mensagem retorna imediatamente para o topo da fila. Esse comportamento gera um ciclo infinito de consumo, consome ciclos de CPU e bloqueia o processamento de novas mensagens válidas, fenômeno conhecido como travamento em cascata por poison messages.

A solução arquitetural para esse cenário é o isolamento de falhas por meio de uma Dead Letter Queue (DLQ). Quando uma mensagem falha de forma irrecuperável, ela deve ser desviada para uma infraestrutura separada para auditoria e análise posterior, permitindo que o fluxo principal continue operando sem interrupções.

Topologia e Arquitetura da Solução

O RabbitMQ gerencia o direcionamento de mensagens rejeitadas através de uma Dead Letter Exchange (DLX). A arquitetura exige a criação de entidades acopladas logicamente, mas isoladas fisicamente:

  1. Exchange Principal (orders-exchange): Recebe as publicações iniciais do produtor.
  2. Fila Principal (orders-queue): Configurada com argumentos que apontam para a DLX e a chave de roteamento correspondente.
  3. Dead Letter Exchange (dlx-exchange): Exchange do tipo direct ou topic responsável por receber mensagens mortas.
  4. Dead Letter Queue (orders-dlq): Fila onde as mensagens rejeitadas, expiradas ou excedentes são armazenadas para inspeção.
Fluxograma detalhando o processo de rejeição de mensagens com basic_nack e direcionamento para a DLQ
Fluxo operacional de encaminhamento de mensagens inválidas para a Dead Letter Queue via DLX

Quando uma mensagem atinge qualquer uma das três condições de descarte — rejeição explícita com requeue=False, expiração de TTL (x-message-ttl) ou estouro da capacidade máxima da fila (x-max-length) —, o broker a republica automaticamente para a DLX informada nos metadados da fila principal, preservando os atributos originais de cabeçalho e roteamento.

Implementação Passo a Passo em Python

O código a seguir implementa a topologia completa utilizando a biblioteca pika. O exemplo contempla a configuração da DLQ, a publicação persistente com confirmação de entrega e o consumo com tratamento seguro de exceções.

import json
import logging
import pika
from pika.exceptions import AMQPConnectionError, AMQPChannelError

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("rabbitmq-dlq-worker")

RABBITMQ_HOST = "localhost"
RABBITMQ_PORT = 5672
RABBITMQ_USER = "guest"
RABBITMQ_PASS = "guest"

# Nomes das entidades
MAIN_EXCHANGE = "orders.exchange"
MAIN_QUEUE = "orders.queue"
ROUTING_KEY = "order.created"

DLX_EXCHANGE = "dlx.exchange"
DLX_QUEUE = "orders.dlq"
DLX_ROUTING_KEY = "order.dead"

def setup_rabbitmq_topology(channel):
    """Declara exchanges, filas e vínculos garantindo a idempotência da topologia."""
    # Configuração da infraestrutura de Dead Letter
    channel.exchange_declare(
        exchange=DLX_EXCHANGE,
        exchange_type="direct",
        durable=True
    )
    channel.queue_declare(
        queue=DLX_QUEUE,
        durable=True
    )
    channel.queue_bind(
        queue=DLX_QUEUE,
        exchange=DLX_EXCHANGE,
        routing_key=DLX_ROUTING_KEY
    )

    # Configuração da infraestrutura principal com apontamento para a DLX
    channel.exchange_declare(
        exchange=MAIN_EXCHANGE,
        exchange_type="direct",
        durable=True
    )

    args = {
        "x-dead-letter-exchange": DLX_EXCHANGE,
        "x-dead-letter-routing-key": DLX_ROUTING_KEY,
    }

    channel.queue_declare(
        queue=MAIN_QUEUE,
        durable=True,
        arguments=args
    )
    channel.queue_bind(
        queue=MAIN_QUEUE,
        exchange=MAIN_EXCHANGE,
        routing_key=ROUTING_KEY
    )
    logger.info("Topologia RabbitMQ declarada com sucesso.")

def publish_message(channel, payload: dict):
    """Publica uma mensagem garantindo persistência e confirmação de entrega."""
    channel.confirm_delivery()
    message_body = json.dumps(payload)

    try:
        channel.basic_publish(
            exchange=MAIN_EXCHANGE,
            routing_key=ROUTING_KEY,
            body=message_body,
            properties=pika.BasicProperties(
                delivery_mode=2,  # Torna a mensagem persistente em disco
                content_type="application/json"
            )
        )
        logger.info("Mensagem publicada com sucesso: %s", payload.get("id"))
    except Exception as e:
        logger.error("Falha ao publicar mensagem: %s", e)
        raise

def process_message(ch, method, properties, body):
    """Consome a mensagem, simulando falhas estruturais e tratamento de exceções."""
    try:
        data = json.loads(body)
        logger.info("Processando pedido ID: %s", data.get("id"))

        # Simula erro crítico de validação ou payload corrompido
        if "fail" in data and data["fail"] is True:
            raise ValueError("Payload corrompido ou erro de regra de negócio irrecuperável.")

        # Confirmação de processamento bem-sucedido
        ch.basic_ack(delivery_tag=method.delivery_tag)
        logger.info("Pedido ID %s processado com sucesso.", data.get("id"))

    except json.JSONDecodeError as jde:
        logger.error("Erro de decodificação JSON. Descartando para DLQ. Erro: %s", jde)
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

    except ValueError as ve:
        logger.error("Erro crítico de negócio. Encaminhando para DLQ. Erro: %s", ve)
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

    except Exception as exc:
        logger.critical("Erro sistêmico inesperado. Reenfileuramento temporário. Erro: %s", exc)
        # Em falhas de infraestrutura transitórias, o requeue=True é aceitável, 
        # mas deve ser limitado por contadores de retentativa para evitar loops infinitos.
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)

def main():
    credentials = pika.PlainCredentials(RABBITMQ_USER, RABBITMQ_PASS)
    parameters = pika.ConnectionParameters(
        host=RABBITMQ_HOST,
        port=RABBITMQ_PORT,
        credentials=credentials,
        heartbeat=600,
        blocked_connection_timeout=300
    )

    try:
        connection = pika.BlockingConnection(parameters)
        channel = connection.channel()

        setup_rabbitmq_topology(channel)

        # Exemplo de publicação de teste
        publish_message(channel, {"id": 101, "item": "server", "fail": False})
        publish_message(channel, {"id": 102, "item": "database", "fail": True})

        channel.basic_qos(prefetch_count=1)
        channel.basic_consume(
            queue=MAIN_QUEUE,
            on_message_callback=process_message,
            auto_ack=False
        )

        logger.info("Iniciando consumo de mensagens. Pressione CTRL+C para sair.")
        channel.start_consuming()

    nutí_except = (AMQPConnectionError, AMQPChannelError)
    except n_exc as e:
        logger.error("Erro de conexão AMQP: %s", e)
    except KeyboardInterrupt:
        logger.info("Consumidor interrompido pelo operador.")
        if 'connection' in locals() and connection.is_open:
            connection.close()

if __name__ == "__main__":
    main()

Tabela de Parâmetros e Configurações

A tabela abaixo descreve os argumentos essenciais utilizados para configurar o comportamento de Dead Letter nas filas do RabbitMQ.

Parâmetro Tipo Descrição Técnica
x-dead-letter-exchange string Define a Exchange para a qual as mensagens mortas serão enviadas.
x-dead-letter-routing-key string Sobrescreve a routing key original ao republicar a mensagem na DLX.
x-message-ttl integer Tempo de vida da mensagem em milissegundos antes de expirar e ir para a DLQ.
x-max-length integer Número máximo de mensagens na fila; excedentes são direcionadas à DLX.

Armadilhas Operacionais em Produção

A implementação de DLQs introduz desafios operacionais específicos que exigem planejamento de infraestrutura:

  • Loops de Poison Messages: Se o consumidor processar a mensagem da DLQ e tentar devolvê-la para a fila principal sem corrigir o erro estrutural, cria-se um loop infinito de reenvio entre a DLQ e a fila principal. A DLQ deve ser estritamente isolada de qualquer processo de reenvio automático.
  • Monitoramento e Alertas: Filas de DLQ não devem acumular mensagens silenciosamente. Configure métricas baseadas no tamanho da fila (rabbitmq_queue_messages{queue="orders.dlq"}) com limiar de alerta estrito maior que zero.
  • Classic Queues vs Quorum Queues: Para ambientes de alta disponibilidade, prefira Quorum Queues em vez de Classic Queues para a DLQ. Quorum Queues utilizam o algoritmo Raft para replicação em disco baseada em consenso, reduzindo o risco de perda de mensagens mortas em caso de falha de nó.

Checklist Pré-Deploy

  • [ ] A Exchange DLX está declarada com durable=True.
  • [ ] A fila DLQ está vinculada à DLX utilizando a routing-key correta.
  • [ ] A fila principal possui os argumentos x-dead-letter-exchange e x-dead-letter-routing-key injetados na declaração.
  • [ ] O produtor implementa channel.confirm_delivery() e define delivery_mode=2.
  • [ ] O consumidor utiliza auto_ack=False e invoca explicitamente basic_nack(requeue=False) em exceções de desserialização e regras de negócio.
  • [ ] Existe monitoramento ativo para alertar sobre o incremento de mensagens na DLQ.
]]>
Arquitetura de Mensageria Assíncrona: RabbitMQ e Apache Kafka em Sistemas de Alta Resiliência https://zehro.digital/engenharia-mensageria-assincrona-rabbitmq-kafka/ Fri, 04 Sep 2026 16:14:28 +0000 https://zehro.digital/engenharia-mensageria-assincrona-rabbitmq-kafka/ Arquitetura de Mensageria Assíncrona: RabbitMQ e Apache Kafka em Sistemas de Alta Resiliência

A Anatomia do Acoplamento Oculto e a Ilusão da Resiliência

Sistemas distribuídos modernos frequentemente mascaram problemas estruturais sob uma fachada de microsserviços independentes. No entanto, quando a carga transacional escala de forma não linear, o calcanhar de Aquiles de qualquer ecossistema de software surge na camada de comunicação. O modelo síncrono baseado em HTTP/REST ou gRPC falha miseravelmente sob picos de latência em cascata. É exatamente neste cenário crítico que a engenharia de mensageria assíncrona deixa de ser uma mera opção de design e se torna a fundação inegociável da continuidade operacional.

A adoção de brokers de mensagens não é uma bala de prata. Escolher entre a flexibilidade baseada em roteamento do RabbitMQ e o paradigma de stream imutável do Apache Kafka exige uma compreensão profunda dos trade-offs subjacentes. Um erro de projeto na escolha do mecanismo de entrega pode resultar em perda de dados catastrófica, gargalos de rede invisíveis ou loops infinitos de reprocessamento que paralisam a infraestrutura corporativa.

O objetivo deste artigo é desconstruir os padrões arquiteturais avançados que regem o RabbitMQ e o Apache Kafka. Vamos analisar gargalos reais em ambientes de produção, discutir estratégias de particionamento, políticas de Dead Letter Queues (DLQ), garantias de entrega (at-least-once, at-most-once, exactly-once) e como estruturar contratos de dados resilientes utilizando Python altamente tipado.

Desmistificando o Tráfego de Borda e os Desafios de Ingestão

Na camada de ingestão de dados de alta volumetria, o primeiro grande obstáculo é absorver rajadas (bursts) de tráfego sem saturar os recursos de computação dos nós receptores. Sistemas legados que tentam processar eventos síncronamente durante picos de acesso sofrem com esgotamento de conexões (connection pool exhaustion) e estouro de pilha de memória.

O RabbitMQ brilha nesta camada quando o requisito central envolve roteamento complexo, troca de mensagens baseada em tópicos flexíveis (topic exchanges) e processamento orientado a tarefas de curta duração. Sua arquitetura baseada em AMQP (Advanced Message Queuing Protocol) permite que produtores enviem mensagens para um exchange, que por sua vez as distribui para filas baseadas em regras de correspondência (bindings).

Por outro lado, o Apache Kafka aborda a ingestão sob uma ótica fundamentalmente distinta: o Commit Log distribuído. No Kafka, o foco não é o roteamento dinâmico de tarefas individuais, mas a persistência sequencial e imutável de fluxos contínuos de dados. Os produtores escrevem em partições específicas dentro de tópicos, garantindo ordenação estrita por chave e permitindo que múltiplos consumidores leiam o mesmo stream em diferentes velocidades sem destruir o estado histórico.

O Dilema dos Protocolos: AMQP vs. Commit Log

Arquitetura de Mensageria Assíncrona: RabbitMQ e Apache Kafka em Sistemas de Alta Resiliência - Figura 1
Fluxograma detalhado da camada de ingestão e borda, ilustrando o roteamento inteligente de requisições e isolamento de falhas em picos de tráfego corporativo.

Para arquitetar sistemas de alta resiliência, é imperativo compreender as diferenças fundamentais entre o modelo de filas tradicionais e o modelo de logs de eventos:

Dimensão Arquitetural RabbitMQ (AMQP / Smart Broker) Apache Kafka (Distributed Log / Dumb Broker)
Modelo de Armazenamento Mensagens são removidas após o reconhecimento de consumo (ACK). Mensagens persistem em disco por retenção de tempo ou tamanho, independente do consumo.
Roteamento Complexo e flexível via Exchanges (Direct, Topic, Fanout, Headers). Simples, baseado em partições de tópicos e chaves de hash.
Garantia de Ordenação Garantida apenas em filas de consumidor único; paralelizá-la quebra a ordem. Garantida estritamente dentro de cada partição de um tópico.
Casos de Uso Ideais Fila de tarefas assíncronas, processamento em background, webhooks, roteamento complexo. Event streaming, microsserviços orientados a eventos, CQRS, pipelines analíticos em tempo real.

Implementando Produtores e Consumidores Resilientes com Python

A teoria arquitetural precisa ser traduzida em código robusto. Abaixo, apresentamos uma implementação em Python utilizando tipagem estrita (pydantic para contratos de dados e pika para integração com RabbitMQ). O código demonstra como estruturar uma camada de publicação resiliente com tratamento de exceções transacionais e serialização validada.

import json
import logging
from typing import Any, Dict
import pika
from pika.exceptions import AMQPConnectionError, ChannelError
from pydantic import BaseModel, Field, ValidationError

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("ZehroMessenger")

class TelemetryEvent(BaseModel):
    event_id: str = Field(..., description="UUID universal do evento")
    source_service: str = Field(..., min_length=2)
    payload: Dict[str, Any]
    timestamp: float = Field(..., gt=0)

class RabbitMQPublisher:
    def __init__(self, host: str, queue_name: str):
        self.host = host
        self.queue_name = queue_name
        self.connection = None
        self.channel = None
        self._connect()

    def _connect(self) -> None:
        try:
            parameters = pika.ConnectionParameters(host=self.host, heartbeat=600, blocked_connection_timeout=300)
            self.connection = pika.BlockingConnection(parameters)
            self.channel = self.connection.channel()

            # Declaração de fila durável com suporte a Dead Letter Exchange (DLX)
            self.channel.queue_declare(
                queue=self.queue_name,
                durable=True,
                arguments={
                    'x-dead-letter-exchange': 'dlx.events.exchange',
                    'x-dead-letter-routing-key': 'dlq.events.routing'
                }
            )
            logger.info(f"Conexão estabelecida com sucesso na fila durável: {self.queue_name}")
        except AMQPConnectionError as e:
            logger.error(f"Falha crítica de conexão com o broker RabbitMQ: {e}")
            raise

    def publish_event(self, event: TelemetryEvent) -> bool:
        try:
            # Validação estrita do contrato de dados via Pydantic
            serialized_body = event.model_dump_json()

            if self.connection.is_closed or self.channel.is_closed:
                logger.warning("Conexão perdida detectada. Tentando reconectar...")
                self._connect()

            self.channel.basic_publish(
                exchange='',
                routing_key=self.queue_name,
                body=serialized_body,
                properties=pika.BasicProperties(
                    delivery_mode=2,  # Torna a mensagem persistente em disco
                    content_type='application/json'
                )
            )
            logger.info(f"Evento {event.event_id} publicado com sucesso.")
            return True

        except (ValidationError, ValueError) as ve:
            logger.error(f"Erro de validação do contrato de dados: {ve}")
            return False
        except (ChannelError, AMQPConnectionError) as ce:
            logger.error(f"Erro de canal ou conexão durante a publicação: {ce}")
            # Em cenários corporativos avançados, implementar Circuit Breaker aqui
            return False

if __name__ == "__main__":
    import time
    publisher = RabbitMQPublisher(host="localhost", queue_name="telemetry.ingest.queue")

    sample_event = TelemetryEvent(
        event_id="f47ac10b-58cc-4372-a567-0e02b2c3d479",
        source_service="edge-gateway-alpha",
        payload={"cpu_usage": 88.4, "memory_allocated_mb": 4096},
        timestamp=time.time()
    )

    publisher.publish_event(sample_event)

Estratégias de Mitigação de Falhas: DLQ e Poison Messages

Em sistemas de alta resiliência, assumir que o consumidor sempre processará a mensagem com sucesso é um erro fatal. Mensagens malformadas ou falhas transitórias em bancos de dados dependentes podem gerar o temido fenômeno da Poison Message: uma mensagem que falha repetidamente, consumindo ciclos de CPU e travando o worker indefinidamente.

Para mitigar este risco, tanto o RabbitMQ quanto o Kafka exigem uma arquitetura defensiva baseada em:

  1. Dead Letter Queues (DLQ): Quando uma mensagem atinge o limite máximo de tentativas de entrega (max-delivery-count) ou é rejeitada explicitamente sem requisição de re-enfileiramento (basic_reject com requeue=False), ela é redirecionada automaticamente para uma exchange secundária isolada.
  2. Backoff Exponencial com Jitter: Em vez de reprocessar mensagens instantaneamente após uma falha de conexão com o banco de dados, os consumidores devem aplicar atrasos crescentes combinados com aleatoriedade controlada (jitter). Isso evita o efeito thundering herd, onde centenas de workers tentam reconectar simultaneamente, derrubando o banco de dados de destino de vez.

Observabilidade, Telemetria e Métricas de Produção

Gerenciar sistemas assíncronos sem uma camada de observabilidade madura é navegar às cegas em uma tempestade. Em ambientes de produção corporativa, o monitoramento não deve se limitar ao uso de CPU e memória dos containers do broker. As métricas cruciais residem no comportamento do fluxo de dados.

As principais métricas que exigem alertas parametrizados em ferramentas como Prometheus e Grafana incluem:

  • Consumer Lag: O indicador mais crítico em Apache Kafka. Representa a diferença entre o deslocamento (offset) atual produzido na partição e o deslocamento consumido pelo grupo de consumo. Um lag crescente indica que os consumidores estão subdimensionados ou travados.
  • Message Throughput (Ingress vs. Egress): A taxa de mensagens publicadas por segundo em comparação à taxa de mensagens reconhecidas ou consumidas. Discrepâncias prolongadas apontam estrangulamentos na lógica de negócio.
  • Unacked Messages (RabbitMQ): O número de mensagens entregues aos consumidores que ainda não receberam um basic_ack. Valores elevados indicam que os consumidores estão presos em operações bloqueantes ou deadlocks.
  • DLQ Influx Rate: A taxa de mensagens direcionadas às filas de erro. Um pico súbito nesta métrica geralmente sinaliza uma quebra de contrato de dados após um deploy de microsserviço sem versionamento adequado.

Conclusão Operacional

A engenharia de mensageria assíncrona exige rigor analítico. A escolha entre RabbitMQ e Apache Kafka não deve ser ditada por modismos tecnológicos, mas por restrições arquiteturais reais: roteamento dinâmico e flexibilidade de tarefas versus persistência imutável e fluxos de dados de alta escala. Ao projetar contratos estritos, blindar a aplicação contra poison messages e monitorar implacavelmente o consumer lag, constrói-se um ecossistema distribuído verdadeiramente resiliente, capaz de absorver falhas sem perder uma única transação corporativa.

Arquitetura de Mensageria Assíncrona: RabbitMQ e Apache Kafka em Sistemas de Alta Resiliência - Figura 2
Diagrama estrutural evidenciando o acoplamento de microsserviços, aplicação de contratos de dados estritos e gerenciamento de filas versus partições de log.
Arquitetura de Mensageria Assíncrona: RabbitMQ e Apache Kafka em Sistemas de Alta Resiliência - Figura 3
Painel de observabilidade avançada exibindo métricas de consumer lag, saturação de threads e telemetria distribuída em ambientes de mensageria críticos.
]]>