> labs.luisguisado.cloud
intermediate ~60 min · por Luis Guisado · actualizado 3 de mayo de 2026

API + SQS + Lambda: ingesta de notificaciones con fire-and-forget

Construye un API serverless que acepta peticiones, las encola en SQS y procesa de forma asíncrona con Lambda — sin Lambda intermediaria en la ruta de ingesta.

API Gateway SQS Lambda IAM CloudWatch Logs

Costo estimado: free under AWS free tier (~$0 si destruyes los recursos al terminar)

Qué vas a construir

Un sistema de ingesta de notificaciones (email, SMS, push) donde:

  1. El cliente hace POST /notifications con un payload JSON.
  2. API Gateway encola el mensaje en SQS de forma directa, sin pasar por una Lambda en la ruta de ingesta.
  3. Una Lambda consumidora procesa los mensajes en batch desde la cola.
  4. El cliente recibe un 202 Accepted con un messageId apenas el mensaje queda en la cola, sin esperar al procesamiento.

Este patrón se llama fire-and-forget y es la pieza fundamental de cualquier ingesta asíncrona en AWS serverless.


Por qué este patrón

Imagina un endpoint que envía emails llamando directamente a un proveedor (SES, SendGrid, etc.):

Cliente → API Gateway → Lambda → SES → Respuesta al cliente

Problemas:

  • Latencia acoplada al proveedor: si SES está lento, el cliente espera.
  • Picos de tráfico: un spike de 1000 req/s abre 1000 conexiones simultáneas a SES y puede agotar quotas.
  • Errores transitorios: si SES falla momentáneamente, el cliente recibe el error y debe reintentar.

Con el patrón fire-and-forget:

Cliente → API Gateway → SQS    (respuesta inmediata: 202 Accepted)

                       Lambda → SES   (procesamiento desacoplado)

Beneficios:

  • El cliente recibe respuesta en milisegundos, sin importar la latencia del proveedor.
  • SQS absorbe picos: si llegan 1000 mensajes/s pero SES solo procesa 100/s, los 900 restantes esperan en la cola sin perderse.
  • Si SES falla, los mensajes quedan en la cola y SQS reintenta automáticamente cuando la Lambda esté disponible.
  • El endpoint público y el procesamiento son piezas independientes que escalan, fallan y se reintentan por separado.

El cliente recibe un messageId que confirma aceptación en la cola, no que la notificación se haya enviado. Si necesitas confirmación end-to-end, eso requiere otro patrón (callback, WebSocket, polling) que sale del scope de este lab.


Arquitectura

                  ┌──────────────┐
                  │   Cliente    │
                  └──────┬───────┘
                         │ POST /v1/notifications
                         │ {"userId":"...","channel":"email","template":"...","data":{...}}

                  ┌──────────────┐         IAM role:
                  │ API Gateway  │◄──── apigw_to_sqs (sqs:SendMessage)
                  │  REST API    │
                  └──────┬───────┘
                         │ Action=SendMessage&MessageBody=<json>

                  ┌──────────────┐
                  │     SQS      │  Standard queue
                  │              │  - long polling 10s
                  └──────┬───────┘  - visibility timeout 30s

                         │ Event Source Mapping (batch_size=5)

                  ┌──────────────┐         IAM role:
                  │   Lambda     │◄──── lambda_exec (sqs:Receive/Delete + logs)
                  │ (nodejs24.x) │
                  └──────┬───────┘


                  CloudWatch Logs

Lo importante de este diagrama:

  • API Gateway → SQS es una integración directa AWS service. No hay Lambda en medio. Esto reduce latencia y costo, y elimina un punto de falla.
  • Dos roles IAM distintos: API Gateway solo puede SendMessage, la Lambda solo puede ReceiveMessage y DeleteMessage. Este es el principio de least privilege aplicado: si una de las dos identidades se compromete, no puede hacer el trabajo de la otra.

Estructura del proyecto

aws-poc-api-sqs-lambda/
├── lambda/
│   ├── package.json
│   └── src/
│       └── index.mjs       ← código del consumidor
└── terraform/
    ├── versions.tf         ← Terraform y providers
    ├── main.tf             ← provider + locals
    ├── variables.tf        ← variables de entrada
    ├── sqs.tf              ← cola SQS
    ├── iam.tf              ← roles y políticas
    ├── lambda.tf           ← Lambda + event source mapping
    ├── api_gateway.tf      ← REST API + integración con SQS
    └── outputs.tf          ← outputs

Vamos a construir cada archivo paso a paso.


Prerrequisitos

# Verifica versiones
aws --version            # AWS CLI v2 o superior
terraform --version      # 1.6.0 o superior
node --version           # opcional para correr local; el deploy no lo necesita

Asegúrate de tener credenciales AWS activas:

aws sts get-caller-identity

Si te devuelve tu cuenta y user/role, estás listo.


Paso 1 — Código del Lambda consumer

Crea la estructura base del proyecto:

mkdir -p aws-poc-api-sqs-lambda/{lambda/src,terraform}
cd aws-poc-api-sqs-lambda

Crea lambda/package.json:

{
  "name": "notifications-consumer",
  "version": "1.0.0",
  "description": "Lambda consumer for notifications SQS queue",
  "main": "src/index.mjs",
  "type": "module",
  "engines": {
    "node": ">=24.0.0"
  },
  "private": true
}

Crea lambda/src/index.mjs:

export const handler = async (event) => {
  for (const record of event.Records) {
    const { userId, channel, template, data } = JSON.parse(record.body);

    console.log(
      JSON.stringify({
        level: "info",
        message: "processing notification",
        messageId: record.messageId,
        userId,
        channel,
        template,
      }),
    );

    await deliver({ channel, userId, template, data });

    console.log(
      JSON.stringify({
        level: "info",
        message: "notification sent",
        messageId: record.messageId,
        userId,
        channel,
      }),
    );
  }
};

// Placeholder for the real provider call (SES, SNS, FCM, Twilio, etc).
// The 50ms sleep simulates network latency so the logs reflect realistic timing.
const deliver = async ({ channel, userId, template, data }) => {
  await new Promise((resolve) => setTimeout(resolve, 50));
  console.log(
    JSON.stringify({
      level: "info",
      message: `simulated ${channel} delivery`,
      userId,
      template,
      data,
    }),
  );
};

Notas sobre este código:

  • event.Records: cuando SQS dispara la Lambda, el evento incluye un array de records (un record por mensaje). Por eso el for. Aún si el batch_size es 1, el código debe manejar arrays.
  • Structured logging: usamos JSON.stringify con campos consistentes (level, message, messageId, etc.) en vez de console.log("processing", id). Esto permite filtrar logs en CloudWatch Insights con queries tipo fields @timestamp, message | filter level = "info".
  • deliver es un placeholder: en un sistema real aquí va la llamada a SES, SNS, FCM o Twilio. Mantenerlo aislado en una función facilita testing y un futuro switch de proveedor.

Paso 2 — Configuración de Terraform

Crea terraform/versions.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source = "hashicorp/aws"
      # >= 6.21 required for nodejs24.x runtime validation in aws_lambda_function.
      version = ">= 6.21, < 7.0"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.4"
    }
  }

  backend "local" {
    path = "terraform.tfstate"
  }
}

Estamos usando backend "local" para que el state quede en disco. Para producción mover a S3 + DynamoDB lock.

Crea terraform/main.tf:

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  }
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
  name_prefix = "${var.project_name}-${var.environment}"
  account_id  = data.aws_caller_identity.current.account_id
  region      = data.aws_region.current.region
}

Crea terraform/variables.tf:

variable "aws_region" {
  description = "AWS region where resources will be deployed"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project identifier used as prefix for resource names"
  type        = string
  default     = "notifications-poc"
}

variable "environment" {
  description = "Deployment environment (dev, staging, prod)"
  type        = string
  default     = "dev"
}

variable "api_stage_name" {
  description = "API Gateway deployment stage name"
  type        = string
  default     = "v1"
}

local.name_prefix da notifications-poc-dev y se usa como prefijo en todos los recursos. Cambiar project_name o environment te da entornos paralelos sin colisión de nombres.


Paso 3 — La cola SQS

Crea terraform/sqs.tf:

resource "aws_sqs_queue" "notifications" {
  name = "${local.name_prefix}-notifications"

  # Must be >= lambda timeout (10s). 30s leaves headroom so a slow batch
  # is not redelivered while still being processed.
  visibility_timeout_seconds = 30

  # 4 days retention. Standard SQS default, enough for an outage window.
  message_retention_seconds = 345600

  # Long polling: reduces empty receives and SQS API cost.
  receive_wait_time_seconds = 10
}

Tres parámetros importantes:

  • visibility_timeout_seconds = 30: cuando la Lambda recibe un mensaje, SQS lo oculta a otros consumers durante este tiempo. Si la Lambda no termina de procesar antes, el mensaje vuelve a aparecer y se procesa de nuevo (riesgo de duplicado). Regla: visibility timeout >= Lambda timeout. La Lambda tiene timeout 10s, así que 30s da margen.
  • message_retention_seconds = 345600 (4 días): si la Lambda está caída, los mensajes esperan en cola hasta este límite. Es el default de SQS.
  • receive_wait_time_seconds = 10: long polling. El consumer espera hasta 10s a que llegue un mensaje en vez de retornar vacío inmediatamente. Reduce costo (menos llamadas a la API de SQS) y latencia (responde apenas llega el mensaje).

Paso 4 — IAM con least privilege

Aquí está el corazón del diseño. Crea terraform/iam.tf:

# Two distinct roles by design (least privilege):
#   - lambda_exec:   only Receive/Delete from the queue + write logs
#   - apigw_to_sqs:  only SendMessage to the queue
# Splitting them prevents either side from doing the other's job if compromised.

data "aws_iam_policy_document" "lambda_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "lambda_exec" {
  name               = "${local.name_prefix}-lambda-exec"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}

resource "aws_iam_role_policy_attachment" "lambda_basic_logs" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

data "aws_iam_policy_document" "lambda_sqs_consume" {
  statement {
    effect = "Allow"
    actions = [
      "sqs:ReceiveMessage",
      "sqs:DeleteMessage",
      "sqs:GetQueueAttributes",
    ]
    resources = [aws_sqs_queue.notifications.arn]
  }
}

resource "aws_iam_role_policy" "lambda_sqs_consume" {
  name   = "${local.name_prefix}-lambda-sqs-consume"
  role   = aws_iam_role.lambda_exec.id
  policy = data.aws_iam_policy_document.lambda_sqs_consume.json
}

data "aws_iam_policy_document" "apigw_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["apigateway.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "apigw_to_sqs" {
  name               = "${local.name_prefix}-apigw-to-sqs"
  assume_role_policy = data.aws_iam_policy_document.apigw_assume_role.json
}

data "aws_iam_policy_document" "apigw_sqs_send" {
  statement {
    effect    = "Allow"
    actions   = ["sqs:SendMessage"]
    resources = [aws_sqs_queue.notifications.arn]
  }
}

resource "aws_iam_role_policy" "apigw_sqs_send" {
  name   = "${local.name_prefix}-apigw-sqs-send"
  role   = aws_iam_role.apigw_to_sqs.id
  policy = data.aws_iam_policy_document.apigw_sqs_send.json
}

Por qué dos roles separados:

  • lambda_exec asume lambda.amazonaws.com (la principal del servicio Lambda). Solo puede ReceiveMessage, DeleteMessage y escribir logs (vía la managed policy AWSLambdaBasicExecutionRole).
  • apigw_to_sqs asume apigateway.amazonaws.com. Solo puede SendMessage.

Si alguien compromete el rol de la Lambda, no puede inyectar mensajes nuevos a la cola. Si compromete el rol del API Gateway, no puede leer ni borrar mensajes. Cada identidad solo puede hacer su trabajo.

Anti-pattern común: usar un solo rol con sqs:*. Funciona, pero rompe el principio de least privilege y abre la puerta a privilege escalation lateral.


Paso 5 — Lambda + event source mapping

Crea terraform/lambda.tf:

data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/../lambda/src"
  output_path = "${path.module}/build/notifications-consumer.zip"
}

# Declared explicitly so the log group is managed by Terraform and gets a
# bounded retention. If we let Lambda auto-create it, retention defaults to
# "Never expire" and the group survives `terraform destroy`.
resource "aws_cloudwatch_log_group" "lambda" {
  name              = "/aws/lambda/${local.name_prefix}-notifications-consumer"
  retention_in_days = 7
}

resource "aws_lambda_function" "notifications_consumer" {
  function_name = "${local.name_prefix}-notifications-consumer"
  role          = aws_iam_role.lambda_exec.arn

  filename         = data.archive_file.lambda_zip.output_path
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256

  runtime = "nodejs24.x"
  handler = "index.handler"
  timeout = 10

  depends_on = [
    aws_iam_role_policy_attachment.lambda_basic_logs,
    aws_cloudwatch_log_group.lambda,
  ]
}

resource "aws_lambda_event_source_mapping" "sqs_to_lambda" {
  event_source_arn = aws_sqs_queue.notifications.arn
  function_name    = aws_lambda_function.notifications_consumer.arn

  # Small batch keeps per-invocation latency low for a notifications use case.
  # Increase for higher throughput / cost optimization.
  batch_size = 5
  enabled    = true
}

Tres detalles que evitan dolor:

  1. source_code_hash: si solo cambia filename Terraform no detecta el cambio del contenido del ZIP. Con source_code_hash apuntando al hash del archivo, cualquier modificación del código dispara redeploy del Lambda.
  2. Log group declarado explícitamente: si dejas que Lambda lo cree solo, queda con retención “Never expire” y terraform destroy no lo borra. Declararlo aquí garantiza retención bounded (7 días) y limpieza completa.
  3. event_source_mapping: Terraform conecta SQS con la Lambda. AWS se encarga del polling — tú no tienes que escribir código que llame a ReceiveMessage. Lambda recibe los mensajes vía el event que pasa al handler.

Paso 6 — API Gateway con integración directa a SQS

Aquí está la parte más interesante: API Gateway llama a SQS sin Lambda en el medio. Crea terraform/api_gateway.tf:

resource "aws_api_gateway_rest_api" "this" {
  name        = "${local.name_prefix}-api"
  description = "Notifications ingestion API"

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

resource "aws_api_gateway_resource" "notifications" {
  rest_api_id = aws_api_gateway_rest_api.this.id
  parent_id   = aws_api_gateway_rest_api.this.root_resource_id
  path_part   = "notifications"
}

resource "aws_api_gateway_method" "post_notifications" {
  rest_api_id   = aws_api_gateway_rest_api.this.id
  resource_id   = aws_api_gateway_resource.notifications.id
  http_method   = "POST"
  authorization = "NONE"
}

# Direct AWS service integration: API Gateway calls SQS without a Lambda proxy.
# SQS expects form-urlencoded with Action=SendMessage&MessageBody=...; the
# request template rewrites the incoming JSON into that format. The URL-encoded
# body becomes the message body the Lambda will read.
resource "aws_api_gateway_integration" "sqs" {
  rest_api_id             = aws_api_gateway_rest_api.this.id
  resource_id             = aws_api_gateway_resource.notifications.id
  http_method             = aws_api_gateway_method.post_notifications.http_method
  integration_http_method = "POST"
  type                    = "AWS"
  uri                     = "arn:aws:apigateway:${local.region}:sqs:path/${local.account_id}/${aws_sqs_queue.notifications.name}"
  credentials             = aws_iam_role.apigw_to_sqs.arn

  passthrough_behavior = "NEVER"

  request_parameters = {
    "integration.request.header.Content-Type" = "'application/x-www-form-urlencoded'"
  }

  request_templates = {
    "application/json" = "Action=SendMessage&MessageBody=$util.urlEncode($input.body)"
  }
}

resource "aws_api_gateway_method_response" "accepted" {
  rest_api_id = aws_api_gateway_rest_api.this.id
  resource_id = aws_api_gateway_resource.notifications.id
  http_method = aws_api_gateway_method.post_notifications.http_method
  status_code = "202"

  response_models = {
    "application/json" = "Empty"
  }
}

# SQS replies with XML (SendMessageResponse → SendMessageResult → MessageId).
# This template extracts the MessageId and returns it as JSON so callers don't
# see SQS internals.
resource "aws_api_gateway_integration_response" "accepted" {
  rest_api_id = aws_api_gateway_rest_api.this.id
  resource_id = aws_api_gateway_resource.notifications.id
  http_method = aws_api_gateway_method.post_notifications.http_method
  status_code = aws_api_gateway_method_response.accepted.status_code

  response_templates = {
    "application/json" = <<EOT
#set($messageId = $input.path('$.SendMessageResponse.SendMessageResult.MessageId'))
{"messageId":"$messageId"}
EOT
  }

  depends_on = [aws_api_gateway_integration.sqs]
}

resource "aws_api_gateway_deployment" "this" {
  rest_api_id = aws_api_gateway_rest_api.this.id

  triggers = {
    redeploy = sha1(jsonencode([
      aws_api_gateway_resource.notifications.id,
      aws_api_gateway_method.post_notifications.id,
      aws_api_gateway_integration.sqs.id,
      aws_api_gateway_integration_response.accepted.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }

  depends_on = [
    aws_api_gateway_integration.sqs,
    aws_api_gateway_integration_response.accepted,
  ]
}

resource "aws_api_gateway_stage" "this" {
  rest_api_id   = aws_api_gateway_rest_api.this.id
  deployment_id = aws_api_gateway_deployment.this.id
  stage_name    = var.api_stage_name
}

Lo más importante de este archivo:

aws_api_gateway_integration con type = "AWS" y URI apuntando directamente al servicio SQS. No hay aws_lambda_function intermedia. La autenticación con SQS la hace API Gateway asumiendo el rol apigw_to_sqs (credentials = aws_iam_role.apigw_to_sqs.arn).

request_templates transforma el body entrante. SQS no acepta JSON crudo en SendMessage — espera form-urlencoded con Action=SendMessage&MessageBody=<body>. La línea:

Action=SendMessage&MessageBody=$util.urlEncode($input.body)

toma el body JSON original ($input.body) y lo URL-encodea para meterlo dentro del campo MessageBody. Esa cadena URL-encodeada es exactamente lo que la Lambda recibirá como record.body (después de que SQS lo decodifique).

response_templates hace el camino inverso. SQS responde XML así:

<SendMessageResponse>
  <SendMessageResult>
    <MessageId>abc-123-def</MessageId>
  </SendMessageResult>
</SendMessageResponse>

El template extrae el MessageId y lo devuelve como {"messageId":"abc-123-def"} para que el cliente no vea las tripas de SQS.

triggers.redeploy: API Gateway cachea el deployment. Si cambias la integración o la response y no haces redeploy, los cambios no se aplican. El hash sobre los IDs de los recursos clave fuerza redeploy automático en Terraform cuando algo cambia.

Por qué evitar la Lambda en la ingesta: cada Lambda invocation suma latencia (cold start si no está caliente) y costo (request count). API Gateway directo a SQS es 1 hop menos, ~50ms menos de latencia, y casi cero costo extra del API. La Lambda solo aporta valor cuando necesitas validación compleja del payload o transformación que VTL no maneja.


Paso 7 — Outputs

Crea terraform/outputs.tf:

output "api_invoke_url" {
  description = "Full URL to POST notifications"
  value       = "${aws_api_gateway_stage.this.invoke_url}/notifications"
}

output "sqs_queue_url" {
  description = "URL of the notifications SQS queue"
  value       = aws_sqs_queue.notifications.url
}

output "sqs_queue_name" {
  description = "Name of the notifications SQS queue"
  value       = aws_sqs_queue.notifications.name
}

output "lambda_function_name" {
  description = "Name of the Lambda consumer function"
  value       = aws_lambda_function.notifications_consumer.function_name
}

output "lambda_log_group" {
  description = "CloudWatch log group for the Lambda"
  value       = aws_cloudwatch_log_group.lambda.name
}

Estos outputs los vamos a usar para invocar el endpoint y leer los logs sin tener que ir a la consola de AWS.


Paso 8 — Desplegar

Desde la carpeta terraform/:

cd terraform
terraform init
terraform plan
terraform apply

Cuando termina, vas a ver los outputs:

api_invoke_url       = "https://abcd1234ef.execute-api.us-east-1.amazonaws.com/v1/notifications"
sqs_queue_name       = "notifications-poc-dev-notifications"
lambda_function_name = "notifications-poc-dev-notifications-consumer"
lambda_log_group     = "/aws/lambda/notifications-poc-dev-notifications-consumer"

Si usas terraform init -upgrade después de modificar versions.tf, considera borrar .terraform.lock.hcl para regenerar el lock con las versiones nuevas. Un init simple respeta el lock previo y falla si el constraint cambió.


Paso 9 — Probar el flujo

Dispara una petición:

curl -X POST "$(terraform output -raw api_invoke_url)" \
  -H "Content-Type: application/json" \
  -d '{"userId":"u-123","channel":"email","template":"welcome","data":{"name":"Luis"}}'

Respuesta esperada (HTTP 202):

{"messageId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}

Ese messageId es el ID que SQS asignó al mensaje. Confirmación de que está en la cola.

Ahora abre los logs de la Lambda:

aws logs tail "$(terraform output -raw lambda_log_group)" --follow

Deberías ver tres líneas en JSON estructurado:

{"level":"info","message":"processing notification","messageId":"...","userId":"u-123","channel":"email","template":"welcome"}
{"level":"info","message":"simulated email delivery","userId":"u-123","template":"welcome","data":{"name":"Luis"}}
{"level":"info","message":"notification sent","messageId":"...","userId":"u-123","channel":"email"}

Eso confirma:

  1. La Lambda recibió el mensaje del event source mapping.
  2. El payload original llegó intacto a través del mapping de API Gateway → SQS → Lambda.
  3. Toda la cadena funciona end-to-end.

Anatomía del flujo (qué pasa internamente)

Vamos paso a paso después de que el cliente hace POST:

  1. API Gateway recibe el request y aplica el request_template. El JSON del body se URL-encodea y se inserta en MessageBody=....
  2. API Gateway asume el rol apigw_to_sqs y llama a sqs.amazonaws.com/<account>/<queue> con Action=SendMessage. SQS confirma con un XML que contiene el MessageId.
  3. API Gateway aplica el response_template sobre el XML. Extrae MessageId y devuelve {"messageId":"..."} con status 202.
  4. El cliente recibe la respuesta en milisegundos (típicamente ~30-80ms incluyendo round-trip de SQS).
  5. Mientras tanto, el event source mapping está haciendo polling de la cola. Cuando ve un mensaje (o hasta batch_size=5), invoca la Lambda pasando los records.
  6. La Lambda procesa cada record, llama al placeholder deliver(), y termina.
  7. Si la Lambda completa sin error, el event source mapping borra los mensajes de la cola (DeleteMessage en bulk).
  8. Si la Lambda lanza una excepción, los mensajes quedan en la cola, el visibility timeout expira, y vuelven a estar disponibles para la próxima invocación.

Este último punto es crucial: el “DeleteMessage” no lo haces tú, lo hace AWS si y solo si la Lambda terminó exitosamente. Es parte de la garantía at-least-once de SQS.


Limitaciones de esta POC

Para que sea producción-ready, te falta:

  • DLQ (Dead Letter Queue): si un mensaje falla N veces, debe ir a una cola separada para inspección manual. Sin DLQ, los mensajes problemáticos pueden ciclarse indefinidamente.
  • Validación de schema en API Gateway: hoy aceptamos cualquier JSON. Producción debería rechazar payloads inválidos en la capa de API (con aws_api_gateway_request_validator y un model JSON Schema) antes de encolar.
  • Idempotencia: SQS garantiza at-least-once delivery. Eso significa que un mismo mensaje puede procesarse más de una vez (ej: si la Lambda termina justo después de enviar la notificación pero antes de retornar). El consumer debería ser idempotente (ej: tracking de messageId en una tabla de DynamoDB con TTL).
  • Throttling y autenticación en el API: hoy authorization = "NONE". Producción usa API keys, IAM auth, Cognito, o un Lambda authorizer.
  • Observabilidad real: structured logs son un buen inicio, pero falta métricas custom (CloudWatch Metrics o Embedded Metric Format), alarmas, y trazas (X-Ray).
  • CI/CD: hoy aplicas Terraform desde tu máquina. Producción usa GitHub Actions con OIDC + role assumption.

Limpieza

terraform destroy

Confirma con yes. Esto borra: API Gateway, SQS queue, Lambda, log group, ambos roles IAM y todas sus policies. El costo en AWS vuelve a $0.

Verifica con:

aws sqs list-queues --queue-name-prefix notifications-poc
aws lambda list-functions --query "Functions[?starts_with(FunctionName, 'notifications-poc')]"

Ambos comandos deberían devolver listas vacías.


Próximos pasos

Cuando quieras llevarlo más allá:

  • Agrega DLQ + redrive policy.
  • Reemplaza el placeholder deliver() con SES real para emails.
  • Agrega validación de schema en API Gateway con aws_api_gateway_model + request_validator.
  • Migra el state a S3 + DynamoDB lock para trabajo en equipo.
  • Agrega CI/CD con GitHub Actions OIDC.
  • Implementa idempotencia en el consumer.

Referencia rápida

# Setup
cd terraform
terraform init
terraform plan
terraform apply

# Probar
curl -X POST "$(terraform output -raw api_invoke_url)" \
  -H "Content-Type: application/json" \
  -d '{"userId":"u-123","channel":"email","template":"welcome","data":{"name":"Luis"}}'

# Ver logs
aws logs tail "$(terraform output -raw lambda_log_group)" --follow

# Ver mensajes pendientes en la cola
aws sqs get-queue-attributes \
  --queue-url "$(terraform output -raw sqs_queue_url)" \
  --attribute-names ApproximateNumberOfMessages

# Limpiar
terraform destroy