Un handler qui attend un token reCAPTCHA v2 peut rester bloqué près d'une minute : bien plus que ce que tolèrent le délai d'acquittement (ack deadline) d'un abonnement Pub/Sub, une exécution Appwrite synchrone ou l'idleTimeout par défaut de Bun. La parade ne dépend pas du framework : répondez tout de suite 202 avec un identifiant de job, confiez l'envoi et le polling à un worker d'arrière-plan qui ne lance jamais plus de résolutions simultanées que votre plan ne compte de threads, puis livrez le token par un endpoint de statut ou par une notification push.
Ce guide applique ce schéma à cinq environnements backend : Encore, en Go comme en TypeScript, avec Pub/Sub ; un service interne Bun + Hono ; Phoenix avec Oban et LiveView ; les exécutions asynchrones d'Appwrite ; les hooks de PocketBase. Pour chacun, il signale le piège qui lui est propre.
Périmètre : les exemples supposent que vous automatisez vos propres sites, ou des sites pour lesquels vous disposez d'une autorisation explicite.
Pourquoi une résolution CAPTCHA ne tient pas dans un handler
Les plafonds de résolution publiés par CaptchaAI
CaptchaAI publie un temps de résolution maximal pour chaque type de CAPTCHA. Dimensionnez vos timeouts sur ces plafonds, jamais sur des moyennes :
| Type de CAPTCHA | Plafond de résolution CaptchaAI |
|---|---|
| Image / OCR | <0,5 s |
| Cloudflare Turnstile | <10 s |
| GeeTest v3 | <12 s |
| Cloudflare Challenge | <15 s |
| reCAPTCHA v2 invisible | <30 s |
| reCAPTCHA v2 | <60 s |
Les délais qui courent déjà dans votre stack
Comparez ces plafonds aux délais imposés par vos plateformes et par les tokens eux-mêmes :
| Délai | Valeur | Source |
|---|---|---|
Ack deadline Pub/Sub d'Encore (le ctx du handler est annulé à son expiration) |
30 s sauf configuration contraire | documentation Encore |
| Exécution synchrone Appwrite | limite stricte de 30 s | documentation Appwrite |
| Réglage Timeout d'une fonction Appwrite | jusqu'à 900 s, maximum système | documentation Appwrite |
idleTimeout de Bun.serve (un handler qui attend n'envoie rien, il est donc considéré comme inactif) |
10 s par défaut, 255 s au maximum | documentation Bun |
$http.send de PocketBase |
timeout de 120 s par défaut, bloque l'appelant | documentation PocketBase |
| Token de réponse reCAPTCHA | valable 2 minutes, vérifiable une seule fois | documentation Google |
| Token Turnstile | valable 5 minutes, à usage unique | documentation Cloudflare |
Le rythme de polling vient de la documentation CaptchaAI : après l'envoi d'une tâche reCAPTCHA, attendez 15 secondes (10 à 15 pour Turnstile), puis interrogez le résultat toutes les 5 secondes. Le code de ce guide ajoute son propre garde-fou : il abandonne 120 secondes après l'envoi, soit deux fois le plafond le plus lent. Ajoutez une dernière requête un peu lente, et un job peut occuper un worker pendant environ deux minutes et demie, ce qu'aucun des délais liés à la requête ne permet d'absorber. La validité des tokens compte tout autant : un token prêt doit atteindre vite son consommateur, et le délai de livraison se pense donc dès la conception.
Allonger le timeout HTTP ne règle rien
Relever le timeout pour attendre la résolution dans le handler est tentant, mais chaque couche entre l'appelant et votre code applique son propre timeout : proxys, load balancers, plafond synchrone de 30 secondes chez Appwrite, logique de retry du client. Un appelant qui atteint son timeout et relance sa requête pendant que votre handler attend toujours déclenche une deuxième résolution, sur un deuxième thread. Une réponse 202 assortie d'une clé d'idempotence rend ces retries inoffensifs.
Le schéma commun : accepter, résoudre en arrière-plan, livrer
Six règles valables sur toutes les plateformes
Toutes les implémentations qui suivent respectent les mêmes règles :
- Accepter sans attendre. Le handler valide
method, le sitekey et l'URL de la page, enregistre un job et répond202avec son identifiant. - Rendre l'opération idempotente. Une clé d'idempotence déjà vue renvoie le job existant. Le worker enregistre l'identifiant de tâche CaptchaAI dès qu'il l'obtient : un message relivré reprend alors le polling au lieu de soumettre une nouvelle tâche.
- Envoyer une fois, interroger à intervalle fixe. Appelez
in.php, attendez 15 s, puis appelezres.phptoutes les 5 s jusqu'à obtenir un token, une vraie erreur, ou jusqu'à 120 s après l'envoi. - Plafonner la concurrence au nombre de threads du plan. CaptchaAI facture par thread simultané, avec des résolutions illimitées par thread ; le plan BASIC ($15/mois, 5 threads) est le plus petit. Quand aucun thread n'est libre,
in.phprépondERROR_ZERO_BALANCE(solde ou threads insuffisants) : c'est un signal pour patienter puis réessayer, pas un échec définitif. - Livrer vite. Stockez le token derrière un endpoint de statut, ou poussez-le vers le client (Phoenix PubSub, Appwrite Realtime).
- Garder la clé côté serveur. Elle réside dans le gestionnaire de secrets ou l'environnement de la plateforme, jamais dans un bundle client, un payload de job ou une ligne de log.
Les deux appels à l'API CaptchaAI
Ces deux appels sont identiques d'une plateforme à l'autre. L'envoi part vers https://ocr.captchaai.com/in.php avec key, method, pageurl, json=1 et le sitekey, dont le nom de paramètre change selon le type : googlekey pour method=userrecaptcha, sitekey pour method=turnstile. En cas de succès, la réponse vaut {"status":1,"request":"<task id>"}. La lecture du résultat se fait sur https://ocr.captchaai.com/res.php avec key, action=get, id=<task id> et json=1 : elle renvoie {"status":0,"request":"CAPCHA_NOT_READY"} tant que le token n'est pas prêt, puis {"status":1,"request":"<token>"}. Toute autre valeur de request associée à un status 0 est un code d'erreur, par exemple ERROR_CAPTCHA_UNSOLVABLE. Les paramètres propres à chaque type sont détaillés dans le pas-à-pas reCAPTCHA v2 et dans le pas-à-pas Cloudflare Turnstile.
Le client TypeScript partagé
Les sections Encore.ts et Bun s'appuient sur le client TypeScript ci-dessous. Il n'utilise que fetch, URLSearchParams et AbortSignal.timeout : il tourne donc tel quel sous Bun comme sous Node, sans la moindre dépendance. Il reçoit l'heure d'envoi en argument, ce qui permet à un worker redémarré de reprendre une tâche sans la soumettre une seconde fois. Pour un typage plus strict couvrant toutes les méthodes, consultez le guide de résolution typée en TypeScript.
// captchaai.ts: CaptchaAI submit + poll, shared by the Encore.ts and Bun services
export type Task =
| { method: "userrecaptcha"; googlekey: string; pageurl: string }
| { method: "turnstile"; sitekey: string; pageurl: string };
export class CaptchaAIError extends Error {
readonly code: string;
constructor(code: string) {
super(code);
this.code = code;
}
}
// Worth retrying later instead of failing the job.
export const RETRYABLE = new Set(["ERROR_ZERO_BALANCE", "ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(path: "in.php" | "res.php", key: string, params: Record<string, string>) {
const query = new URLSearchParams({ ...params, key, json: "1" });
const res = await fetch(`https://ocr.captchaai.com/${path}?${query}`, { signal: AbortSignal.timeout(30_000) });
return (await res.json()) as { status: number; request: string };
}
export async function submit(key: string, task: Task): Promise<string> {
const r = await call("in.php", key, task);
if (r.status !== 1) throw new CaptchaAIError(r.request);
return r.request; // task id
}
export async function waitForResult(key: string, taskId: string, submittedAt = Date.now()): Promise<string> {
await sleep(Math.max(0, submittedAt + 15_000 - Date.now()));
while (Date.now() < submittedAt + 120_000) {
const r = await call("res.php", key, { action: "get", id: taskId });
if (r.status === 1) return r.request; // the token
if (r.request !== "CAPCHA_NOT_READY") throw new CaptchaAIError(r.request);
await sleep(5_000);
}
throw new CaptchaAIError("TIMEOUT_120S");
}
Quelle plateforme pour quel besoin ?
Avant le code, voici les six implémentations côte à côte ; chaque section détaille ensuite sa ligne du tableau.
| Plateforme | Primitive d'arrière-plan | Contrôle de la concurrence | Livraison du résultat | Piège à éviter |
|---|---|---|---|---|
| Encore.go | Abonnement Pub/Sub, livraison at-least-once | MaxConcurrency par instance (ignoré sur Encore Cloud), plus des retries sur ERROR_ZERO_BALANCE |
Ligne Postgres, lue via le GET /solve/:id privé |
L'AckDeadline par défaut de 30 s annule le ctx en plein polling |
| Encore.ts | Subscription importée de encore.dev/pubsub |
maxConcurrency, avec les mêmes réserves |
Ligne SQLDatabase |
Même ack deadline ; les durées s'écrivent en chaînes, comme "3m" |
| Bun + Hono | File d'attente dans le processus | Compteur MAX_IN_FLIGHT exact |
Map en mémoire avec TTL | Un redémarrage perd les jobs ; une seule réplique possible |
| Phoenix + Oban | Job d'envoi, puis job de polling qui snooze | Limite de file par nœud ; un job en snooze n'occupe aucun slot | Phoenix.PubSub vers le handle_info de la LiveView |
Un snooze ne consomme pas de tentative : plafonnez sur le temps écoulé |
| Appwrite | Exécution asynchrone | Rien d'intégré ; gérez ERROR_ZERO_BALANCE côté appelant |
Ligne de table, suivie avec Realtime | Les exécutions synchrones s'arrêtent à 30 s ; les corps de réponse asynchrones ne sont pas conservés |
| PocketBase | Polling par cronAdd sur des identifiants de tâche persistés |
Rien sur les envois ; ERROR_ZERO_BALANCE enregistre un record en échec |
Record de collection protégé par une règle owner |
Portée isolée des handlers ; cron limité à un passage par minute |
En bref : pour une file durable qui survit aux redémarrages, Encore ou Phoenix + Oban ; pour un petit outil interne à processus unique, Bun + Hono. Appwrite et PocketBase conviennent aussi, mais sans plafond de concurrence : le client doit relancer après un ERROR_ZERO_BALANCE, et le cron de PocketBase peut retarder le token jusqu'à une minute.
Encore.go : secret, endpoints privés et worker Pub/Sub
Encore.go déclare chaque élément du schéma comme une ressource : un secret, une base Postgres, un topic, un abonnement et, en option, une tâche cron. Le service ci-dessous expose deux endpoints privés que les autres services Encore appellent comme de simples fonctions (solver.Enqueue(ctx, &solver.SolveParams{...})), si bien que la clé CaptchaAI ne quitte jamais le service de résolution. Les noms de secrets sont globaux à l'application ; définissez la valeur pour chaque type d'environnement avec encore secret set --type dev,local CaptchaAIKey, puis une seconde fois avec --type prod. La migration se range dans solver/migrations/ :
-- solver/migrations/1_create_solve_jobs.up.sql
CREATE TABLE solve_jobs (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
method TEXT NOT NULL,
sitekey TEXT NOT NULL,
pageurl TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
task_id TEXT,
token TEXT,
error TEXT,
submitted_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Le fichier du service déclare le secret, la base, le topic et l'abonnement comme variables au niveau du package (Encore impose cette forme pour les topics et les abonnements), puis définit les endpoints et le worker :
// solver/solver.go
package solver
import (
"context"
"errors"
"time"
"encore.dev/beta/errs"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
)
var secrets struct {
CaptchaAIKey string
}
var db = sqldb.NewDatabase("solver", sqldb.DatabaseConfig{Migrations: "./migrations"})
type SolveRequested struct {
JobID int64
}
var SolveRequests = pubsub.NewTopic[*SolveRequested]("solve-requests", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
var _ = pubsub.NewSubscription(SolveRequests, "run-solve", pubsub.SubscriptionConfig[*SolveRequested]{
Handler: runSolve,
MaxConcurrency: 5, // per service instance; ignored on Encore Cloud
AckDeadline: 3 * time.Minute, // must outlast the 120 s polling cap plus one slow request
RetryPolicy: &pubsub.RetryPolicy{
MinBackoff: 10 * time.Second,
MaxBackoff: 2 * time.Minute,
MaxRetries: 10,
},
})
type SolveParams struct {
IdempotencyKey string `json:"idempotency_key"`
Method string `json:"method"` // "userrecaptcha" or "turnstile"
SiteKey string `json:"sitekey"`
PageURL string `json:"pageurl"`
}
type Accepted struct {
JobID int64 `json:"job_id"`
Status int `encore:"httpstatus"`
}
//encore:api private method=POST path=/solve
func Enqueue(ctx context.Context, p *SolveParams) (*Accepted, error) {
if p.IdempotencyKey == "" || p.SiteKey == "" || p.PageURL == "" ||
(p.Method != "userrecaptcha" && p.Method != "turnstile") {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "idempotency_key, sitekey, pageurl and a supported method are required"}
}
var id int64
err := db.QueryRow(ctx, `
INSERT INTO solve_jobs (idempotency_key, method, sitekey, pageurl)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`, p.IdempotencyKey, p.Method, p.SiteKey, p.PageURL).Scan(&id)
switch {
case errors.Is(err, sqldb.ErrNoRows): // key seen before: return the existing job, publish nothing
err = db.QueryRow(ctx, `SELECT id FROM solve_jobs WHERE idempotency_key = $1`, p.IdempotencyKey).Scan(&id)
case err == nil:
if _, err = SolveRequests.Publish(ctx, &SolveRequested{JobID: id}); err != nil {
// Drop the row so a retry with the same key starts clean instead of finding a job nobody runs.
_, _ = db.Exec(ctx, `DELETE FROM solve_jobs WHERE id = $1`, id)
}
}
if err != nil {
return nil, err
}
return &Accepted{JobID: id, Status: 202}, nil
}
type Job struct {
Status string `json:"status"`
Token string `json:"token,omitempty"`
Error string `json:"error,omitempty"`
}
//encore:api private method=GET path=/solve/:id
func GetJob(ctx context.Context, id int64) (*Job, error) {
var j Job
err := db.QueryRow(ctx, `SELECT status, COALESCE(token, ''), COALESCE(error, '') FROM solve_jobs WHERE id = $1`, id).
Scan(&j.Status, &j.Token, &j.Error)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "no such job"}
}
if err != nil {
return nil, err
}
return &j, nil
}
// runSolve is idempotent: a redelivered message resumes the stored task instead of submitting again.
func runSolve(ctx context.Context, ev *SolveRequested) error {
var method, sitekey, pageurl, status, taskID string
var submittedAt time.Time
err := db.QueryRow(ctx, `
SELECT method, sitekey, pageurl, status, COALESCE(task_id, ''), COALESCE(submitted_at, now())
FROM solve_jobs WHERE id = $1`, ev.JobID).
Scan(&method, &sitekey, &pageurl, &status, &taskID, &submittedAt)
if errors.Is(err, sqldb.ErrNoRows) || status == "solved" || status == "failed" {
return nil
}
if err != nil {
return err
}
if taskID == "" {
if taskID, err = submit(ctx, method, sitekey, pageurl); err != nil {
return settle(ctx, ev.JobID, err)
}
submittedAt = time.Now()
if _, err := db.Exec(ctx, `UPDATE solve_jobs SET status = 'submitted', task_id = $2, submitted_at = $3 WHERE id = $1`,
ev.JobID, taskID, submittedAt); err != nil {
return err
}
}
token, err := waitForResult(ctx, taskID, submittedAt)
if err != nil {
return settle(ctx, ev.JobID, err)
}
_, err = db.Exec(ctx, `UPDATE solve_jobs SET status = 'solved', token = $2, finished_at = now() WHERE id = $1`, ev.JobID, token)
return err
}
// settle hands retryable errors back to Pub/Sub (redelivery with backoff) and records the rest.
func settle(ctx context.Context, id int64, err error) error {
if retryable(err) {
return err
}
_, dbErr := db.Exec(ctx, `UPDATE solve_jobs SET status = 'failed', error = $2, finished_at = now() WHERE id = $1`, id, err.Error())
return dbErr
}
Le client CaptchaAI vit dans le même package afin de pouvoir lire secrets. Il distingue les codes d'erreur CaptchaAI des erreurs réseau, car les deux appellent un traitement différent :
// solver/captchaai.go
package solver
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"time"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
var errTimedOut = errors.New("no result within 120 s of submission")
// apiError carries a CaptchaAI error code such as ERROR_ZERO_BALANCE.
type apiError struct{ Code string }
func (e *apiError) Error() string { return e.Code }
// retryable: no free thread or balance, a transient server error, or a network/context error.
func retryable(err error) bool {
var ae *apiError
if !errors.As(err, &ae) {
return !errors.Is(err, errTimedOut)
}
switch ae.Code {
case "ERROR_ZERO_BALANCE", "ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR":
return true
}
return false
}
func call(ctx context.Context, endpoint string, params url.Values) (string, error) {
params.Set("key", secrets.CaptchaAIKey)
params.Set("json", "1")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+params.Encode(), nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
Status int `json:"status"`
Request string `json:"request"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode %s: %w", endpoint, err)
}
if out.Status != 1 {
return "", &apiError{Code: out.Request}
}
return out.Request, nil
}
func submit(ctx context.Context, method, sitekey, pageurl string) (string, error) {
p := url.Values{"method": {method}, "pageurl": {pageurl}}
if method == "userrecaptcha" {
p.Set("googlekey", sitekey)
} else {
p.Set("sitekey", sitekey)
}
return call(ctx, "https://ocr.captchaai.com/in.php", p)
}
// waitForResult: first poll 15 s after submission, then every 5 s, stop 120 s after submission.
func waitForResult(ctx context.Context, taskID string, submittedAt time.Time) (string, error) {
next := submittedAt.Add(15 * time.Second)
for deadline := submittedAt.Add(120 * time.Second); next.Before(deadline); next = time.Now().Add(5 * time.Second) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Until(next)):
}
token, err := call(ctx, "https://ocr.captchaai.com/res.php", url.Values{"action": {"get"}, "id": {taskID}})
var ae *apiError
if errors.As(err, &ae) && ae.Code == "CAPCHA_NOT_READY" {
continue
}
return token, err
}
return "", errTimedOut
}
Trois particularités d'Encore à ne pas négliger
Trois détails propres à Encore déterminent si ce service se comporte comme prévu :
- L'ack deadline borne aussi votre contexte. Encore annule le
ctxdu handler dès queAckDeadlineest dépassé et remet le message à disposition pour une nouvelle livraison. Avec la valeur par défaut de 30 secondes, une résolution reCAPTCHA v2 lente est coupée en plein polling et n'aboutit qu'après une ou plusieurs relivraisons, chacune décomptée deMaxRetries. Trois minutes couvrent le plafond de 120 secondes plus une dernière requête lente. MaxConcurrencyn'est pas un sémaphore global. La documentation Pub/Sub d'Encore et la référence du package le définissent par instance, sans effet sur les environnements Encore Cloud, et avec une concurrence adaptative sur les abonnements push de GCP Cloud Run. Considérez-le comme un frein local : le vrai plafond reste leERROR_ZERO_BALANCErenvoyé par CaptchaAI, traité comme une erreur réessayable pour que la politique de retry espace la tentative suivante.- La relivraison est normale. La livraison est at-least-once et Encore attend des handlers idempotents. Enregistrer
task_idavant le premier polling transforme une relivraison en « reprise du polling » plutôt qu'en « un thread de plus consommé ». Une foisMaxRetriesépuisé, le message part dans la dead-letter queue et la ligne reste à l'étatqueuedousubmitted: les appelants deGET /solve/:idont donc besoin de leur propre délai d'abandon.
Surveiller l'occupation des threads
Pour suivre la charge de vos threads, ajoutez une tâche cron qui appelle l'action documentée threadsinfo (threads totaux et threads occupés). Les crons Encore ne s'exécutent ni en local ni dans les environnements de preview, et l'offre gratuite (Free Tier) les limite à une exécution par heure au plus : ce job sert donc surtout à alimenter un tableau de bord.
// solver/threads.go
package solver
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"encore.dev/cron"
"encore.dev/rlog"
)
var _ = cron.NewJob("captchaai-threads", cron.JobConfig{
Title: "Log CaptchaAI thread usage",
Every: 1 * cron.Hour,
Endpoint: CheckThreads,
})
//encore:api private
func CheckThreads(ctx context.Context) error {
var body bytes.Buffer
form := multipart.NewWriter(&body)
form.WriteField("key", secrets.CaptchaAIKey)
form.WriteField("action", "threadsinfo")
form.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://ocr.captchaai.com/res.php", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", form.FormDataContentType())
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var usage struct {
Threads string `json:"threads"`
WorkingThreads int `json:"working_threads"`
}
if err := json.NewDecoder(resp.Body).Decode(&usage); err != nil {
return err
}
rlog.Info("captchaai thread usage", "threads", usage.Threads, "working_threads", usage.WorkingThreads)
return nil
}
Pour un client Go sans framework, reportez-vous à la résolution de CAPTCHA en Go. Si votre service est une application Fiber plutôt qu'un service Encore, le guide dédié au serveur Fiber couvre cette configuration.
Encore.ts : secret(), api() et Topic/Subscription
Le SDK TypeScript fournit les mêmes briques sous d'autres noms. Un secret se déclare au niveau supérieur du module avec secret("CaptchaAIKey"), importé de encore.dev/config, et se lit en l'appelant comme une fonction. api(), importé de encore.dev/api, prend expose, method et path. Dans la configuration de l'abonnement, les durées sont des chaînes comme "3m" ou "10s". Placez ce fichier à côté d'un encore.service.ts qui exporte new Service("solver"), réutilisez la migration de la section Go et importez le client partagé captchaai.ts :
// solver/solver.ts
import { api, APIError, HttpStatus } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { Subscription, Topic } from "encore.dev/pubsub";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { CaptchaAIError, RETRYABLE, submit, waitForResult, type Task } from "./captchaai";
const captchaAIKey = secret("CaptchaAIKey");
const db = new SQLDatabase("solver", { migrations: "./migrations" });
export const solveRequests = new Topic<{ jobId: number }>("solve-requests", {
deliveryGuarantee: "at-least-once",
});
interface SolveParams {
idempotencyKey: string;
method: "userrecaptcha" | "turnstile";
sitekey: string;
pageurl: string;
}
interface Accepted { jobId: number; status: HttpStatus }
interface JobStatus { status: string; token?: string; error?: string }
export const enqueue = api(
{ expose: false, method: "POST", path: "/solve" },
async ({ idempotencyKey, method, sitekey, pageurl }: SolveParams): Promise<Accepted> => {
if (!idempotencyKey || !sitekey || !pageurl) {
throw APIError.invalidArgument("idempotencyKey, sitekey and pageurl are required");
}
const inserted = await db.queryRow`
INSERT INTO solve_jobs (idempotency_key, method, sitekey, pageurl)
VALUES (${idempotencyKey}, ${method}, ${sitekey}, ${pageurl})
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`;
if (inserted) {
try {
await solveRequests.publish({ jobId: Number(inserted.id) });
} catch (err) {
await db.exec`DELETE FROM solve_jobs WHERE id = ${inserted.id}`; // a retry with the same key starts clean
throw err;
}
}
const row = inserted ?? (await db.queryRow`SELECT id FROM solve_jobs WHERE idempotency_key = ${idempotencyKey}`);
return { jobId: Number(row!.id), status: HttpStatus.Accepted };
},
);
export const getJob = api(
{ expose: false, method: "GET", path: "/solve/:id" },
async ({ id }: { id: number }): Promise<JobStatus> => {
const row = await db.queryRow`SELECT status, token, error FROM solve_jobs WHERE id = ${id}`;
if (!row) throw APIError.notFound("no such job");
return { status: row.status, token: row.token ?? undefined, error: row.error ?? undefined };
},
);
const _ = new Subscription(solveRequests, "run-solve", {
handler: async ({ jobId }) => {
const job = await db.queryRow`
SELECT method, sitekey, pageurl, status, task_id, submitted_at FROM solve_jobs WHERE id = ${jobId}`;
if (!job || job.status === "solved" || job.status === "failed") return; // duplicate delivery
const task = (job.method === "turnstile"
? { method: "turnstile", sitekey: job.sitekey, pageurl: job.pageurl }
: { method: "userrecaptcha", googlekey: job.sitekey, pageurl: job.pageurl }) as Task;
try {
let taskId: string | null = job.task_id;
let submittedAt = job.submitted_at ? new Date(job.submitted_at).getTime() : Date.now();
if (!taskId) {
taskId = await submit(captchaAIKey(), task);
submittedAt = Date.now();
await db.exec`UPDATE solve_jobs SET status = 'submitted', task_id = ${taskId}, submitted_at = now() WHERE id = ${jobId}`;
}
const token = await waitForResult(captchaAIKey(), taskId, submittedAt);
await db.exec`UPDATE solve_jobs SET status = 'solved', token = ${token}, finished_at = now() WHERE id = ${jobId}`;
} catch (err) {
if (!(err instanceof CaptchaAIError) || RETRYABLE.has(err.code)) throw err; // redeliver with backoff
await db.exec`UPDATE solve_jobs SET status = 'failed', error = ${err.code}, finished_at = now() WHERE id = ${jobId}`;
}
},
maxConcurrency: 5,
ackDeadline: "3m",
retryPolicy: { minBackoff: "10s", maxBackoff: "2m", maxRetries: 10 },
});
Les pièges sont les mêmes qu'en Go : le SDK documente une valeur par défaut de 30 secondes pour ackDeadline, et un maxConcurrency appliqué par instance, sans effet sur Encore Cloud. Lever une exception dans le handler provoque un nack du message, et retryPolicy.maxRetries décide du moment où il bascule dans la dead-letter queue. Avec expose: false, seuls les autres services de l'application peuvent appeler ces endpoints.
Bun + Hono : un service interne de 50 lignes, livré en un seul binaire
Parfois, il suffit d'un petit service interne qui transforme « résous ce CAPTCHA » en identifiant de job. Pensez à une équipe QA, à Lyon ou à Montréal, dont plusieurs suites Playwright tournent contre la préproduction et doivent se partager une seule clé et un même budget de concurrence. Avec Bun et Hono, cela tient en un fichier posé à côté du client partagé, sans base de données. Lancez bun add hono, placez captchaai.ts dans le même dossier et démarrez le service avec bun run index.ts :
// index.ts: internal CaptchaAI job service (Bun + Hono)
import { Hono } from "hono";
import { bearerAuth } from "hono/bearer-auth";
import { CaptchaAIError, submit, waitForResult, type Task } from "./captchaai";
const KEY = process.env.CAPTCHAAI_KEY ?? "";
const TOKEN = process.env.SOLVER_TOKEN ?? "";
if (!KEY || !TOKEN) throw new Error("set CAPTCHAAI_KEY and SOLVER_TOKEN");
const MAX_IN_FLIGHT = Number(process.env.PLAN_THREADS ?? 5); // BASIC plan: 5 threads
const TTL_MS = 10 * 60_000;
type Job = { status: "queued" | "running" | "solved" | "failed"; token?: string; error?: string; created: number };
const jobs = new Map<string, Job>();
const queue: Array<{ id: string; task: Task }> = [];
let inFlight = 0;
function pump() {
while (inFlight < MAX_IN_FLIGHT && queue.length > 0) {
const { id, task } = queue.shift()!;
const job = jobs.get(id)!;
job.status = "running";
inFlight++;
submit(KEY, task)
.then((taskId) => waitForResult(KEY, taskId))
.then((token) => Object.assign(job, { status: "solved", token }))
.catch((e) => Object.assign(job, { status: "failed", error: e instanceof CaptchaAIError ? e.code : String(e) }))
.finally(() => { inFlight--; pump(); });
}
}
setInterval(() => {
for (const [id, job] of jobs) {
const done = job.status === "solved" || job.status === "failed";
if (done && Date.now() - job.created > TTL_MS) jobs.delete(id);
}
}, 60_000);
const app = new Hono();
app.use("*", bearerAuth({ token: TOKEN }));
app.post("/solve", async (c) => {
const task = (await c.req.json()) as Task;
const hasKey = task.method === "userrecaptcha" ? Boolean(task.googlekey) : task.method === "turnstile" && Boolean(task.sitekey);
if (!hasKey || !task.pageurl) {
return c.json({ error: "method (userrecaptcha|turnstile), pageurl and its site key are required" }, 400);
}
const id = c.req.header("Idempotency-Key") ?? Bun.randomUUIDv7();
if (!jobs.has(id)) {
jobs.set(id, { status: "queued", created: Date.now() });
queue.push({ id, task });
pump();
}
return c.json({ id, status: jobs.get(id)!.status }, 202);
});
app.get("/solve/:id", (c) => {
const job = jobs.get(c.req.param("id"));
return job ? c.json(job) : c.json({ error: "unknown or expired job" }, 404);
});
export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch };
Les appelants envoient une tâche avec l'en-tête Authorization: Bearer <SOLVER_TOKEN> et un en-tête Idempotency-Key, reçoivent 202 et un identifiant, puis interrogent GET /solve/:id. Comme chaque handler répond immédiatement, l'idleTimeout de 10 secondes de Bun n'entre jamais en jeu. MAX_IN_FLIGHT est le plafond aligné sur les threads du plan ; il reste exact tant que ce seul processus effectue toutes les soumissions de la clé. C'est aussi la limite de l'outil : la map des jobs vit en mémoire, donc un redémarrage efface tous les jobs et une deuxième réplique double le nombre de résolutions simultanées. Pour la durabilité ou plusieurs répliques, préférez l'une des architectures à file persistante de ce guide.
Pour livrer un exécutable unique, lancez bun build --compile --minify --target=bun-linux-x64 ./index.ts --outfile solver, puis démarrez ./solver avec CAPTCHAAI_KEY, SOLVER_TOKEN et PLAN_THREADS dans son environnement, par exemple sur une petite VM Linux chez Scaleway ou OVHcloud. N'ajoutez pas --env=inline : sans cette option, les références à process.env restent des lectures à l'exécution, et la clé n'est jamais figée dans un binaire que l'on copie d'une machine à l'autre. La version Node + Express de cet outil se trouve dans le guide Express.js côté serveur.
Phoenix : Req, jobs Oban mis en snooze pendant le polling, mises à jour LiveView
En Elixir, le découpage naturel repose sur deux workers Oban. Un job d'envoi appelle in.php et planifie un job de polling 15 secondes plus tard ; ce dernier se met en pause (le « snooze » d'Oban) par pas de 5 secondes jusqu'à l'arrivée du token. Aucun des deux n'immobilise un processus pendant que CaptchaAI travaille. Avec req et oban dans vos dépendances, le client reste court :
# lib/my_app/captcha.ex
defmodule MyApp.Captcha do
@moduledoc "CaptchaAI client: in.php to submit, res.php to read the result (json=1)."
@base "https://ocr.captchaai.com"
@retryable ~w(ERROR_ZERO_BALANCE ERROR_SERVER_ERROR ERROR_INTERNAL_SERVER_ERROR)
def submit(task), do: request("/in.php", task)
def result(task_id), do: request("/res.php", %{"action" => "get", "id" => task_id})
def retryable?(code), do: code in @retryable
def notify(ref, message), do: Phoenix.PubSub.broadcast(MyApp.PubSub, "captcha:" <> ref, message)
defp request(path, params) do
key = Application.fetch_env!(:my_app, :captchaai_key)
query = params |> Map.merge(%{"key" => key, "json" => "1"}) |> Enum.to_list()
# Req retries GET on transient failures by default; a retried in.php call could create a second task.
retry = if path == "/in.php", do: false, else: :safe_transient
case Req.get(@base <> path, params: query, receive_timeout: 30_000, retry: retry) do
{:ok, %Req.Response{body: body}} -> decode(body)
{:error, exception} -> {:error, {:transport, exception}}
end
end
defp decode(body) when is_binary(body), do: body |> Jason.decode!() |> decode()
defp decode(%{"status" => 1, "request" => value}), do: {:ok, value}
defp decode(%{"request" => code}), do: {:error, code}
end
Les workers portent les règles de timing, avec une contrainte unique calée sur la clé de requête fournie par l'appelant :
# lib/my_app/captcha/workers.ex
defmodule MyApp.Captcha.SubmitWorker do
use Oban.Worker, queue: :captcha_submit, max_attempts: 5, unique: [period: 300, keys: [:request_key]]
alias MyApp.Captcha
@impl Oban.Worker
def perform(%Oban.Job{args: %{"request_key" => ref, "task" => task}, inserted_at: inserted_at}) do
case Captcha.submit(task) do
{:ok, task_id} ->
%{"request_key" => ref, "task_id" => task_id, "submitted_at" => System.system_time(:second)}
|> MyApp.Captcha.PollWorker.new(schedule_in: 15)
|> Oban.insert()
{:error, "ERROR_ZERO_BALANCE"} ->
# No free thread (or no balance): wait, but not forever. Snoozing does not use up attempts.
if DateTime.diff(DateTime.utc_now(), inserted_at) < 600, do: {:snooze, 10}, else: give_up(ref, "NO_FREE_THREADS")
{:error, {:transport, _} = reason} ->
{:error, reason}
{:error, code} ->
if Captcha.retryable?(code), do: {:error, code}, else: give_up(ref, code)
end
end
# Tell the waiting LiveView before cancelling, or it shows "solving" forever.
defp give_up(ref, reason) do
Captcha.notify(ref, {:captcha_failed, ref, reason})
{:cancel, reason}
end
end
defmodule MyApp.Captcha.PollWorker do
use Oban.Worker, queue: :captcha_poll, max_attempts: 3
alias MyApp.Captcha
@impl Oban.Worker
def perform(%Oban.Job{args: %{"request_key" => ref, "task_id" => task_id, "submitted_at" => at}}) do
case Captcha.result(task_id) do
{:ok, token} ->
Captcha.notify(ref, {:captcha_solved, ref, token})
{:error, "CAPCHA_NOT_READY"} ->
if System.system_time(:second) - at < 120, do: {:snooze, 5}, else: give_up(ref, "TIMEOUT")
{:error, {:transport, _} = reason} ->
{:error, reason}
{:error, code} ->
give_up(ref, code)
end
end
defp give_up(ref, reason) do
Captcha.notify(ref, {:captcha_failed, ref, reason})
{:cancel, reason}
end
end
Branchez Oban et la clé dans la configuration, puis ajoutez {Oban, Application.fetch_env!(:my_app, Oban)} à la liste des enfants supervisés dans application.ex, à côté de l'entrée Phoenix.PubSub que le générateur a déjà créée :
# config/config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [captcha_submit: 5, captcha_poll: 10]
# config/runtime.exs
config :my_app, :captchaai_key, System.fetch_env!("CAPTCHAAI_KEY")
La LiveView s'abonne au topic du job avant d'insérer celui-ci : elle ne peut donc pas manquer un résultat rapide. Le token reste dans les assigns côté serveur ; la page n'affiche que le statut :
# lib/my_app_web/live/solve_live.ex
defmodule MyAppWeb.SolveLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket), do: {:ok, assign(socket, status: :idle, ref: nil, token: nil)}
def handle_event("solve", %{"sitekey" => sitekey, "pageurl" => pageurl}, socket) do
ref = Ecto.UUID.generate()
Phoenix.PubSub.subscribe(MyApp.PubSub, "captcha:" <> ref)
%{"request_key" => ref, "task" => %{"method" => "userrecaptcha", "googlekey" => sitekey, "pageurl" => pageurl}}
|> MyApp.Captcha.SubmitWorker.new()
|> Oban.insert()
{:noreply, assign(socket, status: :solving, ref: ref)}
end
def handle_info({:captcha_solved, ref, token}, %{assigns: %{ref: ref}} = socket),
do: {:noreply, assign(socket, status: :solved, token: token)}
def handle_info({:captcha_failed, ref, reason}, %{assigns: %{ref: ref}} = socket),
do: {:noreply, assign(socket, status: {:failed, reason})}
# Results for an earlier request (the user clicked Solve twice) are ignored.
def handle_info(_message, socket), do: {:noreply, socket}
def render(assigns) do
~H"""
<form phx-submit="solve">
<input name="sitekey" placeholder="Site key" />
<input name="pageurl" placeholder="Page URL" />
<button>Solve</button>
</form>
<p>Status: <%= inspect(@status) %></p>
"""
end
end
Deux comportements d'Oban expliquent ces choix. D'abord, la documentation d'Oban.Worker précise qu'un snooze ramène en arrière le compteur de tentatives du job : max_attempts n'arrête donc jamais un job qui se remet sans cesse en snooze, d'où les plafonds fondés sur le temps écoulé dans les deux workers. Ensuite, les limites de file s'appliquent par nœud (les limites globales sont une fonctionnalité d'Oban Pro), et un job de polling en snooze n'occupe aucun slot : captcha_submit: 5 régule les envois sur un nœud, sans plafonner les résolutions en cours. Le plafond qui fait foi reste ERROR_ZERO_BALANCE, auquel le worker d'envoi répond par un snooze. Pour des pipelines de traitement par lots plutôt que des résolutions à la requête, consultez le guide des pipelines Broadway.
Appwrite Functions : timeouts, exécutions asynchrones et stockage du résultat
Des deux modes d'exécution d'Appwrite, un seul convient à une résolution CAPTCHA. D'après la documentation d'exécution d'Appwrite, les exécutions synchrones ont une limite stricte de 30 secondes, alors que les exécutions asynchrones passent par une file et ne sont bornées que par le Timeout de la fonction (Settings > Timeout, jusqu'à un maximum système de 900 secondes). Réglez-le à environ 180 secondes pour couvrir le plafond de polling de 120 secondes, une dernière requête lente et les écritures en base.
Le hic : les exécutions asynchrones ne conservent pas le corps de la réponse. Le token doit donc atterrir là où le client peut le lire, ici une ligne de la table captcha_jobs avec les colonnes texte status, method, pageurl, token et error. La fonction l'écrit avec la clé éphémère qu'Appwrite transmet dans l'en-tête x-appwrite-key (activez le scope rows.write dans Settings > Scopes de la fonction). Une ligne créée avec une clé serveur ne porte aucune permission, et personne ne pourrait la lire : la fonction accorde donc la lecture à l'utilisateur qui a lancé l'exécution, dont l'identifiant arrive dans x-appwrite-user-id. CAPTCHAAI_KEY et DATABASE_ID sont des variables d'environnement de la fonction, si bien que la clé CaptchaAI n'atteint jamais un client :
// src/main.js: Appwrite Function (Node.js runtime), always executed with async: true
import { Client, Permission, Role, TablesDB } from 'node-appwrite';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function captchaai(path, params) {
const query = new URLSearchParams({ ...params, key: process.env.CAPTCHAAI_KEY, json: '1' });
const response = await fetch(`https://ocr.captchaai.com/${path}?${query}`, { signal: AbortSignal.timeout(30_000) });
return response.json();
}
export default async ({ req, res, log }) => {
const userId = req.headers['x-appwrite-user-id'];
if (!userId) return res.json({ error: 'execute as a signed-in user' }, 401);
const { rowId, method, sitekey, pageurl } = req.bodyJson;
const client = new Client()
.setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
.setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
.setKey(req.headers['x-appwrite-key']);
const tables = new TablesDB(client);
const where = { databaseId: process.env.DATABASE_ID, tableId: 'captcha_jobs', rowId };
try {
// rowId doubles as the idempotency key: a second execution for the same row stops here.
await tables.createRow({
...where,
data: { status: 'queued', method, pageurl },
permissions: [Permission.read(Role.user(userId))], // only the requesting user can read (and watch) it
});
} catch (e) {
if (e.code === 409) return res.json({ duplicate: true });
throw e;
}
const finish = async (data) => {
await tables.updateRow({ ...where, data });
return res.json({ status: data.status });
};
const task = method === 'turnstile'
? { method: 'turnstile', sitekey, pageurl }
: { method: 'userrecaptcha', googlekey: sitekey, pageurl };
const submitted = await captchaai('in.php', task);
if (submitted.status !== 1) return finish({ status: 'failed', error: submitted.request });
await tables.updateRow({ ...where, data: { status: 'submitted' } });
const deadline = Date.now() + 120_000;
await sleep(15_000);
while (Date.now() < deadline) {
const r = await captchaai('res.php', { action: 'get', id: submitted.request });
if (r.status === 1) return finish({ status: 'solved', token: r.request });
if (r.request !== 'CAPCHA_NOT_READY') return finish({ status: 'failed', error: r.request });
await sleep(5_000);
}
log(`task ${submitted.request} not ready after 120 s`);
return finish({ status: 'failed', error: 'TIMEOUT' });
};
Côté appelant, le code génère l'identifiant de ligne, lance l'exécution en arrière-plan et surveille cette ligne :
// Caller side (Appwrite web SDK): start the solve without waiting for it
import { Functions, ID } from 'appwrite';
export async function requestSolve(client, functionId, task) {
const rowId = ID.unique();
await new Functions(client).createExecution({
functionId,
body: JSON.stringify({ rowId, ...task }),
async: true,
});
return rowId; // subscribe to this row with Realtime, or read it until status is solved or failed
}
Realtime ne diffuse que les événements des lignes que l'utilisateur peut lire, d'où l'importance de la permission par ligne. Activez la sécurité au niveau des lignes (row security) sur captcha_jobs et n'accordez aucune lecture au niveau de la table, car une lecture ouverte à tous les utilisateurs exposerait les tokens de chacun. Réservez l'accès Execute de la fonction aux utilisateurs connectés : quiconque peut l'exécuter peut consommer vos threads. Rien ici ne plafonne les exécutions simultanées, donc un ERROR_ZERO_BALANCE se traduit par une ligne en échec ; faites réessayer l'appelant après un court délai.
PocketBase : pb_hooks avec routerAdd, $http.send et polling par cronAdd
PocketBase exécute les hooks JavaScript des fichiers *.pb.js du dossier pb_hooks sur un moteur embarqué (goja), et non sur Node : fetch n'y existe pas. Les appels HTTP sortants passent par $http.send qui, selon la documentation HTTP de PocketBase, bloque jusqu'à réception de la réponse complète. Cela convient à in.php, qui répond aussitôt avec un identifiant de tâche, mais pas à l'attente sur res.php : la route envoie donc la tâche et persiste son identifiant, puis un cron se charge du polling.
Créez une collection captcha_jobs avec les champs texte status, method, sitekey, pageurl, task_id, token, error, un champ numérique submitted et un champ relation owner pointant vers users. Fixez ses règles List et View à owner = @request.auth.id et laissez Create, Update et Delete verrouillées (réservées aux superusers), puisque les hooks écrivent via $app et ne sont pas soumis à ces règles. Démarrez PocketBase avec CAPTCHAAI_KEY dans son environnement.
Chaque handler s'exécute dans un contexte isolé et ne voit pas les fonctions déclarées au niveau supérieur du fichier. Le code partagé va donc dans un module chargé avec require à l'intérieur du handler :
// pb_hooks/captchaai.js: loaded with require() inside each handler
module.exports = {
call: (path, params) => {
const q = Object.assign({}, params, { key: $os.getenv("CAPTCHAAI_KEY"), json: "1" });
const qs = Object.keys(q).map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(q[k])).join("&");
return $http.send({ url: "https://ocr.captchaai.com/" + path + "?" + qs, method: "GET", timeout: 30 }).json;
},
taskParams: (b) => b.method === "turnstile"
? { method: "turnstile", sitekey: b.sitekey, pageurl: b.pageurl }
: { method: "userrecaptcha", googlekey: b.sitekey, pageurl: b.pageurl },
};
Le fichier de hooks proprement dit déclare la route d'envoi et le cron de polling :
// pb_hooks/captcha.pb.js
/// <reference path="../pb_data/types.d.ts" />
// POST /api/captcha/solve: store the job and submit it. Never wait for the answer here.
routerAdd("POST", "/api/captcha/solve", (e) => {
const cai = require(`${__hooks}/captchaai.js`);
const body = e.requestInfo().body;
const job = new Record($app.findCollectionByNameOrId("captcha_jobs"));
job.set("owner", e.auth.id);
job.set("method", body.method);
job.set("sitekey", body.sitekey);
job.set("pageurl", body.pageurl);
const r = cai.call("in.php", cai.taskParams(body));
if (r.status === 1) {
job.set("status", "submitted");
job.set("task_id", r.request);
job.set("submitted", Math.floor(Date.now() / 1000));
} else {
job.set("status", "failed");
job.set("error", r.request);
}
$app.save(job);
return e.json(202, { id: job.id, status: job.getString("status") });
}, $apis.requireAuth());
// Every minute: one res.php check per open task, with a 3-minute cap.
cronAdd("captcha_poll", "* * * * *", () => {
const cai = require(`${__hooks}/captchaai.js`);
const open = $app.findRecordsByFilter("captcha_jobs", "status = 'submitted'", "submitted", 50, 0);
for (const job of open) {
const r = cai.call("res.php", { action: "get", id: job.getString("task_id") });
if (r.status === 1) {
job.set("status", "solved");
job.set("token", r.request);
} else if (r.request !== "CAPCHA_NOT_READY") {
job.set("status", "failed");
job.set("error", r.request);
} else if (Date.now() / 1000 - job.getInt("submitted") > 180) {
job.set("status", "failed");
job.set("error", "TIMEOUT");
} else {
continue;
}
$app.save(job);
}
});
$apis.requireAuth() réserve l'endpoint aux utilisateurs connectés, et la règle owner limite chacun à la lecture de ses propres jobs, via l'API records habituelle ou un abonnement realtime. Rien ne plafonne ici les soumissions simultanées : sous charge, in.php répond ERROR_ZERO_BALANCE et le job est sauvegardé en échec ; faites réessayer le client après un court délai. Le point faible, c'est le timing. Une expression cron à cinq champs se déclenche au plus une fois par minute, si bien qu'un token prêt peut attendre jusqu'à une minute avant d'être relevé. C'est confortable pour les cinq minutes de validité de Turnstile, mais serré pour les deux minutes de reCAPTCHA. Si votre trafic est surtout du reCAPTCHA v2, consommez le token dès que le job passe à solved, ou résolvez depuis un vrai processus worker et laissez PocketBase stocker le résultat.
Dépannage
| Symptôme | Cause | Correctif |
|---|---|---|
Sous Encore, les jobs reCAPTCHA journalisent context canceled et n'aboutissent qu'après plusieurs relivraisons |
L'AckDeadline par défaut de 30 s annule le ctx du handler en plein polling |
Passez AckDeadline à 3 minutes ; grâce au task_id enregistré, chaque relivraison reprend le polling au lieu de soumettre à nouveau |
| L'exécution Appwrite échoue au bout de 30 secondes | Elle a tourné en mode synchrone | Appelez createExecution avec async: true |
| L'exécution Appwrite asynchrone s'arrête avant l'arrivée du token | Timeout de la fonction inférieur au plafond de polling de 120 secondes | Montez Settings > Timeout à environ 180 s |
| La ligne du job Appwrite existe, mais le client ne la voit jamais | Ligne créée avec la clé serveur, sans permission | Passez Permission.read(Role.user(userId)) à createRow et activez la sécurité par ligne |
Pic de ERROR_ZERO_BALANCE après un passage à plusieurs instances |
Les limites par instance ou par nœud se multiplient avec les répliques, et le total des envois dépasse les threads du plan | Traitez-le comme de la contre-pression et réessayez avec backoff ; baissez les limites par instance ou passez à un plan supérieur |
| Token rejeté par le formulaire cible | Token expiré ou réutilisé : 2 minutes pour reCAPTCHA, 5 pour Turnstile, un seul usage dans les deux cas | Consommez-le dès réception, préférez le push à un polling lent, ne mettez jamais de token en cache |
| La clé API apparaît dans les logs | URL complètes de in.php journalisées, alors que la clé est un paramètre de requête |
Ne journalisez que les identifiants de tâche et les codes d'erreur ; gardez la clé dans le gestionnaire de secrets de la plateforme |
Questions fréquentes
L'interrogation de res.php consomme-t-elle des threads ?
Non. Un thread correspond à un CAPTCHA en cours de résolution et se libère dès que cette résolution se termine ; le polling ne fait que lire l'état d'une tâche qui occupe déjà un thread. Ce sont les envois qui consomment des threads : voilà pourquoi la clé d'idempotence et l'identifiant de tâche enregistré comptent davantage que la fréquence du polling.
Combien de threads prévoir pour un service de ce type ?
Dimensionnez en fonction du type le plus lent que vous traitez. Au plafond reCAPTCHA v2 de moins de 60 secondes, chaque thread termine au moins une résolution par minute ; au plafond Turnstile de moins de 10 secondes, au moins six. Le plan BASIC ($15/mois) offre 5 threads, le plan STANDARD ($30/mois) en offre 15. Si ERROR_ZERO_BALANCE apparaît en trafic normal, et pas seulement lors de pics, votre plan est devenu trop petit.
Peut-on journaliser les tokens ou les URL de requête pour déboguer ?
Non. Un token journalisé n'apporte rien au débogage, puisqu'il est à usage unique, mais il reste exploitable par quiconque lit les logs tant qu'il est valide. Les URL complètes de in.php contiennent en outre votre clé API. Journalisez les identifiants de tâche et les codes d'erreur, rien de plus. Enfin, si les URL de pages que vous stockez contiennent des identifiants d'utilisateurs, vos tables de jobs renferment sans doute des données personnelles : vérifiez vos obligations RGPD et purgez régulièrement les lignes terminées.
Ce schéma fonctionne-t-il pour GeeTest v3 ou les CAPTCHA image ?
Le schéma, oui ; le code tel quel, non. Les exemples de ce guide n'acceptent que userrecaptcha et turnstile. CaptchaAI résout aussi GeeTest v3, Cloudflare Challenge et les CAPTCHA image/OCR, mais chaque type a ses propres paramètres d'envoi : étendez la définition des tâches et la validation du handler en suivant la documentation du type concerné.
Pour aller plus loin
Le même schéma en Python est décrit dans le guide du microservice FastAPI et dans le guide Celery et Redis ; pour des services Rust sous Axum ou Actix, voyez la résolution de CAPTCHA en Rust. Créez votre clé CaptchaAI, stockez-la dans le gestionnaire de secrets de votre plateforme, puis déplacez votre première résolution hors du handler, dans un worker.