<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: WilhelmKnight8435</title>
    <description>The latest articles on DEV Community by WilhelmKnight8435 (@wilhelmknight8435).</description>
    <link>https://dev.to/wilhelmknight8435</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4098536%2F8cf609de-5d8a-4e66-9927-251f99e2141a.png</url>
      <title>DEV Community: WilhelmKnight8435</title>
      <link>https://dev.to/wilhelmknight8435</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wilhelmknight8435"/>
    <language>en</language>
    <item>
      <title>OAuth Failure Recovery: Why I Choose Safe Retries for Authorization and Callback Steps</title>
      <dc:creator>WilhelmKnight8435</dc:creator>
      <pubDate>Sat, 29 Aug 2026 00:10:18 +0000</pubDate>
      <link>https://dev.to/wilhelmknight8435/oauth-failure-recovery-why-i-choose-safe-retries-for-authorization-and-callback-steps-2ip3</link>
      <guid>https://dev.to/wilhelmknight8435/oauth-failure-recovery-why-i-choose-safe-retries-for-authorization-and-callback-steps-2ip3</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; Retry the transport operation, never the OAuth meaning: keep one durable authorization attempt, accept its callback once, and make every downstream effect replayable from recorded state. For a B2B SaaS account-deletion flow, I would block new sessions before attempting remote cleanup, because deleting data while a surviving session can still act is the more dangerous ordering.&lt;/p&gt;

&lt;p&gt;That is the architecture decision. It treats a timeout as missing knowledge, not proof of failure. A callback may have committed even when the browser received no response; a token exchange may have reached the other side even when the connection closed; an account deletion may be retried by a worker after its first lease expires. The recovery design must therefore answer a narrow question at each boundary: do we know the operation did not happen, do we know it happened, or is the result still unknown?&lt;/p&gt;

&lt;h2&gt;
  
  
  What must remain true during OAuth failure recovery?
&lt;/h2&gt;

&lt;p&gt;The first invariant is that an authorization attempt has one identity independent of any HTTP request. Store an opaque flow identifier, the expected callback state, the account or tenant context, a creation time, an expiry, and a small state machine such as &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;exchanging&lt;/code&gt;, &lt;code&gt;succeeded&lt;/code&gt;, or &lt;code&gt;failed&lt;/code&gt;. Don't let a browser refresh create a second logical attempt merely because it creates a second request.&lt;/p&gt;

&lt;p&gt;The second invariant is single consumption. An authorization code and its state belong to one attempt; the callback handler must atomically claim that attempt before triggering side effects. A duplicate callback should read the previously recorded outcome and return the same application-level destination. It must not provision the user again, issue another internal session, or append a second audit event that claims a second login. Exactly once is the goal, but HTTP cannot promise it by itself, so I use an exactly-once mindset at the business boundary: an atomic database transition establishes who owns the work, unique constraints reject duplicate effects, and an outbox carries committed work to asynchronous consumers. Delivery can still be at least once. Effects cannot. For auditability, record transitions rather than raw secrets. The useful trail says that flow &lt;code&gt;af_7d31&lt;/code&gt; moved from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;exchanging&lt;/code&gt;, that callback attempt &lt;code&gt;3&lt;/code&gt; observed a transport timeout, and that a later reconciliation read the stored outcome. Authorization codes, tokens, and session cookies do not belong in logs. This also keeps a GDPR deletion record useful after the personal fields it once referred to have been erased: retain the minimum compliance evidence your policy permits, under a pseudonymous operation identifier, and separate it from authentication material.&lt;/p&gt;

&lt;p&gt;One boundary deserves special treatment. Account deletion and session revocation are not a single remote call. In the local transaction, mark the account as deletion-pending, advance or invalidate its session generation, reject future token refreshes, and enqueue cleanup. Only then should workers revoke remote grants and erase dependent data. The exact retention period and legal basis vary by jurisdiction and contract; I'm not sure a universal duration exists, so counsel and the data inventory must settle that part rather than the retry loop.&lt;/p&gt;

&lt;p&gt;Stop access first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should OAuth failure recovery retry authorization and callback steps?
&lt;/h2&gt;

&lt;p&gt;Classify failures by evidence, not by the layer that printed an error. A connection timeout is ambiguous. A validation rejection is terminal for that attempt. A browser abandonment is neither success nor protocol failure; it is an expired attempt that can be closed without inventing an error. This distinction prevents the common mistake of putting the entire authorization flow inside a generic exponential-retry wrapper.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step or observation&lt;/th&gt;
&lt;th&gt;Safe action&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authorization redirect was not delivered to the browser&lt;/td&gt;
&lt;td&gt;Re-render a link for the same unexpired attempt, or explicitly start a new attempt after expiring it&lt;/td&gt;
&lt;td&gt;A redirect is user navigation, not a background command to repeat blindly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Callback arrives twice with the same state&lt;/td&gt;
&lt;td&gt;Return the stored outcome after an atomic claim&lt;/td&gt;
&lt;td&gt;Duplicate delivery must not duplicate provisioning, sessions, or audit effects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Callback validation fails&lt;/td&gt;
&lt;td&gt;End that attempt and require a fresh authorization start&lt;/td&gt;
&lt;td&gt;Retrying unchanged invalid input cannot establish trust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token exchange loses its response&lt;/td&gt;
&lt;td&gt;Mark the result unknown and reconcile through the attempt state; start fresh only after the attempt is terminal&lt;/td&gt;
&lt;td&gt;The absence of a response does not prove that the peer did no work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local commit succeeds but the response is lost&lt;/td&gt;
&lt;td&gt;Serve the committed result on retry&lt;/td&gt;
&lt;td&gt;The database, not the prior HTTP response, is the source of truth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account cleanup worker loses its lease&lt;/td&gt;
&lt;td&gt;Resume named cleanup stages and skip stages already recorded complete&lt;/td&gt;
&lt;td&gt;Recovery proceeds from durable checkpoints rather than repeating the whole deletion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Backoff still matters, but only after classification. Cap attempts, add jitter, and stop on a deadline tied to the authorization attempt's expiry. Those controls protect dependencies from a retry wave; they do not create idempotency. For an unknown token-exchange outcome, the safest generic recovery is to avoid guessing that the same protocol credential can be consumed again. Persist the ambiguity, prevent parallel exchanges, and move the user into a fresh flow when the original attempt can no longer produce a trustworthy local result.&lt;/p&gt;

&lt;p&gt;There is a useful asymmetry here — callback delivery can be replayed because the application controls its receiver, while authorization is an interaction with a person. A worker may retry a callback-side database transaction under a stable operation key. It should never manufacture repeated browser consent interactions in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where are the failure and compliance boundaries?
&lt;/h2&gt;

&lt;p&gt;I draw four boundaries in the decision record: browser to callback, callback to authorization peer, callback to local transaction, and local outbox to workers. Each gets its own timeout, ownership rule, durable status, and observable counter. Combining them into one &lt;code&gt;oauth_failed&lt;/code&gt; metric loses the evidence needed for recovery.&lt;/p&gt;

&lt;p&gt;The browser boundary is untrusted input. Validate the callback against the stored attempt before changing state, return generic authentication errors to the user, and keep diagnostic detail in protected telemetry. OWASP's authentication guidance emphasizes generic responses because response differences can disclose account information; the same restraint belongs in callback error pages. A support identifier can connect the generic page to an audit event without displaying a token or internal exception.&lt;/p&gt;

&lt;p&gt;The local transaction is the commit boundary. It should write the successful flow result, the internal session reference, the audit transition, and an outbox row together, or write none of them. If creating a session lives in a separate store, use a unique key derived from the authorization attempt and make session creation return the existing record for that key. A lock by itself isn't enough: process death releases locks, while a uniqueness rule survives it.&lt;/p&gt;

&lt;p&gt;Deletion crosses a different compliance boundary. The request should produce a durable deletion operation whose stages are visible to operators: access blocked, sessions revoked, remote grants addressed, dependent records erased, and permitted audit evidence minimized. Do not log a cheerful &lt;code&gt;deleted&lt;/code&gt; event after only the first stage. Reconciliation should compare the operation ledger with each owned data store and alert on an overdue stage. For payment or ledger records that cannot follow the same erasure schedule, isolate the legally retained record from active identity data and document the governing policy; an OAuth callback handler cannot decide that question.&lt;/p&gt;

&lt;p&gt;Operationally, I would alert on attempts stuck in &lt;code&gt;exchanging&lt;/code&gt;, repeated callbacks for distinct attempt identifiers, deletion operations past their deadline, and outbox age. Counts need tenant-safe dimensions and bounded cardinality. Logs need operation IDs. Traces need the same IDs, but no credentials. This is deliberately boring observability, which is what incident reconstruction needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the critical callback path look like in Go?
&lt;/h2&gt;

&lt;p&gt;The following sketch keeps protocol calls behind an interface and makes the database claim explicit. It is not tied to a provider. The important part is the shape: claim once, record ambiguity, and publish effects only through committed state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;callback&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"errors"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ErrInvalidCallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invalid callback"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ErrOutcomeUnknown&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"exchange outcome unknown"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Attempt&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;State&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;ExpiresAt&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
    &lt;span class="n"&gt;ResultURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;LoadByState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ClaimPending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attemptID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;MarkUnknown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attemptID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;CommitSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attemptID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Exchanger&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Exchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Store&lt;/span&gt;    &lt;span class="n"&gt;Store&lt;/span&gt;
    &lt;span class="n"&gt;Exchange&lt;/span&gt; &lt;span class="n"&gt;Exchanger&lt;/span&gt;
    &lt;span class="n"&gt;Now&lt;/span&gt;      &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadByState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrInvalidCallback&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"succeeded"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResultURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClaimPending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrOutcomeUnknown&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exchange&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// The store records uncertainty; policy decides whether a fresh flow is needed.&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarkUnknown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrOutcomeUnknown&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommitSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;committed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadByState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;committed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResultURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CommitSuccess&lt;/code&gt; is where the real rigor lives. Its transaction needs uniqueness on the attempt's success record and must insert the outbox event beside that record. If two handlers race, one commits and the other reads the same result. If the process stops after commit but before responding, the refreshed callback also reads that result. No special recovery endpoint is required.&lt;/p&gt;

&lt;p&gt;The example deliberately does not retry &lt;code&gt;Exchange&lt;/code&gt; in a loop. Some failures can be identified as definitely pre-send in a concrete transport, but a portable architecture should not infer that from a generic error. Your mileage may vary with a peer's documented idempotency contract; encode such knowledge in the adapter and test it, rather than allowing the callback handler to assume it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reject a stateless retry loop, and when is it suitable?
&lt;/h2&gt;

&lt;p&gt;The rejected option is a stateless callback that exchanges the code, provisions the account, creates a session, and returns a redirect on every invocation. It is attractive because it has little schema and works on the happy path. Its failure boundary is unacceptable for account deletion and migration: once the response is lost, the application cannot distinguish an uncommitted attempt from a committed one, and retrying may duplicate effects while refusing to retry may strand the user.&lt;/p&gt;

&lt;p&gt;The durable attempt ledger has costs. It adds a state machine, uniqueness constraints, expiry cleanup, reconciliation, and on-call runbooks. It is not suitable when the callback has no durable effect, the application carries no sensitive session state, and a failed attempt can be abandoned without harming a user; in that narrow case, a fresh authorization flow with short-lived in-memory correlation may be enough. A stateless loop is also reasonable in a disposable local prototype.&lt;/p&gt;

&lt;p&gt;For a B2B SaaS service that must delete an account and revoke every session, stick with the durable design. Migration off a managed provider makes this more important, not less: the application-owned operation key, audit vocabulary, and session-generation rule remain stable while adapters change. The final decision is conditional and plain: use safe replay around your own commit boundary, start a new authorization attempt after an ambiguous protocol outcome, and never declare deletion complete until reconciliation proves every owned stage reached its terminal state.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>oauth</category>
      <category>authentication</category>
      <category>backend</category>
      <category>security</category>
    </item>
  </channel>
</rss>
