AI Image API Request Deduplication: Prevent Duplicate Jobs

Oct 30, 2026·7 min read

A customer clicks Generate twice, your frontend retries a slow request, and two workers start producing the same product image. AI image API request deduplication should stop those duplicate application requests before either worker spends money. The difficult case is not the double-click: it is a network timeout after a generation service may already have accepted the request.

The practical approach is to persist a tenant-scoped generation intent, admit one submission attempt, and reconcile uncertain outcomes instead of automatically sending another generation request. This article describes an application-side design for developers integrating the GenRelay image API. It does not claim that GenRelay provides a native idempotency header or an exactly-once generation guarantee.

What should an image generation deduplication key identify?

A deduplication key should identify one customer action, not every request that happens to contain the same prompt. Deduplication means recognizing repeated deliveries of that action and returning its existing record rather than starting another operation.

An intent is a durable record of a requested operation. Create its identifier when the user deliberately asks for a generation, then reuse that identifier across browser retries and queue deliveries. A user choosing Generate Again should receive a new intent identifier, even when the prompt has not changed.

Scope identifiers by tenant, meaning the customer or organization that owns the operation. Derive that tenant from authenticated server-side identity, not a request field that clients can freely change. Otherwise, one customer might accidentally retrieve another customer's result or prevent their request from running.

Store a request fingerprint alongside the identifier. A fingerprint is a deterministic digest of the request's relevant contents. If an identifier is reused with a different model, prompt, reference asset, or resolution, reject the conflict rather than silently returning an unrelated image.

Which request fields belong in the fingerprint?

Include every application field that changes the intended generation, and normalize the representation before hashing it. Sort JSON keys, retain meaningful prompt whitespace, and record defaults explicitly so that deployment changes do not quietly change interpretation.

For reference images, prefer an immutable asset identifier and content checksum over an expiring download URL. Two signed URLs can identify the same underlying file; conversely, a mutable URL can point to different content tomorrow. Include preprocessing and prompt-template versions when those affect what is actually submitted.

Do not put credentials in the fingerprint input or ordinary logs. Hashing a secret does not make storing unnecessary authentication material a good practice. Keep the exact submission payload in access-controlled storage if it is needed for reconciliation, with a retention policy appropriate to the content.

The fingerprint protects against accidental identifier reuse. It should not become a global content cache unless your product explicitly defines that behavior and applies tenant isolation, access checks, and deletion rules.

How can SQLite admit one local generation intent?

Use a database uniqueness constraint and a transaction to choose one submission owner. A uniqueness constraint makes the database reject a second record with the same tenant and intent pair, even when requests arrive concurrently.

The following standard-library Python example runs locally and performs no paid generation. Its field names are application fields, not a documented GenRelay request schema. Save it as dedup.py.

import hashlib
import json
import sqlite3


def connect(path):
    db = sqlite3.connect(path, timeout=10, isolation_level=None)
    db.execute('''CREATE TABLE IF NOT EXISTS intents (
        tenant TEXT NOT NULL,
        intent TEXT NOT NULL,
        fingerprint TEXT NOT NULL,
        state TEXT NOT NULL,
        provider_job_id TEXT,
        PRIMARY KEY (tenant, intent)
    )''')
    return db


def admit(db, tenant, intent, payload):
    raw = json.dumps(payload, sort_keys=True, separators=(',', ':'))
    digest = hashlib.sha256(raw.encode()).hexdigest()
    db.execute('BEGIN IMMEDIATE')
    try:
        row = db.execute(
            'SELECT fingerprint, state FROM intents '
            'WHERE tenant=? AND intent=?', (tenant, intent)
        ).fetchone()
        if row:
            if row[0] != digest:
                raise ValueError('Intent already has different inputs')
            db.commit()
            return False, row[1]
        db.execute(
            'INSERT INTO intents VALUES (?, ?, ?, ?, NULL)',
            (tenant, intent, digest, 'submission_pending')
        )
        db.commit()
        return True, 'submission_pending'
    except Exception:
        db.rollback()
        raise

The returned Boolean identifies whether this caller created the intent. An existing record must not be treated as permission to submit again, regardless of its age. The transaction finishes before network work begins; holding a database lock while waiting for generation would block unrelated requests.

This is a small admission primitive, not a distributed job system. A production service still needs durable queue delivery, worker ownership, monitoring, and crash recovery. Multiple application hosts need a shared authoritative database rather than independent local SQLite files.

How do you test retries and intentional variations?

Test repeated delivery, mismatched inputs, tenant isolation, and deliberate new generations as separate cases. The following snippet imports the previous file and verifies the local behavior without making network calls.

from dedup import connect, admit

with connect(':memory:') as db:
    payload = {'model_label': 'Nano Banana 2',
               'resolution_label': '1K', 'prompt': 'A blue ceramic cup'}
    assert admit(db, 'tenant-a', 'action-1', payload)[0] is True
    assert admit(db, 'tenant-a', 'action-1', payload)[0] is False
    assert admit(db, 'tenant-a', 'action-2', payload)[0] is True
    assert admit(db, 'tenant-b', 'action-1', payload)[0] is True
    changed = dict(payload, prompt='A green ceramic cup')
    try:
        admit(db, 'tenant-a', 'action-1', changed)
    except ValueError:
        pass
    else:
        raise AssertionError('Changed inputs must be rejected')
print('All admission tests passed')

Also test simultaneous requests against the same database in your deployment environment. Unit tests demonstrate logical behavior; they do not establish the behavior of your queue, filesystem, deployment topology, or database failover configuration.

What happens if the generation request times out?

Treat an uncertain submission as unresolved, not as a confirmed failure. A timeout means your client stopped waiting; it does not establish whether the remote service accepted or completed the operation.

Persist a submission-attempt record before network dispatch. If a response returns a documented job identifier, store it immediately and use the documented retrieval mechanism. If the response is lost, retain the attempt timestamp, request fingerprint, and available correlation information for reconciliation.

A crash between remote acceptance and local persistence is a distributed-systems boundary that the uniqueness constraint cannot close. Even a durable queue cannot independently guarantee exactly-once external execution. Native idempotency can help when a service explicitly documents it, but do not invent a header and assume it works.

For an unknown outcome, stop automated resubmission, expose a clear pending status, and use available usage records or support-assisted investigation. If the customer authorizes another attempt, create a new linked intent and explain the possibility of an additional charge. See the image API error-handling guide for the broader distinction between request validation and transient transport errors.

How much can duplicate image requests cost?

Duplicate cost equals the number of additional billable images multiplied by the applicable unit price. As of October 30, 2026, the supplied GenRelay image prices are:

Model Resolution tier Price per image 100 additional billable images
Nano Banana Pro 1K or 2K $0.030 $3.00
Nano Banana Pro 4K $0.042 $4.20
Nano Banana 2 1K $0.020 $2.00
Nano Banana 2 4K $0.036 $3.60
GPT-image-2 Quoted per-image rate $0.014 $1.40

These are arithmetic examples, not measured duplicate rates or promises about failed-request billing. Reconcile actual charges with your usage records and confirm applicable settings on the pricing page.

Track admitted intents, suppressed duplicate deliveries, conflicting identifier reuse, unresolved submissions, and recorded charges separately. A duplicate delivery prevented before dispatch is different from an image generated successfully but rejected by your customer. Mixing those events hides the actual source of waste.

What questions come up during implementation?

The main questions concern ownership, retention, and the gap between local state and external execution.

Is a prompt hash enough for deduplication?

No. Identical prompts can represent intentional variations. Use a customer-action identifier for identity and a fingerprint to detect conflicting reuse of that identifier.

Can I delete an intent immediately after completion?

Not if delayed retries can still arrive. Keep a lightweight record for your defined retry window, apply privacy-conscious retention, and decide explicitly how expired identifiers behave.

Does a database lock guarantee one billable generation?

No. It coordinates local admission. It cannot prove what happened after a network request was accepted remotely but its response was lost.

Should the frontend hide the Generate button?

Disabling the button improves the experience but is not a correctness mechanism. Network retries, multiple tabs, and duplicated queue messages still require server-side admission checks.

What should the customer see during reconciliation?

Show that the request is being checked and preserve its intent identifier. Avoid presenting an uncertain submission as a completed failure with an automatic retry button that silently creates another charge.

Related posts

Join our DiscordAI Image API Request Deduplication: Prevent Duplicate Jobs — GenRelay