Boas práticas para trabalhar com thin events

Thin events são payloads de webhook que contêm informação mínima, normalmente apenas um tipo de evento e um identificador de recurso, em vez dos dados completos do recurso. Este guia traz orientações práticas para implementar thin events de forma eficaz em sistemas em produção.

Para uma introdução completa ao tema, veja O que são thin events?.

Por que e quando usar thin events

Benefícios dos thin events

Redução do tamanho do payload: thin events reduzem significativamente o tamanho dos payloads de webhook, o que pode melhorar a performance de rede e reduzir custos de banda, especialmente quando você lida com alto volume de tráfego de webhooks.

Estabilidade de schema: ao incluir apenas identificadores essenciais, os thin events minimizam o impacto de mudanças na API. Os seus handlers de webhook continuam estáveis mesmo quando a estrutura do recurso evolui.

Suporte a alto volume: payloads menores significam transmissão e processamento mais rápidos, o que torna os thin events ideais para cenários de alto throughput em que você recebe milhares de webhooks por minuto.

Dados atualizados: como você busca o recurso no momento em que o processa, sempre obtém o estado atual em vez de dados potencialmente desatualizados, capturados quando o evento foi gerado.

Segurança e privacidade: thin events reduzem a exposição de dados sensíveis em trânsito e em logs, já que as informações detalhadas só são buscadas quando necessário e por chamadas de API autenticadas.

Quando usar thin events

Thin events são especialmente adequados para:

  • Fluxos de eventos de alto volume em que tamanho do payload e velocidade de processamento importam
  • Eventos com recursos pesados em que o payload completo teria vários kilobytes ou mais
  • APIs em rápida evolução em que mudanças de schema são frequentes
  • Cenários sensíveis do ponto de vista de segurança em que você quer minimizar a exposição de dados
  • Sistemas eventualmente consistentes em que buscar dados atualizados é preferível

Quando NÃO usar thin events

Considere alternativas aos thin events quando:

  • Eventos críticos exigem processamento imediato e uma chamada de API adicional introduziria latência inaceitável
  • A API do provedor do webhook é instável ou tem rate limits que impediriam você de buscar os recursos de forma confiável
  • A sua aplicação precisa processar eventos offline ou sem acesso a APIs externas
  • O evento representa um recurso apagado que não pode mais ser buscado
  • As chamadas de API adicionais aumentariam significativamente os custos ou a complexidade

O padrão fetch-before-process

O padrão fetch-before-process é o fluxo central para lidar com thin events. Ele consiste em quatro passos principais:

  1. Receber o webhook do thin event
  2. Buscar o recurso completo usando o identificador do evento
  3. Validar que o recurso buscado corresponde ao que você espera processar
  4. Processar os dados completos do recurso

Para um aprofundamento nesse padrão, veja O padrão Fetch Before Process em webhooks.

Passos de implementação

Receber e interpretar o evento

Quando o seu endpoint de webhook recebe um thin event, extraia as informações essenciais:

app.post('/webhooks/provider', async (req, res) => {
  const { event_type, resource_id, resource_type } = req.body;
  
  try {
    // Queue the event for processing
    await queue.enqueue({
      event_type,
      resource_id,
      resource_type,
      received_at: new Date().toISOString()
    });
    
    // Only acknowledge after successful queueing
    res.status(200).send('OK');
  } catch (err) {
    console.error('Failed to queue event:', err);
    res.status(500).send('Failed to process event');
  }
});

Buscar o recurso completo

Use o identificador do recurso para buscar os dados completos na API do provedor:

async function fetchResource(resourceType, resourceId) {
  const response = await fetch(
    `https://api.provider.com/${resourceType}/${resourceId}`,
    {
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      }
    }
  );
  
  if (!response.ok) {
    throw new Error(`Failed to fetch resource: ${response.status}`);
  }
  
  return response.json();
}

Tratar falhas na busca

A busca de recursos pode falhar por vários motivos. Trate essas falhas:

async function fetchResourceWithRetry(resourceType, resourceId, maxRetries = 3) {
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fetchResource(resourceType, resourceId);
    } catch (error) {
      lastError = error;
      
      // Handle 404 - resource may not exist yet or was deleted
      if (error.status === 404) {
        if (attempt < maxRetries) {
          // Wait briefly in case of eventual consistency
          await sleep(Math.pow(2, attempt) * 1000);
          continue;
        }
        // After retries, consider this a valid scenario
        console.warn(`Resource ${resourceId} not found after ${maxRetries} attempts`);
        return null;
      }
      
      // Handle rate limiting
      if (error.status === 429) {
        const retryAfter = error.headers?.get('Retry-After') || Math.pow(2, attempt);
        await sleep(retryAfter * 1000);
        continue;
      }
      
      // For other errors, use exponential backoff
      if (attempt < maxRetries) {
        await sleep(Math.pow(2, attempt) * 1000);
      }
    }
  }
  
  throw lastError;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Validar e processar

Depois de buscar o recurso, valide-o antes de processar:

async function processEvent(eventType, resourceId, resourceType) {
  // For deletion events, resource may not be fetchable
  if (eventType === 'resource.deleted') {
    // Handle deletion with just the ID since resource is already deleted
    return handleDeletion(resourceId);
  }
  
  // Fetch the resource for non-deletion events
  const resource = await fetchResourceWithRetry(resourceType, resourceId);
  
  if (!resource) {
    // Log and skip if resource doesn't exist
    console.warn(`Skipping event: resource ${resourceId} not found`);
    return;
  }
  
  // Validate resource state matches event type
  if (!isValidForEvent(resource, eventType)) {
    console.warn(`Resource state mismatch for event ${eventType}`);
    return;
  }
  
  // Process based on event type
  switch (eventType) {
    case 'resource.created':
      return handleCreation(resource);
    case 'resource.updated':
      return handleUpdate(resource);
    default:
      console.warn(`Unknown event type: ${eventType}`);
  }
}

Lidando com múltiplas tentativas de busca

Em sistemas distribuídos, a consistência eventual pode causar situações em que um recurso mencionado em um webhook ainda não existe na API:

async function fetchWithEventualConsistency(resourceType, resourceId, options = {}) {
  const {
    maxRetries = 3,
    initialDelay = 1000,
    maxDelay = 10000
  } = options;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const resource = await fetchResource(resourceType, resourceId);
      
      if (resource) {
        return resource;
      }
    } catch (error) {
      if (error.status !== 404 && attempt === maxRetries) {
        throw error;
      }
    }
    
    // Calculate delay with exponential backoff and jitter
    const delay = Math.min(
      initialDelay * Math.pow(2, attempt - 1) + Math.random() * 1000,
      maxDelay
    );
    
    await sleep(delay);
  }
  
  return null;
}

Considerações sobre idempotência

Como os webhooks podem ser entregues várias vezes, garanta que a sua implementação de fetch-before-process seja idempotente. Para saber mais sobre como implementar idempotência, veja como implementar idempotência em webhooks:

async function processEventIdempotently(eventId, eventType, resourceId, resourceType) {
  // Check if this event has already been processed
  const isProcessed = await checkIfProcessed(eventId);
  
  if (isProcessed) {
    console.log(`Event ${eventId} already processed, skipping`);
    return;
  }
  
  // Mark as processing to prevent concurrent processing
  await markAsProcessing(eventId);
  
  try {
    // Fetch and process
    const resource = await fetchResourceWithRetry(resourceType, resourceId);
    
    if (resource) {
      await processResource(eventType, resource);
    }
    
    // Mark as successfully processed
    await markAsProcessed(eventId);
  } catch (error) {
    // Mark as failed for retry
    await markAsFailed(eventId, error);
    throw error;
  }
}

Boas práticas operacionais

Deduplicação

Implemente deduplicação para lidar com entregas duplicadas de webhooks:

// Using Redis for distributed deduplication
async function isDuplicate(eventId, ttl = 86400) {
  const key = `webhook:processed:${eventId}`;
  const exists = await redis.exists(key);
  
  if (exists) {
    return true;
  }
  
  // Set with TTL (24 hours default)
  await redis.setex(key, ttl, '1');
  return false;
}

app.post('/webhooks/provider', async (req, res) => {
  const { id, event_type, resource_id } = req.body;
  
  // Check for duplicate
  if (await isDuplicate(id)) {
    return res.status(200).send('Already processed');
  }
  
  try {
    // Queue the event for processing
    await queue.enqueue({
      id,
      event_type,
      resource_id,
      received_at: new Date().toISOString()
    });
    
    // Only acknowledge after successful queueing
    res.status(200).send('OK');
  } catch (err) {
    console.error('Failed to queue event:', err);
    res.status(500).send('Failed to process event');
  }
});

Handlers idempotentes

Projete a sua lógica de processamento para ser idempotente:

async function handleUpdate(resource) {
  // Use upsert operations that are naturally idempotent
  await db.collection('resources').updateOne(
    { id: resource.id },
    { 
      $set: {
        ...resource,
        updatedAt: new Date()
      }
    },
    { upsert: true }
  );
}

async function handleCreation(resource) {
  // Use insertOne with error handling for duplicates
  try {
    await db.collection('resources').insertOne({
      ...resource,
      createdAt: new Date()
    });
  } catch (error) {
    if (error.code === 11000) { // Duplicate key error
      // Already exists, treat as update
      return handleUpdate(resource);
    }
    throw error;
  }
}

Lidando com falhas da API downstream

Quando a busca falha, implemente tratamento de erros e lógica de retry adequados:

async function processWithCircuitBreaker(eventType, resourceId, resourceType) {
  // Check circuit breaker state
  if (circuitBreaker.isOpen()) {
    throw new Error('Circuit breaker is open, skipping fetch');
  }
  
  try {
    const resource = await fetchResourceWithRetry(resourceType, resourceId);
    circuitBreaker.recordSuccess();
    return resource;
  } catch (error) {
    circuitBreaker.recordFailure();
    
    // Implement fallback strategies
    if (circuitBreaker.isOpen()) {
      // Queue for later processing
      await queueForRetry(eventType, resourceId, resourceType);
    }
    
    throw error;
  }
}

// Simple circuit breaker implementation
class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.failureCount = 0;
    this.threshold = threshold;
    this.timeout = timeout;
    this.openedAt = null;
  }
  
  recordSuccess() {
    this.failureCount = 0;
    this.openedAt = null;
  }
  
  recordFailure() {
    this.failureCount++;
    if (this.failureCount >= this.threshold) {
      this.openedAt = Date.now();
    }
  }
  
  isOpen() {
    if (!this.openedAt) return false;
    if (Date.now() - this.openedAt > this.timeout) {
      // Half-open state: allow one request
      this.openedAt = null;
      this.failureCount = this.threshold - 1;
      return false;
    }
    return true;
  }
}

const circuitBreaker = new CircuitBreaker();

Observabilidade

Implemente logging e monitoramento abrangentes:

async function processEvent(eventType, resourceId, resourceType) {
  const startTime = Date.now();
  const logger = {
    eventType,
    resourceId,
    resourceType
  };
  
  try {
    console.log('Processing event', logger);
    
    // Fetch resource
    const fetchStart = Date.now();
    const resource = await fetchResourceWithRetry(resourceType, resourceId);
    const fetchDuration = Date.now() - fetchStart;
    
    // Log fetch metrics
    console.log('Resource fetched', {
      ...logger,
      fetchDuration,
      resourceFound: !!resource
    });
    
    if (!resource) {
      console.warn('Resource not found', logger);
      return;
    }
    
    // Process resource
    await processResource(eventType, resource);
    
    // Log success metrics
    const totalDuration = Date.now() - startTime;
    console.log('Event processed successfully', {
      ...logger,
      totalDuration,
      fetchDuration
    });
    
    // Send metrics to monitoring system
    metrics.increment('webhooks.processed.success', {
      eventType,
      resourceType
    });
    metrics.timing('webhooks.processing.duration', totalDuration);
    metrics.timing('webhooks.fetch.duration', fetchDuration);
    
  } catch (error) {
    const totalDuration = Date.now() - startTime;
    
    console.error('Event processing failed', {
      ...logger,
      error: error.message,
      stack: error.stack,
      totalDuration
    });
    
    // Send error metrics
    metrics.increment('webhooks.processed.error', {
      eventType,
      resourceType,
      errorType: error.name
    });
    
    throw error;
  }
}

Considerações sobre rate limiting

Fique atento aos rate limits ao buscar recursos:

// Token bucket rate limiter
class RateLimiter {
  constructor(tokensPerSecond, bucketSize) {
    this.tokensPerSecond = tokensPerSecond;
    this.bucketSize = bucketSize;
    this.tokens = bucketSize;
    this.lastRefill = Date.now();
  }
  
  async acquire() {
    this.refill();
    
    if (this.tokens < 1) {
      const waitTime = (1 - this.tokens) * (1000 / this.tokensPerSecond);
      await sleep(waitTime);
      this.refill();
    }
    
    this.tokens -= 1;
  }
  
  refill() {
    const now = Date.now();
    const timePassed = (now - this.lastRefill) / 1000;
    const newTokens = timePassed * this.tokensPerSecond;
    this.tokens = Math.min(this.bucketSize, this.tokens + newTokens);
    this.lastRefill = now;
  }
}

const rateLimiter = new RateLimiter(10, 50); // 10 requests/sec, burst of 50

async function fetchResource(resourceType, resourceId) {
  await rateLimiter.acquire();
  
  const response = await fetch(
    `https://api.provider.com/${resourceType}/${resourceId}`,
    {
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      }
    }
  );
  
  if (!response.ok) {
    throw new Error(`Failed to fetch resource: ${response.status}`);
  }
  
  return response.json();
}

Exemplos de código genéricos

Estrutura básica do handler

Um exemplo completo de handler de webhook para thin events:

const express = require('express');
const app = express();

app.use(express.json());

// In-memory store for processed events (use Redis in production)
const processedEvents = new Set();

app.post('/webhooks/provider', async (req, res) => {
  const { id, event_type, resource_id, resource_type, timestamp } = req.body;
  
  // Validate webhook signature (implementation depends on provider)
  if (!validateSignature(req)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Acknowledge receipt immediately
  res.status(200).send('OK');
  
  // Process asynchronously
  try {
    await processWebhook(id, event_type, resource_id, resource_type);
  } catch (error) {
    console.error('Webhook processing error:', error);
    // Implement dead letter queue for failed events
    await sendToDeadLetterQueue({ id, event_type, resource_id, error });
  }
});

async function processWebhook(eventId, eventType, resourceId, resourceType) {
  // Check for duplicate
  if (processedEvents.has(eventId)) {
    console.log(`Event ${eventId} already processed`);
    return;
  }
  
  // Mark as processing
  processedEvents.add(eventId);
  
  try {
    // Fetch the resource
    const resource = await fetchResourceWithRetry(resourceType, resourceId);
    
    if (!resource) {
      console.warn(`Resource ${resourceId} not found`);
      return;
    }
    
    // Process based on event type
    await processResource(eventType, resource);
    
    console.log(`Successfully processed event ${eventId}`);
  } catch (error) {
    // Remove from processed set on failure to allow retry
    processedEvents.delete(eventId);
    throw error;
  }
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

Padrão de busca com tratamento de erros

Uma implementação de busca com tratamento de erros:

async function fetchResourceWithRetry(resourceType, resourceId, options = {}) {
  const {
    maxRetries = 3,
    timeout = 5000,
    retryableStatuses = [408, 429, 500, 502, 503, 504]
  } = options;
  
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
      
      const response = await fetch(
        `https://api.provider.com/${resourceType}/${resourceId}`,
        {
          headers: {
            'Authorization': `Bearer ${process.env.API_KEY}`,
            'Content-Type': 'application/json'
          },
          signal: controller.signal
        }
      );
      
      clearTimeout(timeoutId);
      
      // Success case
      if (response.ok) {
        return await response.json();
      }
      
      // Handle 404 - resource doesn't exist
      if (response.status === 404) {
        if (attempt < maxRetries) {
          // Might be eventual consistency, retry with backoff
          await sleep(Math.pow(2, attempt) * 1000);
          continue;
        }
        return null; // Resource truly doesn't exist
      }
      
      // Handle rate limiting
      if (response.status === 429) {
        const retryAfter = response.headers.get('Retry-After');
        const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000;
        console.log(`Rate limited, retrying after ${delay}ms`);
        await sleep(delay);
        continue;
      }
      
      // Handle other retryable errors
      if (retryableStatuses.includes(response.status) && attempt < maxRetries) {
        await sleep(Math.pow(2, attempt) * 1000);
        continue;
      }
      
      // Non-retryable error
      throw new Error(`HTTP ${response.status}: ${await response.text()}`);
      
    } catch (error) {
      lastError = error;
      
      // Don't retry abort errors on last attempt
      if (error.name === 'AbortError' && attempt === maxRetries) {
        throw new Error(`Request timeout after ${timeout}ms`);
      }
      
      // Retry on network errors
      if (attempt < maxRetries) {
        await sleep(Math.pow(2, attempt) * 1000);
        continue;
      }
    }
  }
  
  throw lastError;
}

Cenários de erro e retry

Como lidar com cenários de erro comuns:

async function handleEventWithErrorRecovery(event) {
  const { id, event_type, resource_id, resource_type } = event;
  
  try {
    // Attempt to process
    const resource = await fetchResourceWithRetry(resource_type, resource_id);
    
    if (!resource) {
      return handleMissingResource(event);
    }
    
    await processResource(event_type, resource);
    
  } catch (error) {
    return handleProcessingError(event, error);
  }
}

async function handleMissingResource(event) {
  const { id, event_type, resource_id } = event;
  
  // Check if this is a deletion event
  if (event_type.endsWith('.deleted')) {
    console.log(`Resource ${resource_id} deleted as expected`);
    return await processResourceDeletion(resource_id);
  }
  
  // For other events, this might indicate eventual consistency
  // Queue for retry after a delay
  console.warn(`Resource ${resource_id} not found, queueing for retry`);
  await queueForRetry(event, { delay: 30000, maxRetries: 3 });
}

async function handleProcessingError(event, error) {
  console.error(`Failed to process event ${event.id}:`, error);
  
  // Categorize error
  if (isTransientError(error)) {
    // Queue for retry
    await queueForRetry(event, { 
      delay: 60000,
      maxRetries: 5,
      backoff: 'exponential'
    });
  } else {
    // Permanent error - send to dead letter queue
    await sendToDeadLetterQueue({
      event,
      error: error.message,
      timestamp: new Date().toISOString()
    });
  }
}

function isTransientError(error) {
  const transientErrors = [
    'ETIMEDOUT',
    'ECONNREFUSED',
    'ECONNRESET',
    'ENOTFOUND'
  ];
  
  return transientErrors.some(code => error.code === code) ||
         error.message.includes('timeout') ||
         error.message.includes('rate limit');
}

// Simple queue implementation (use a proper queue service in production)
const retryQueue = [];

async function queueForRetry(event, options = {}) {
  const {
    delay = 60000,
    maxRetries = 3,
    backoff = 'exponential'
  } = options;
  
  const retryCount = event.retryCount || 0;
  
  if (retryCount >= maxRetries) {
    console.error(`Max retries exceeded for event ${event.id}`);
    return await sendToDeadLetterQueue(event);
  }
  
  const retryDelay = backoff === 'exponential' 
    ? delay * Math.pow(2, retryCount)
    : delay;
  
  setTimeout(async () => {
    console.log(`Retrying event ${event.id} (attempt ${retryCount + 1})`);
    await handleEventWithErrorRecovery({
      ...event,
      retryCount: retryCount + 1
    });
  }, retryDelay);
}

async function sendToDeadLetterQueue(data) {
  // Implement based on your infrastructure
  // Could be: database table, S3, dedicated queue service, etc.
  console.error('Sending to dead letter queue:', data);
  // await deadLetterQueue.send(data);
}

Simplificando thin events com o Hookdeck

Como este guia demonstra, implementar thin events exige construir e manter vários componentes operacionais complexos:

  • Infraestrutura de rate limiting para evitar sobrecarregar as APIs dos provedores com requisições de busca
  • Sistemas de fila para desacoplar a ingestão dos webhooks do processamento
  • Lógica de deduplicação para filtrar eventos redundantes antes que eles disparem buscas desnecessárias na API
  • Circuit breakers para lidar de forma elegante com indisponibilidades da API do provedor
  • Ferramentas de observabilidade para monitorar falhas de busca e métricas de processamento
  • Mecanismos de retry com exponential backoff e dead letter queues

Embora esses padrões sejam essenciais para lidar com thin events em nível de produção, eles representam um esforço de engenharia considerável para construir, testar e manter.

O Hookdeck oferece essas capacidades prontas por meio do seu Event Gateway, eliminando a necessidade de você mesmo implementá-las:

DesafioImplementação própriaSolução Hookdeck
Rate limitingConstruir um rate limiter de token bucket, gerenciar estado, tratar capacidade de picosConfigurar a taxa máxima de entrega no Destination — o Event Gateway enfileira e controla a taxa automaticamente
DeduplicaçãoImplementar deduplicação apoiada em Redis, gerenciar TTLs, tratar comparação por campoConfigurar regras de deduplicação com inclusão/exclusão de campos — sem infraestrutura adicional
Gestão de filasProvisionar e manter infraestrutura de fila de mensagens, lidar com back pressureFila durável embutida com tratamento automático de back pressure
ObservabilidadeConstruir logging, coleta de métricas e dashboards para falhas de buscaTimeline completa da requisição, dashboard de métricas e replay de eventos embutidos
Recuperação de errosImplementar lógica de retry, dead letter queues, ferramentas de replay manualRetries automáticos, replay em massa e acompanhamento de issues incluídos
Circuit breakingEscrever e manter o padrão circuit breaker para as APIs dos provedoresO rate limiting evita cenários de circuit breaker; o retry automático trata falhas transitórias

Para um guia completo sobre como usar o Hookdeck para lidar com thin events em escala, veja Handling Thin Events with the Hookdeck Event Gateway.

Quando usar o Hookdeck e quando implementar por conta própria

Escolha o Hookdeck quando:

  • Você quer implementar thin events sem construir infraestrutura de fila e rate limiting
  • Você precisa imediatamente de deduplicação, observabilidade e retries prontos para produção
  • Você está escalando para milhares ou milhões de webhooks e quer uma infraestrutura comprovada
  • O seu time deve focar em regra de negócio, e não em infraestrutura de webhooks

Considere implementar por conta própria quando:

  • Você tem requisitos únicos que exigem lógica de enfileiramento customizada
  • Você já tem uma infraestrutura de webhooks robusta e quer estendê-la
  • Você precisa de controle completo sobre cada aspecto do pipeline de processamento

Para a maioria dos times que implementam thin events, o Hookdeck reduz significativamente o tempo até a produção, oferecendo confiabilidade e observabilidade de nível empresarial.

Resumo

Thin events trazem benefícios importantes para sistemas baseados em webhooks, mas exigem uma implementação cuidadosa para funcionarem bem. Seguindo o padrão fetch-before-process e implementando tratamento de erros, deduplicação e observabilidade robustos, você consegue construir handlers de webhook confiáveis que escalam junto com a sua aplicação.

Pontos principais:

  • Sempre confirme o recebimento dos webhooks imediatamente e processe de forma assíncrona
  • Implemente lógica de retry com exponential backoff nas operações de busca
  • Trate a consistência eventual com intervalos de retry adequados
  • Torne os seus handlers idempotentes para lidar com entregas duplicadas com segurança
  • Implemente observabilidade abrangente para monitorar performance e erros
  • Use circuit breakers e rate limiters para proteger as APIs downstream
  • Desenhe estratégias de tratamento de erros tanto para falhas transitórias quanto para permanentes

Para mais informações sobre temas relacionados, veja: