NovinkaZverejnili sme 50+ Laravel balíkov ako open source
Custom AI apps, agents and automation — Roundly ConsultingRoundly
Všetky balíky
Sentinel for Laravel

Idempotenčné kľúče

Klienti posielajú hlavičku Idempotency-Key (draft-ietf-httpapi-idempotency-key-header-07). Prvá požiadavka prebehne; opakovania dostanú uloženú odpoveď s Idempotent-Replayed: true; ten istý kľúč s iným obsahom odpovie 422 a požiadavka, ktorá ešte beží, 409 s Retry-After. Odpovede 5xx kľúč uvoľnia, aby to klient mohol skúsiť znova.

Route::post('/orders', StoreOrder::class)->middleware(['auth:sanctum', 'sentinel.idempotent']);           // optional key
Route::post('/payments', StorePayment::class)->middleware(['auth:sanctum', 'sentinel.idempotent:required']);
Route::post('/exports', StoreExport::class)->middleware('sentinel.idempotent:optional,3600');          // own TTL

// The same, validated when the route is declared (a TTL outside 60–2 592 000 throws):
Route::post('/payments', StorePayment::class)->middleware(['auth:sanctum', EnsureIdempotency::required()]);
Route::post('/exports', StoreExport::class)->middleware(EnsureIdempotency::optional(ttl: 3600));

Zaraďte ho za auth, aby bol kľúč viazaný na používateľa.

Poradie kontrol

  • Metódy mimo idempotency.methods (POST, PATCH) prejdú bez zmeny.
  • Hlavička sa parsuje ako reťazec RFC 9651; s accept_unquoted sa prijme aj hodnota bez úvodzoviek. Dĺžka musí byť min_length..max_length (16..255; min_length väčšie než max_length sa odmietne ako neplatná konfigurácia). Neplatná alebo opakovaná → 400 invalid_idempotency_key; chýbajúca → 400 idempotency_key_missing pri :required, inak prejde.
  • Scope: user:<guard>:<id> pre prihláseného, inak sig:<ring>:<keyid>, ak je na požiadavke overený HTTP podpis, inak ip:<ip> — plus routa. Uložený kľúč je digest zo scope, routy a kľúča.
  • Odtlačok: metóda, surová cesta, surový query reťazec, content type a SHA-256 surového tela. Pri multipart/form-data sa namiesto toho viažu polia v poradí a pri každom súbore jeho názov od klienta, veľkosť a SHA-256.
SituáciaOdpoveď
Žiadny záznam, alebo expirovaný (po TTL a bez živého lease)Prevezme ho (processing) → spustí handler
Iný odtlačok (v akomkoľvek stave)422 idempotency_key_reused
processing, lease platný409 idempotency_request_in_progress + Retry-After
processing, lease vypršal (lock_seconds)Prevezme ho (compare-and-swap na tokene vlastníka) → spustí handler
completed, dá sa prehraťPrehrá uloženú odpoveď + Idempotent-Replayed: true
completed, nedá sa prehrať (streamovaná, príliš veľká)409 idempotent_response_unavailable
Uložený záznam bol upravený (nečitateľný)409 idempotent_response_unavailable — nikdy sa nespustí dvakrát naslepo

Čo sa ukladá

  • Status < 500: status, hlavičky z replayed_headers a telo — pri zapnutom idempotency.encrypt (predvolené) zašifrované AES-256-GCM pod kľúčom odvodeným z APP_KEY len pre uložené odpovede, viazané na digest a scope kľúča, takže odpoveď skopírovaná k inému kľúču alebo podstrčená ako nešifrovaný text sa nikdy neprehrá. 4xx len so store_client_errors (predvolene zapnuté).
  • ≥ 500: kľúč sa uvoľní, aby to klient mohol skúsiť znova (pokiaľ nie je zapnuté store_server_errors).
  • Streamované či súborové odpovede a telá nad max_response_bytes sa dokončia, ale nedajú sa prehrať. Set-Cookie sa nikdy neukladá ani neprehráva.
  • Kľúče expirujú ttl sekúnd (predvolene 24 hodín) od prvého výskytu — uveďte to v dokumentácii svojho API — no nikdy, kým ich drží živý lease.

Odmietnutia sú odpovede application/problem+json podľa RFC 9457 s členom code a spúšťajú IdempotencyRejected; prehratia spúšťajú IdempotentRequestReplayed. So zapnutým idempotency.transactional (len úložisko database) sa handler a idempotenčný záznam potvrdia v jednej transakcii — práve raz pre zápisy na tomto pripojení, za cenu transakcie držanej počas celej požiadavky.

Programové behy

Pre joby, príkazy a spracovanie webhookov:

$result = Sentinel::idempotency()->run(
    "charge:{$order->id}",
    scope: 'billing',
    callback: fn () => $gateway->charge($order),
    fingerprint: "order:{$order->id}:{$order->total}",   // optional — reuse with another one → 422
    ttl: 3600,                                         // optional
    lease: 900,                                        // optional — above the callback's longest run
);

$result->value;         // the JSON round-trip of the callback's result — the same on every run
$result->replayed;      // true on repeats
$result->firstSeenAt;

Sentinel::idempotency()->forget("charge:{$order->id}", scope: 'billing');
use RoundlyConsulting\Sentinel\Actions\Idempotency\RunIdempotentAction;
use RoundlyConsulting\Sentinel\DataTransferObjects\IdempotentCall;

$result = Sentinel::runIdempotent(new IdempotentCall("charge:{$order->id}", 'billing', fn () => $gateway->charge($order)));
Sentinel::forgetIdempotencyKey("charge:{$order->id}", 'billing');           // bool: whether it existed

$result = app(RunIdempotentAction::class)->execute(new IdempotentCall("charge:{$order->id}", 'billing', fn () => $gateway->charge($order)));
  • Kľúč aj scope majú 1–255 bajtov — scope nikdy nie je prázdny — ttl je 60–2 592 000 sekúnd a lease 1–86 400 sekúnd; čokoľvek iné vyhodí InvalidIdempotencyKeyException skôr, než sa siahne na úložisko. Zvoľte si vlastné scopes (billing, webhooks), nie také, ktoré pripomínajú HTTP scopes.
  • fingerprint odmietne opätovné použitie kľúča s iným vstupom (IdempotencyKeyReusedException).
  • value je vždy to, čo vráti prehratie — JSON round-trip výsledku callbacku — takže $result->value['id'] funguje pri prvom behu aj pri každom opakovaní.
  • Callback, ktorý vyhodí výnimku, kľúč vráti. Callback, ktorý prebehol, no vrátil niečo, čo sa nedá uložiť (binárne dáta, INF, resource), sa už nikdy nespustí: jeho kľúč sa dokončí bez výsledku (IdempotentResultException) a opakovanie sa odmietne (IdempotentResponseUnavailableException, 409), aby sa vedľajší účinok nezopakoval.
  • Výsledok, ktorý úložisko nedokáže uchovať — chýba APP_KEY na jeho zašifrovanie, úložisko pri dokončení zlyhá —, kľúč aj tak dokončí ako neprehrateľný a chybu vyhodí znova, takže opakovanie dostane IdempotentResponseUnavailableException (409) a callback sa po uplynutí lease nespustí druhýkrát. Kľúč ostane rozpracovaný až do konca lease len pri páde medzi callbackom a dokončením.
  • Lease určuje, ako dlho bežiace volanie drží kľúč; callbacku, ktorý môže bežať dlhšie než lock_seconds, dajte lease dlhší než jeho najdlhší beh.

Joby vo fronte

Middleware jobov Idempotent spustí job najviac raz pre kľúč — webhook doručený znova ako druhý job, dvojité odoslanie — teda nad rámec toho, čo pokrýva ShouldBeUnique (len kým je job vo fronte) a WithoutOverlapping (len súbežne):

use RoundlyConsulting\Sentinel\Jobs\Middleware\Idempotent;

final class HandleStripeEvent implements ShouldQueue
{
    use InteractsWithQueue, Queueable;

    public function __construct(public readonly StripeEvent $event) {}

    public function middleware(): array
    {
        return [new Idempotent("stripe:{$this->event->id}", scope: 'webhooks')];
    }
}

new Idempotent(string $key, string $scope = 'jobs', ?int $ttl = null, int $releaseAfter = 10, ?int $lease = null) overí argumenty už pri vytvorení. Bežiaci job drží kľúč počas svojho lease: lease, ak je zadaný, inak $timeout jobu, inak retry_after pripojenia fronty — nikdy menej než idempotency.lock_seconds a najviac deň. Fronta sync nemá retry_after, takže job bez $timeout tam drží kľúč len lock_seconds (predvolene 60 sekúnd) a duplikát, ktorý príde neskôr, sa spustí popri prvom behu, ktorý ešte neskončil: takému jobu dajte $timeout alebo zadajte lease.

SituáciaVýsledok
Prvý beh sa dokončíKľúč sa dokončí (výsledok null).
Duplikát potomDokončí sa bez spustenia.
Duplikát, kým prvý ešte bežírelease(max(Retry-After, releaseAfter)) späť do fronty — alebo znovu vyhodí IdempotencyRequestInProgressException pri jobe bez release().
Job vyhodí výnimkuKľúč sa uvoľní; fronta job zopakuje alebo označí za neúspešný ako zvyčajne.
Job sám zavolá release() alebo fail()Kľúč sa uvoľní (beh sa nedokončil), takže opakovanie prebehne.
Job prebehol, no jeho dokončenie sa nepodarilo uložiťKľúč sa dokončí ako neprehrateľný a chyba sa znovu vyhodí; opätovné doručenie job už nikdy nespustí.
Opätovné doručenie kľúča dokončeného ako neprehrateľnýZlyhá s IdempotentResponseUnavailableException (409, idempotent_response_unavailable) bez spustenia jobu — zámerne sa nepreskočí (pozri nižšie).

Job, ktorý opakovane zlyháva s 409

Kľúč sa dokončí ako neprehrateľný, keď jeho beh svoju prácu urobil, no Sentinel nedokázal zaznamenať čisté dokončenie. Vedľajší účinok — platba, e-mail, volanie API — teda pravdepodobne nastal, a preto znova doručený job s týmto kľúčom (opakovanie neúspešného pokusu frontou, druhé odoslanie) zlyhá s IdempotentResponseUnavailableException (409, rejection() = IdempotencyRejection::Unavailable) namiesto toho, aby sa potichu dokončil ako bežný duplikát. Je to zámer: preskočenie by zakrylo beh, ktorého výsledok nikto nezaznamenal, a nové spustenie by mohlo vedľajší účinok zopakovať. Fronta job zopakuje až do jeho $tries — každý pokus zlyhá bez spustenia — a potom ho presunie do failed_jobs; každé ďalšie doručenie zlyhá rovnako, kým kľúč neexpiruje. Čo urobiť:

  • Preskúmajte: nájdite chybu prvého pokusu (vo vašich logoch alebo vo failed_jobs — chybu úložiska alebo IdempotentResultException s jej príčinou) a overte, či vedľajší účinok nastal (u poskytovateľa platieb, v logu e-mailov, v API partnera).
  • Nastal: nič viac netreba — job zmažte (php artisan queue:forget <id>, hromadne queue:prune-failed). Kľúč ostane dokončený, kým neexpiruje, a duplikáty ďalej odmieta.
  • Nenastal alebo sa má spustiť znova: uvoľnite kľúč cez Sentinel::idempotency()->forget($key, $scope) — s tým istým kľúčom a scope, s ktorými bol middleware vytvorený — a potom spustite php artisan queue:retry <id>. Ďalší pokus job spustí.

Ak sa to chcete dozvedieť hneď a nie až dodatočne, zachyťte výnimku v metóde failed() jobu:

use RoundlyConsulting\Sentinel\Exceptions\IdempotentResponseUnavailableException;

public function failed(Throwable $exception): void
{
    if ($exception instanceof IdempotentResponseUnavailableException) {
        // The key's first run may have charged already: page someone to inspect it.
    }
}

Strana klienta

Http::withIdempotencyKey()->post('https://api.example.com/orders', $payload);          // a fresh UUIDv7
Http::withIdempotencyKey($order->uuid)->post('https://api.example.com/orders', $payload);

Úložiská

database (predvolené) používa sentinel_idempotency_keys s jedinečným digestom kľúča, insert-or-ignore a compare-and-swap na tokene vlastníka. cache vyžaduje store s atomickými zámkami (inak sa pri štarte odmietne). Pre vlastné úložisko naviažte Contracts\IdempotencyStore (pozri Rozšírenie). Všetky úložiská zdieľajú jednu rozhodovaciu tabuľku a osem súbežných požiadaviek s rovnakým kľúčom na PostgreSQL aj MySQL má práve jedného vlastníka.

Prejavte lásku k open source

Tento balík je zadarmo pod licenciou MIT. Ak vám šetrí čas, jednorazový príspevok alebo členstvo na Patreone nám pomôže ho ďalej udržiavať, testovať a dokumentovať.

Ďalšie spôsoby podpory vrátane kryptomien

Odoslaním daru súhlasíte s našimi podmienkami prijímania darov.

Chcete to zabudovať do svojho produktu?

Naše balíky integrujeme do zákazkových Laravel a AI riešení. Napíšte nám, na čom pracujete, a ozveme sa do 48 hodín.