<?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: leojxu</title>
    <description>The latest articles on DEV Community by leojxu (@leojxu).</description>
    <link>https://dev.to/leojxu</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%2F4046018%2F1768ffce-462e-49de-8fdd-38aa1c578857.png</url>
      <title>DEV Community: leojxu</title>
      <link>https://dev.to/leojxu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leojxu"/>
    <language>en</language>
    <item>
      <title>Orchestrating Slow, Unreliable Async Jobs Across Multiple Providers</title>
      <dc:creator>leojxu</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:08:56 +0000</pubDate>
      <link>https://dev.to/leojxu/orchestrating-slow-unreliable-async-jobs-across-multiple-providers-41mf</link>
      <guid>https://dev.to/leojxu/orchestrating-slow-unreliable-async-jobs-across-multiple-providers-41mf</guid>
      <description>&lt;p&gt;Video generation broke every assumption my request-handling code was built on. A job takes anywhere from forty seconds to eleven minutes. It fails a small but non-trivial fraction of the time for reasons unrelated to your input. It costs real money per attempt, so a careless retry is a charge, not a hiccup. And you're doing this across several providers, each with its own idea of what a job status API looks like.&lt;/p&gt;

&lt;p&gt;This is not an HTTP problem. It's a distributed systems problem wearing an HTTP costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Own the job, not the request
&lt;/h2&gt;

&lt;p&gt;The first architectural decision that matters: your job ID is not the provider's job ID. &lt;code&gt;POST /generate&lt;/code&gt; returns &lt;code&gt;201 {"job_id": "job_7fc2a1", "status": "queued"}&lt;/code&gt;, and that ID is minted before you talk to any provider. That indirection buys you three things: retrying onto a &lt;em&gt;different&lt;/em&gt; provider without the client noticing, surviving a provider that loses its own job ID, and a stable identity for logs and billing.&lt;/p&gt;

&lt;p&gt;The state machine should be explicit and boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queued -&amp;gt; submitted -&amp;gt; running -&amp;gt; succeeded
                    -&amp;gt; failed(retryable) -&amp;gt; queued  (bounded)
                    -&amp;gt; failed(terminal)
                    -&amp;gt; expired  (no update past deadline)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expired&lt;/code&gt; earns its place. Providers drop jobs — not "return an error," just never update again. Without a deadline sweeper those rows sit in &lt;code&gt;running&lt;/code&gt; forever while users watch a bar that will never finish. Every job gets an absolute deadline at submit time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency: the expensive kind
&lt;/h2&gt;

&lt;p&gt;Standard idempotency keys prevent duplicate work. Here a duplicate submission is a duplicate &lt;em&gt;charge&lt;/em&gt; — a double-tap or a retried POST buys the same video twice. The key must derive from request content, not a client-generated UUID; a client retrying after a timeout may well generate a fresh one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;idem_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_sha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_sha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# image bytes, not its URL
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two subtleties that bit me. &lt;strong&gt;Hash the input image bytes, not the URL&lt;/strong&gt; — signed URLs carry expiring tokens, so the same image yields a different key every request. And &lt;strong&gt;deliberate re-rolls must not collide&lt;/strong&gt;: a second take of the same prompt is not a duplicate. Scope idempotency to a short window (say 60 seconds) so double-taps collapse but an intentional re-roll goes through. Getting this wrong means users cannot re-roll, which for a generation product is worse than the occasional duplicate.&lt;/p&gt;

&lt;p&gt;The claim-and-submit sequence must also be crash-safe. Claim the row (&lt;code&gt;queued&lt;/code&gt; → &lt;code&gt;submitting&lt;/code&gt;) in a transaction, submit outside it, then write back the provider reference. If the process dies in between you have a charged job with no reference — which is why you send the idempotency key &lt;em&gt;to the provider&lt;/em&gt; too, so recovery re-attaches rather than re-bills.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks are an optimization; polling is the guarantee
&lt;/h2&gt;

&lt;p&gt;Every provider offers webhooks. Trust none as your only path — they get lost, arrive out of order, arrive twice, and come from providers whose retry policy is "we tried once."&lt;/p&gt;

&lt;p&gt;The pattern that has held up: &lt;strong&gt;poll as the baseline, treat webhooks as a latency optimization.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;next_poll_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_at&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;    &lt;span class="c1"&gt;# most fast jobs land here
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;                     &lt;span class="c1"&gt;# long tail, don't hammer
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A webhook simply short-circuits the wait — it marks the job dirty and triggers an immediate poll rather than being trusted on its own. That means &lt;strong&gt;the webhook handler never writes terminal state from its payload&lt;/strong&gt;; it says "something changed, go look." This one rule eliminates forgery concerns, out-of-order delivery, and double-crediting at a stroke, because the status endpoint is always the authority. Keep the handler fast: verify signature, mark dirty, return 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries when attempts cost money
&lt;/h2&gt;

&lt;p&gt;Not all failures are equal, and the classification determines whether you may spend again:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Retry?&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;429 / rate limited&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;same provider, backoff + jitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5xx / timeout on submit&lt;/td&gt;
&lt;td&gt;yes, guarded&lt;/td&gt;
&lt;td&gt;same provider, with idem key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;failed&lt;/code&gt;, no reason given&lt;/td&gt;
&lt;td&gt;yes, once&lt;/td&gt;
&lt;td&gt;prefer a different provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content policy rejection&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;terminal, surface to user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malformed params / 400&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;terminal, it's your bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No update past deadline&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;different provider&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Content policy rejection being terminal matters: retrying burns money and never succeeds, and cross-provider retry of policy-rejected content is exactly how accounts get suspended.&lt;/p&gt;

&lt;p&gt;Cross-provider retry is also where credit accounting gets sharp. If one backend fails and you fall back to another at a different unit cost, the user was still quoted one price. My rule: &lt;strong&gt;quote and hold credits at submit; settle on success&lt;/strong&gt;, with failed attempts releasing the hold. The cost variance is yours to absorb — that's the entire value proposition of a unified credit balance, and the accounting model behind &lt;a href="https://hyper-frames.com" rel="noopener noreferrer"&gt;hyper-frames&lt;/a&gt;. If you pass backend cost variance through to users, you have not abstracted anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest progress with dishonest ETAs
&lt;/h2&gt;

&lt;p&gt;Providers report progress badly. Some give a percentage that sits at 5% then jumps to 100%. Some give nothing. Some give an ETA off by 4x.&lt;/p&gt;

&lt;p&gt;Do not display a provider percentage directly — users read a stalled 30% as "broken" and hit cancel-and-retry, which costs you a generation. What works better:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Phase labels over percentages.&lt;/strong&gt; "Queued → Generating → Encoding → Ready" is four honest states. Nobody feels lied to by a phase name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own historical p50/p90&lt;/strong&gt; for this model and duration, not the provider's ETA. Show a range: "usually 2-4 min."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let the bar go backwards&lt;/strong&gt; or stall at 99%. Drive it from elapsed time against your own p50, approaching ~90% asymptotically and jumping only on real completion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say when it's slow.&lt;/strong&gt; Past p90, replace the estimate with "taking longer than usual — still running." That kills most of the "is it broken?" support load.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The principle underneath all of this: the provider is an untrusted, slow, occasionally amnesiac collaborator. Your database is the source of truth about what the user asked for and what they were charged. Everything the provider tells you is a hint to be verified — including, especially, its claims about success.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>api</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Routing Between Video Models Without Leaking the Abstraction</title>
      <dc:creator>leojxu</dc:creator>
      <pubDate>Fri, 24 Jul 2026 19:02:37 +0000</pubDate>
      <link>https://dev.to/leojxu/routing-between-video-models-without-leaking-the-abstraction-55h4</link>
      <guid>https://dev.to/leojxu/routing-between-video-models-without-leaking-the-abstraction-55h4</guid>
      <description>&lt;p&gt;If you build on a single video generation model, you will rewrite your integration when it gets deprecated. If you build on several, you spend your time writing adapters. Here is what I learned choosing the second option.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parameters do not line up
&lt;/h2&gt;

&lt;p&gt;Every provider has a different idea of the same concept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duration&lt;/strong&gt;: some take seconds, some take frame counts, some only accept a fixed enum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aspect ratio&lt;/strong&gt;: &lt;code&gt;"16:9"&lt;/code&gt; vs &lt;code&gt;{width, height}&lt;/code&gt; vs a named preset&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seeds&lt;/strong&gt;: supported, ignored, or silently non-deterministic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image conditioning&lt;/strong&gt;: URL, base64, or a pre-uploaded asset ID&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A lowest-common-denominator interface is tempting and wrong — you lose the capabilities you are paying for. The alternative that held up: a &lt;strong&gt;common core plus a typed passthrough&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VideoRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;        &lt;span class="c1"&gt;// normalised, adapter converts&lt;/span&gt;
  &lt;span class="na"&gt;aspect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;16:9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;9:16&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1:1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;ImageInput&lt;/span&gt;
  &lt;span class="nx"&gt;providerOptions&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="c1"&gt;// escape hatch, typed per adapter&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core covers what every provider supports. The escape hatch means using a provider-specific feature does not require redesigning the interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async is the actual hard part
&lt;/h2&gt;

&lt;p&gt;Video generation takes minutes, so every provider is asynchronous — and each has invented its own async:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Polling with a job ID&lt;/li&gt;
&lt;li&gt;Webhook callbacks&lt;/li&gt;
&lt;li&gt;Server-sent events&lt;/li&gt;
&lt;li&gt;A queue position field that sometimes goes &lt;em&gt;backwards&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Normalising this matters more than normalising parameters. What worked: adapters expose a single &lt;code&gt;poll(jobId): Promise&amp;lt;JobState&amp;gt;&lt;/code&gt; and internally hide whatever the provider does. &lt;code&gt;JobState&lt;/code&gt; is a small discriminated union — &lt;code&gt;queued | running | done | failed&lt;/code&gt; — with a normalised &lt;code&gt;progress&lt;/code&gt; when available and &lt;code&gt;undefined&lt;/code&gt; when not.&lt;/p&gt;

&lt;p&gt;Do not fabricate progress. A spinner that lies is worse than a spinner that says "still working."&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes deserve normalising too
&lt;/h2&gt;

&lt;p&gt;Providers fail differently: content policy rejections, transient 5xx, quota exhaustion, silent truncation. Mapping these into a common taxonomy is what makes retry logic possible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;retryable&lt;/code&gt; — backoff and retry&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;policy&lt;/code&gt; — do not retry, surface to user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quota&lt;/code&gt; — do not retry, switch provider or fail loudly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting this wrong means retrying a policy rejection 5 times and burning credits on a request that will never succeed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credits across providers
&lt;/h2&gt;

&lt;p&gt;If you expose one balance over several backends with different pricing, you need a normalisation table and you need to be honest that it is an approximation. I run this in production at &lt;a href="https://hyper-frames.com" rel="noopener noreferrer"&gt;HyperFrames&lt;/a&gt; — text or image in, routed across Kling, Veo, Wan and others, one balance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveat
&lt;/h2&gt;

&lt;p&gt;This abstraction is worth it above roughly three providers. Below that, adapters cost more than they save — just write the integrations directly and move on.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
