DevOps & Scaling

Construire une résolution CAPTCHA orientée événements avec AWS SNS et CaptchaAI

Un résultat CAPTCHA n'intéresse presque jamais un seul composant : le scraper veut reprendre son parcours, un service veut l'archiver, l'audit veut une trace, et une alerte doit partir dès qu'une résolution échoue. La réponse propre consiste à publier ce résultat une seule fois dans un topic AWS SNS, puis à laisser chaque consommateur s'y abonner de son côté.

CaptchaAI s'y prête directement. À la fin d'une résolution, le service appelle votre URL de callback ; ce callback publie un événement dans SNS ; SNS le diffuse en fan-out vers autant de files SQS, fonctions Lambda ou abonnements e-mail que nécessaire. Vous supprimez le polling qui immobilise vos threads et vous rompez le couplage entre soumission et traitement aval. Ce guide monte ce pipeline étape par étape, avec le code Python et Node.js.

Vue d'ensemble de l'architecture

[Scraper] → Submit CAPTCHA → [CaptchaAI API]
                                    ↓
                            Solve completes
                                    ↓
                            Callback → [API Gateway + Lambda]
                                    ↓
                            Publish → [SNS Topic]
                                    ↓
                    ┌───────────────┼───────────────┐
                    ↓               ↓               ↓
            [SQS Queue]      [Lambda Logger]   [Email Alert]
            (result store)   (audit trail)     (on failure)

Le point clé est le fan-out : le callback publie un unique message, et SNS le recopie vers chaque abonné, sans que le récepteur connaisse les consommateurs. Vous ajoutez un tableau de bord ou une file de reprise plus tard, sans toucher au code d'entrée. Pour une équipe hébergée sur AWS eu-west-3 (Paris), placez le topic, les files et les Lambda dans cette même région pour limiter la latence interne et garder vos données au plus près de vos utilisateurs.

Étape 1 : créer le topic SNS

Le topic est le point de rendez-vous du pipeline : créez-le une fois, puis réutilisez son ARN partout.

AWS CLI

aws sns create-topic --name captcha-results --output text
# Returns: arn:aws:sns:us-east-1:123456789:captcha-results

Python (boto3)

import boto3

sns = boto3.client("sns", region_name="us-east-1")

response = sns.create_topic(Name="captcha-results")
topic_arn = response["TopicArn"]
print(f"Topic ARN: {topic_arn}")

Conservez le TopicArn renvoyé : c'est la valeur que vous injecterez comme variable d'environnement dans le récepteur de callback.

Étape 2 : recevoir le callback et republier dans SNS

Cette fonction Lambda est le seul point d'entrée HTTP du système. Elle reçoit la réponse de CaptchaAI, la valide, puis la republie dans SNS. Gardez-la minimaliste : moins elle en fait, plus le point d'entrée reste rapide.

Python (gestionnaire Lambda)

import json
import os
import boto3

sns = boto3.client("sns")
TOPIC_ARN = os.environ["SNS_TOPIC_ARN"]


def lambda_handler(event, context):
    """Receive CaptchaAI callback and publish to SNS."""
    # Parse query parameters from API Gateway
    params = event.get("queryStringParameters", {}) or {}
    task_id = params.get("id", "")
    solution = params.get("code", "")

    if not task_id or not solution:
        return {"statusCode": 400, "body": "Missing id or code"}

    # Publish to SNS
    message = {
        "task_id": task_id,
        "solution": solution,
        "status": "solved"
    }

    sns.publish(
        TopicArn=TOPIC_ARN,
        Message=json.dumps(message),
        Subject="captcha-solved",
        MessageAttributes={
            "task_id": {
                "DataType": "String",
                "StringValue": task_id
            }
        }
    )

    return {"statusCode": 200, "body": "OK"}

L'attribut de message task_id servira à filtrer les abonnements sans désérialiser le corps du message.

JavaScript (gestionnaire Lambda)

const { SNSClient, PublishCommand } = require("@aws-sdk/client-sns");

const sns = new SNSClient({ region: "us-east-1" });
const TOPIC_ARN = process.env.SNS_TOPIC_ARN;

exports.handler = async (event) => {
  const params = event.queryStringParameters || {};
  const taskId = params.id;
  const solution = params.code;

  if (!taskId || !solution) {
    return { statusCode: 400, body: "Missing id or code" };
  }

  const message = {
    task_id: taskId,
    solution: solution,
    status: "solved",
  };

  await sns.send(
    new PublishCommand({
      TopicArn: TOPIC_ARN,
      Message: JSON.stringify(message),
      Subject: "captcha-solved",
      MessageAttributes: {
        task_id: { DataType: "String", StringValue: taskId },
      },
    })
  );

  return { statusCode: 200, body: "OK" };
};

Étape 3 : soumettre les CAPTCHA avec une URL de callback

Côté soumission, il suffit de renseigner l'URL de votre API Gateway dans le paramètre pingback. CaptchaAI rappellera alors cette URL une fois la résolution terminée, au lieu de vous obliger à interroger le résultat en boucle.

Python

import os
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
CALLBACK_URL = os.environ["CALLBACK_GATEWAY_URL"]  # API Gateway URL


def submit_captcha(sitekey, pageurl):
    """Submit CAPTCHA with SNS-backed callback."""
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "pingback": CALLBACK_URL,
        "json": 1
    })
    data = resp.json()

    if data.get("status") == 1:
        return data["request"]  # task_id
    raise RuntimeError(f"Submit failed: {data.get('request')}")

La méthode userrecaptcha résout ici un reCAPTCHA, mais le schéma est identique pour les autres types pris en charge (Cloudflare Turnstile, GeeTest v3, image/OCR) : seuls le method et ses paramètres changent, le pingback reste le même.

Étape 4 : abonner les consommateurs

C'est ici que le fan-out prend toute sa valeur. Chaque rôle du pipeline devient un abonnement indépendant au même topic.

File SQS pour stocker les résultats

# Subscribe an SQS queue to receive all results
sqs_arn = "arn:aws:sqs:us-east-1:123456789:captcha-results-queue"

sns.subscribe(
    TopicArn=topic_arn,
    Protocol="sqs",
    Endpoint=sqs_arn
)

Lambda pour l'audit

# Subscribe a Lambda for audit logging
lambda_arn = "arn:aws:lambda:us-east-1:123456789:function:captcha-audit-logger"

sns.subscribe(
    TopicArn=topic_arn,
    Protocol="lambda",
    Endpoint=lambda_arn
)

Pour l'audit, appliquez la minimisation du RGPD : journalisez le task_id, le statut et un horodatage, mais évitez d'archiver des données personnelles superflues. Une trace utile n'est pas une trace exhaustive.

Email pour les alertes

# Subscribe email for error notifications with filter
sns.subscribe(
    TopicArn=topic_arn,
    Protocol="email",
    Endpoint="ops@example.com"
)

Étape 5 : consommer les résultats depuis SQS

Votre scraper lit désormais la solution depuis SQS, au rythme de sa propre boucle, au lieu d'interroger CaptchaAI en continu. Le long polling SQS (jusqu'à 20 s) évite de marteler la file quand aucun message n'est disponible.

Python

import json
import boto3

sqs = boto3.client("sqs", region_name="us-east-1")
QUEUE_URL = os.environ["SQS_QUEUE_URL"]


def get_solved_captcha(timeout=30):
    """Wait for a CAPTCHA solution from the SQS queue."""
    response = sqs.receive_message(
        QueueUrl=QUEUE_URL,
        MaxNumberOfMessages=1,
        WaitTimeSeconds=min(timeout, 20)  # Long polling (max 20s)
    )

    messages = response.get("Messages", [])
    if not messages:
        return None

    msg = messages[0]
    # SNS wraps the message — unwrap it
    sns_envelope = json.loads(msg["Body"])
    result = json.loads(sns_envelope["Message"])

    # Delete message after processing
    sqs.delete_message(
        QueueUrl=QUEUE_URL,
        ReceiptHandle=msg["ReceiptHandle"]
    )

    return result

Notez le double json.loads : SNS encapsule votre message dans une enveloppe. Il faut désérialiser l'enveloppe, puis le champ Message qu'elle contient — c'est l'erreur la plus fréquente d'un premier branchement SNS vers SQS.

JavaScript

const {
  SQSClient,
  ReceiveMessageCommand,
  DeleteMessageCommand,
} = require("@aws-sdk/client-sqs");

const sqs = new SQSClient({ region: "us-east-1" });
const QUEUE_URL = process.env.SQS_QUEUE_URL;

async function getSolvedCaptcha(timeout = 30) {
  const response = await sqs.send(
    new ReceiveMessageCommand({
      QueueUrl: QUEUE_URL,
      MaxNumberOfMessages: 1,
      WaitTimeSeconds: Math.min(timeout, 20),
    })
  );

  const messages = response.Messages || [];
  if (messages.length === 0) return null;

  const msg = messages[0];
  const snsEnvelope = JSON.parse(msg.Body);
  const result = JSON.parse(snsEnvelope.Message);

  await sqs.send(
    new DeleteMessageCommand({
      QueueUrl: QUEUE_URL,
      ReceiptHandle: msg.ReceiptHandle,
    })
  );

  return result;
}

Filtrer les messages SNS par statut

Tous les consommateurs n'ont pas besoin de tous les messages. Une politique de filtrage laisse SNS trier à la source et évite d'invoquer un abonné pour rien.

# Only send failures to the ops queue
sns.subscribe(
    TopicArn=topic_arn,
    Protocol="sqs",
    Endpoint=failure_queue_arn,
    Attributes={
        "FilterPolicy": json.dumps({
            "status": ["failed", "error"]
        })
    }
)

La file d'astreinte ne reçoit alors que les échecs, tandis que la file principale continue de tout collecter : même topic, deux abonnements, deux filtres.

Dépannage

Problème Cause probable Correctif
Le callback renvoie 403 La route API Gateway bloque CaptchaAI Ouvrez cette route ou remplacez l'auth par une validation par token
Les messages n'arrivent pas dans SQS Permission SNS vers SQS manquante Ajoutez sns:Publish à la policy de la file SQS
Des doublons sont traités Livraison at-least-once de SNS Rendez le traitement idempotent en vérifiant le task_id avant d'agir
Le callback est lent au premier appel Cold start Lambda Activez la provisioned concurrency sur la Lambda de callback

FAQ

Que se passe-t-il si un consommateur est indisponible au moment de la publication ?

Avec une file SQS abonnée, le message reste en tampon jusqu'à sa lecture, même si votre worker était arrêté. Pour un abonnement e-mail ou HTTP direct, activez une redrive policy vers une file de lettres mortes afin de ne rien perdre.

Comment éviter de traiter deux fois le même résultat ?

SNS assure une livraison at-least-once : un doublon occasionnel est normal. Rendez chaque consommateur idempotent en vérifiant le task_id dans un magasin déjà-vu (table DynamoDB ou clé Redis) avant tout effet de bord.

Faut-il choisir SNS FIFO plutôt que SNS standard ?

Le mode standard suffit tant que l'ordre n'est pas critique, ce qui couvre la plupart des pipelines de résolution. Ne passez à SNS FIFO couplé à SQS FIFO que pour imposer l'ordre par tâche, avec une clé de regroupement explicite.

Comment limiter les données personnelles dans ce pipeline ?

Ne faites transiter dans le message SNS que le nécessaire : task_id, statut et solution technique. Chiffrez le topic et les files côté serveur, restreignez les abonnements par policy IAM, et alignez la rétention SQS sur vos obligations RGPD.

Articles connexes

Prochaines étapes

Assemblez ce pipeline orienté événements, puis récupérez votre clé API CaptchaAI pour le relier à votre stack AWS.

Guides associés :

Les commentaires sont désactivés pour cet article.