Skip to main content

Cyber AI România

Vineri, 6 noiembrie 2026

Timeout după Publish: cum confirmi postarea înainte să o trimiți din nou

Workerul trimite o postare, conexiunea expiră, iar răspunsul cu identificatorul postării nu mai ajunge. În acel moment există două rezultate posibile: platforma nu a procesat cererea sau a publicat conținutul înainte ca răspunsul să se piardă. Un al doilea POST executat imediat poate transforma o problemă de observabilitate într-o postare duplicată.

Soluția este state reconciliation: păstrezi local intenția de publicare, marchezi rezultatul drept necunoscut după timeout și verifici starea reală a contului înainte să permiți o nouă încercare. Tutorialul construiește această decizie în jurul unei amprente a conținutului, al unei ferestre temporale și al unei stări separate pentru cazurile ambigue.

Timeout-ul nu trebuie tradus automat prin „publicarea a eșuat”

Un timeout descrie ce a observat clientul: nu a primit la timp un răspuns complet. Nu dovedește că serverul nu a executat operațiunea.

De exemplu, platforma poate salva postarea, genera identificatorul și trimite răspunsul, dar conexiunea se poate întrerupe înainte ca workerul să îl primească. Același efect apare când un gateway returnează 504 Gateway Timeout: conform semanticii HTTP, gateway-ul nu a primit la timp răspunsul de la serviciul din amonte, nu confirmă că acel serviciu a abandonat operațiunea.

De aceea, modelul local nu trebuie să aibă doar stările PUBLISHED și FAILED. Sunt necesare cel puțin:

  • PUBLISHING — cererea urmează să fie trimisă sau este în curs;
  • PUBLISHED — API-ul a returnat identificatorul postării;
  • UNKNOWN — răspunsul lipsește, iar efectul pe platformă nu este cunoscut;
  • CONFIRMED_BY_RECONCILIATION — postarea a fost găsită ulterior;
  • RETRYABLE — verificările nu au găsit postarea, iar politica internă permite o nouă încercare;
  • REVIEW_REQUIRED — au fost găsite mai multe rezultate plauzibile sau datele nu permit o decizie sigură.

Această separare completează mecanismele de prevenire descrise în ghidul despre idempotency keys și jurnalul de execuție. Cheia de idempotency protejează reluarea numai dacă platforma acceptă și respectă un asemenea mecanism. Reconcilierea rămâne necesară atunci când API-ul social nu oferă această garanție sau când comportamentul său nu este documentat clar.

Pasul 1 — Salvează intenția înainte ca cererea să plece spre platformă

Înregistrarea locală trebuie creată înainte de apelul de publicare. Dacă procesul cade după trimitere, baza de date păstrează suficiente informații pentru investigație.

O structură minimă poate arăta astfel:

CREATE TABLE publication_attempts (
    operation_id TEXT PRIMARY KEY,
    platform TEXT NOT NULL,
    account_id TEXT NOT NULL,
    content_fingerprint TEXT NOT NULL,
    request_started_at TEXT NOT NULL,
    request_finished_at TEXT,
    state TEXT NOT NULL,
    platform_post_id TEXT,
    platform_permalink TEXT,
    last_error_class TEXT,
    reconciliation_attempts INTEGER NOT NULL DEFAULT 0,
    next_check_at TEXT,
    updated_at TEXT NOT NULL
);

CREATE INDEX idx_publication_reconciliation
ON publication_attempts (state, next_check_at);

operation_id este identificatorul intern al unei singure intenții editoriale. El nu trebuie regenerat la fiecare reluare a workerului.

account_id delimitează contul sau pagina unde trebuia să apară postarea. Același text publicat pe două pagini diferite reprezintă două operațiuni distincte.

content_fingerprint este o amprentă calculată din conținutul pregătit efectiv pentru platformă, nu din articolul sursă. Dacă adaptorul scurtează textul, schimbă linkul sau selectează alte fișiere media, amprenta trebuie calculată după acea transformare.

request_started_at păstrează momentul trimiterii. Fără el, reconcilerul nu poate delimita postările relevante de publicațiile istorice care au același text.

platform_post_id rămâne gol până când identificatorul este primit direct sau recuperat prin reconciliere.

Înainte de apel, workerul efectuează următoarea tranziție într-o tranzacție locală:

READY -> PUBLISHING

Abia după salvarea stării PUBLISHING trimite cererea către platformă. Dacă API-ul răspunde cu succes și oferă identificatorul obiectului, workerul salvează:

PUBLISHING -> PUBLISHED

Dacă apare un timeout de rețea, un răspuns incomplet sau o întrerupere înainte ca răspunsul să poată fi interpretat, tranziția corectă este:

PUBLISHING -> UNKNOWN

Nu folosi FAILED pentru această situație. FAILED ar sugera că operațiunea poate fi repetată fără o verificare suplimentară.

Pasul 2 — Generează aceeași amprentă pentru cerere și pentru candidați

Potrivirea numai după text este fragilă. Două postări legitime pot avea aceeași descriere, iar unele platforme pot normaliza spațiile sau finalurile de linie.

Amprenta trebuie să includă numai câmpuri stabile și recuperabile. Un exemplu rezonabil este combinația dintre cont, textul normalizat, destinația linkului și identificatorii fișierelor media din propriul sistem.

Scriptul următor generează amprenta și decide ce stare rezultă din lista postărilor candidate. El folosește numai biblioteca standard Python:

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable


@dataclass(frozen=True)
class Publication:
    platform_post_id: str
    account_id: str
    text: str
    target_url: str
    media_asset_ids: tuple[str, ...]
    created_at: datetime
    permalink: str


def normalize_text(value: str) -> str:
    return " ".join(value.split())


def content_fingerprint(
    account_id: str,
    text: str,
    target_url: str,
    media_asset_ids: Iterable[str],
) -> str:
    canonical = {
        "account_id": account_id,
        "text": normalize_text(text),
        "target_url": target_url.strip(),
        "media_asset_ids": sorted(media_asset_ids),
    }
    encoded = json.dumps(
        canonical,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def reconcile(
    expected_fingerprint: str,
    candidates: Iterable[Publication],
    window_start: datetime,
    window_end: datetime,
) -> dict[str, str | None]:
    matches: list[Publication] = []

    for candidate in candidates:
        if not window_start <= candidate.created_at <= window_end:
            continue

        candidate_fingerprint = content_fingerprint(
            account_id=candidate.account_id,
            text=candidate.text,
            target_url=candidate.target_url,
            media_asset_ids=candidate.media_asset_ids,
        )

        if candidate_fingerprint == expected_fingerprint:
            matches.append(candidate)

    if len(matches) == 1:
        match = matches[0]
        return {
            "decision": "CONFIRMED_BY_RECONCILIATION",
            "platform_post_id": match.platform_post_id,
            "permalink": match.permalink,
        }

    if len(matches) > 1:
        return {
            "decision": "REVIEW_REQUIRED",
            "platform_post_id": None,
            "permalink": None,
        }

    return {
        "decision": "NOT_FOUND_YET",
        "platform_post_id": None,
        "permalink": None,
    }


if __name__ == "__main__":
    request_time = datetime(2026, 9, 26, 10, 0, tzinfo=timezone.utc)

    expected = content_fingerprint(
        account_id="pagina-exemplu",
        text="Programul evenimentului este disponibil pe site.",
        target_url="https://example.com/eveniment",
        media_asset_ids=("asset-editorial-42",),
    )

    observed = [
        Publication(
            platform_post_id="platform-post-987",
            account_id="pagina-exemplu",
            text="Programul evenimentului este disponibil pe site.",
            target_url="https://example.com/eveniment",
            media_asset_ids=("asset-editorial-42",),
            created_at=datetime(2026, 9, 26, 10, 1, tzinfo=timezone.utc),
            permalink="https://example.com/postare/platform-post-987",
        )
    ]

    result = reconcile(
        expected_fingerprint=expected,
        candidates=observed,
        window_start=request_time,
        window_end=datetime(2026, 9, 26, 10, 5, tzinfo=timezone.utc),
    )

    print(json.dumps(result, ensure_ascii=False, indent=2))

Adresele și identificatorii din exemplu sunt date de documentare. În integrarea reală, permalink trebuie preluat din răspunsul oficial al platformei, nu construit prin concatenarea unor șiruri.

Rezultatul exemplului este:

{
  "decision": "CONFIRMED_BY_RECONCILIATION",
  "platform_post_id": "platform-post-987",
  "permalink": "https://example.com/postare/platform-post-987"
}

Amprenta nu trebuie plasată în textul public doar pentru a putea fi căutată. Un cod tehnic vizibil degradează postarea și poate fi eliminat de editor. Păstrează-l în jurnalul intern și compară proprietățile recuperate prin API.

Pasul 3 — Interoghează contul corect și o fereastră temporală îngustă

După trecerea în UNKNOWN, un job separat consultă interfața oficială a platformei pentru postările recente ale contului vizat. Endpointul, permisiunile și câmpurile disponibile diferă între platforme și între tipurile de cont, deci nu există o adresă API universală.

Adaptorul platformei trebuie să returneze cel puțin:

  • identificatorul postării;
  • identificatorul autorului, paginii sau organizației;
  • momentul publicării;
  • textul disponibil prin API;
  • referința linkului și a materialului media, dacă sunt expuse;
  • permalinkul oficial, dacă platforma îl furnizează.

Căutarea trebuie limitată în jurul lui request_started_at. Fereastra exactă este o politică internă, nu o regulă garantată de platformă. Ea trebuie să țină cont de întârzierile de procesare, postările programate și diferențele dintre ora aplicației și ora raportată de API.

Aplică filtrele în această ordine:

  1. selectează numai contul destinație;
  2. elimină postările din afara ferestrei temporale;
  3. calculează amprenta pentru fiecare candidat;
  4. compară amprenta cu intenția locală;
  5. acceptă automat numai un rezultat unic;
  6. trimite rezultatele multiple spre verificare umană.

Dacă textul recuperat nu conține toate elementele folosite în amprenta locală, nu completa câmpurile lipsă prin presupuneri. Definește pentru acel adaptor o amprentă mai restrânsă și compensează cu alte semnale, precum momentul publicării și identificatorul fișierului media.

O postare găsită trebuie deschisă și prin permalink, dacă acesta este disponibil. Un obiect returnat de API poate avea altă stare decât o postare publică obișnuită: de exemplu, poate fi programat, retras sau vizibil numai într-un context limitat. Starea exactă depinde de capabilitățile documentate ale platformei.

Pasul 4 — Separă „nu a fost găsită” de permisiunea de a retrimite

Prima interogare fără rezultate nu dovedește că publicarea lipsește. Indexarea, procesarea materialului media sau consistența dintre serviciile platformei pot întârzia vizibilitatea obiectului.

Reconcilerul trebuie să întoarcă NOT_FOUND_YET, nu să trimită direct încă un POST. Workerul programează o nouă verificare, incrementând reconciliation_attempts.

O politică internă poate folosi intervale progresive între verificări. Valorile se aleg după măsurători proprii și după limitele oficiale ale API-ului. Adaugă variație aleatorie controlată, numită jitter, pentru ca toate joburile care au eșuat simultan să nu interogheze platforma în același moment. Ghidul AWS despre timeout-uri și retries explică rolul backoff-ului și al jitter-ului în reducerea încărcării sincronizate.

Decizia de retry trebuie luată numai după îndeplinirea simultană a condițiilor interne:

  • au fost executate verificările stabilite de politică;
  • fereastra de așteptare s-a încheiat;
  • nu există webhook sau răspuns întârziat neprocesat;
  • nu a fost găsit niciun candidat;
  • nu există o altă execuție activă pentru același operation_id;
  • tranziția din UNKNOWN în RETRYABLE este salvată atomic.

Pentru retry păstrează aceeași intenție editorială și leagă noua încercare de operațiunea inițială. Nu crea un al doilea obiect local independent, deoarece vei pierde relația dintre cele două trimiteri.

Articolul despre rate limits, retries și reconcilierea stării oferă contextul mai larg al limitării cererilor. În fluxul de față, accentul rămâne mai îngust: dovada că obiectul există înainte de autorizarea retrimiterii.

Pasul 5 — Folosește webhook-ul ca dovadă suplimentară, nu ca unic adevăr

Platformele care oferă webhooks pot anunța schimbările fără interogări repetate. Meta documentează webhooks pentru Pages, inclusiv notificări legate de activitatea din feed-ul unei pagini.

Evenimentul trebuie salvat mai întâi într-un inbox local, apoi procesat. Handlerul HTTP nu ar trebui să modifice direct starea editorială înainte ca evenimentul să fie validat și corelat.

Fluxul devine:

timeout
  -> UNKNOWN
  -> webhook sau interogare API
  -> corelare cu intenția locală
  -> CONFIRMED_BY_RECONCILIATION

Dacă webhook-ul conține identificatorul postării, adaptorul îl poate folosi pentru o citire suplimentară prin API. Astfel, decizia se bazează pe obiectul curent al platformei, nu numai pe conținutul notificării.

Webhook-ul nu elimină reconcilierea periodică. Configurația sa poate fi greșită, aplicația poate fi indisponibilă, iar evenimentul poate ajunge înainte ca tranzacția locală să fie vizibilă procesatorului. Jobul periodic rămâne plasa de siguranță.

Păstrează separat:

  • payloadul brut, cu acces restricționat;
  • momentul recepției;
  • identificatorul evenimentului, dacă platforma îl oferă;
  • rezultatul validării;
  • operațiunea locală asociată;
  • tranziția de stare efectuată.

Nu salva tokenuri sau antete de autorizare în jurnal. Pentru controlul editorial anterior publicării, fluxul poate fi combinat cu un sistem de aprobare în două etape, fără ca aprobarea să fie confundată cu dovada publicării.

Pasul 6 — Oprește automatizarea când rezultatul nu este unic

Potrivirea automată este sigură numai când există un singur candidat suficient de puternic. Dacă două postări au aceeași amprentă în fereastra analizată, sistemul nu trebuie să aleagă prima sau cea mai recentă.

Starea devine REVIEW_REQUIRED, iar operatorul primește:

  • operation_id;
  • contul și platforma;
  • momentul cererii;
  • eroarea observată;
  • lista identificatorilor candidați;
  • momentele publicării;
  • permalinkurile oficiale;
  • diferențele dintre conținutul așteptat și cel recuperat.

Operatorul verifică postările direct în contul administrat și decide care aparține operațiunii. Dacă există deja un duplicat, nu mai pornește alt retry; urmează politica editorială pentru retragerea sau păstrarea lui.

Nici absența permisiunilor nu trebuie transformată în RETRYABLE. Un răspuns de autorizare insuficientă, un token expirat sau imposibilitatea de a citi feed-ul înseamnă că sistemul nu poate obține dovada. Starea corectă rămâne UNKNOWN sau devine REVIEW_REQUIRED, în funcție de politica operațională.

Testul de capăt urmărește decizia, nu doar răspunsul HTTP

În mediul de test al aplicației sau într-un cont controlat, simulează trei situații:

  • API-ul răspunde cu identificatorul postării;
  • postarea este creată, dar clientul simulează pierderea răspunsului;
  • cererea nu produce nicio postare.

Pentru al doilea caz, adaptorul de test trebuie să creeze obiectul în depozitul simulat și apoi să ridice o excepție de timeout. Rezultatul corect este:

PUBLISHING
UNKNOWN
CONFIRMED_BY_RECONCILIATION

Pentru al treilea caz, rezultatul trebuie să rămână UNKNOWN pe durata verificărilor, apoi să ajungă în RETRYABLE numai prin tranziția controlată. Testul eșuează dacă workerul trimite automat o a doua cerere imediat după timeout.

Lista de acceptare pentru producție este:

  • ☐ intenția este salvată înaintea apelului API;
  • ☐ timeout-ul produce UNKNOWN, nu FAILED;
  • ☐ amprenta folosește conținutul final trimis platformei;
  • ☐ reconcilerul verifică autorul și intervalul temporal;
  • ☐ un singur candidat poate confirma automat publicarea;
  • ☐ zero candidați declanșează o nouă verificare, nu un retry imediat;
  • ☐ mai mulți candidați produc REVIEW_REQUIRED;
  • ☐ identificatorul și permalinkul recuperate sunt salvate;
  • ☐ tokenurile nu apar în loguri;
  • ☐ o singură execuție poate autoriza retrimiterea.

Ultima decizie nu este „a expirat cererea, deci mai încercăm”, ci „avem sau nu avem dovada că postarea există?”. Dacă dovada lipsește din cauza permisiunilor ori a rezultatelor ambigue, automatizarea trebuie să se oprească înainte să publice din nou.

Întrebări frecvente

De ce nu marchez operațiunea drept eșuată imediat după timeout?

Timeout-ul arată că răspunsul nu a ajuns la timp, nu că platforma nu a creat postarea. Starea UNKNOWN păstrează această diferență și blochează retrimiterea prematură.

Este suficient să caut textul postării?

Nu. Pot exista texte identice, iar platforma poate normaliza conținutul. Potrivirea trebuie să includă contul, intervalul temporal și, când API-ul le expune, linkul și referințele media.

Ce fac dacă reconcilerul găsește două postări potrivite?

Operațiunea trece în REVIEW_REQUIRED. Sistemul prezintă identificatorii și permalinkurile, iar un operator verifică obiectele fără să lanseze încă un retry.

Webhook-ul înlocuiește interogarea periodică?

Nu. Webhook-ul oferă o dovadă suplimentară și poate accelera confirmarea, dar jobul periodic rămâne necesar pentru situațiile în care notificarea nu poate fi corelată sau procesată.

Surse

Facebook
X
WhatsApp
Timeout după Publish: cum confirmi postarea înainte să o trimiți din nou

Te-ar putea interesa si: