The expensive bug is not a failed image request. It is treating every failure as temporary, retrying it from the browser, and then discovering you have created three jobs for one prompt. midjourney api error handling starts with a smaller claim: an image-generation request is an asynchronous job, and each failure class needs a specific owner and next action.
Midjourney does not expose an official public API. The patterns here apply to wrapper APIs and self-hosted platforms that interact with account sessions. Nextjourney is not affiliated with Midjourney or Discord, and those third-party workflows carry applicable Terms of Service risk.
Midjourney API error handling begins with a job state
A synchronous POST /generate call encourages the wrong behavior. The browser waits, a proxy hits its timeout, and the user presses Generate again. Instead, create a durable job, return 202 Accepted, and let a worker own the upstream request.
POST /v1/image-jobs
Idempotency-Key: 47d7f9b8-...
HTTP/1.1 202 Accepted
Content-Type: application/json
{"id":"job_8ab3","status":"queued"}
The job record should retain the idempotency key, attempt count, provider job id, last error class, and next retry time. That record gives you the answer when a client reconnects: return the original job rather than submitting the prompt again.
The basic state machine is enough for most products: queued → submitting → processing → completed or failed. Keep provider-specific details in the worker, not in the UI. The architectural shape is covered in the guide to building a Midjourney integration; the endpoint baseline is in the Midjourney REST API guide.
Classify the failure before you retry it
Retries are not a reliability feature by themselves. They are a way to turn a known temporary failure into delayed work. First classify what happened.
| Signal | Meaning | Correct response |
|---|---|---|
| 400 / rejected prompt | The request will not succeed unchanged. | Fail the job and show a safe, actionable reason. |
| 401 / 403 | Your application token is invalid, or an upstream account session is unavailable. | Refresh or repair credentials; do not loop the same request. |
| 429 | A concurrency or rate limit was reached. | Honor Retry-After, reduce pressure, then retry once scheduled. |
| 408 / gateway timeout | The result is unknown, not necessarily absent. | Reconcile by job id before any new submission. |
| 502 / 503 / 504 | A transient gateway or upstream outage may exist. | Back off with a bounded retry budget. |
The timeout row is the one teams get wrong. If the provider accepted the job just before your connection broke, resubmitting can buy two images and two charges. Ask for the provider job status first. If the API lacks idempotency or a status lookup, that is a product constraint you should understand before putting it behind customer traffic.
Rate limits need a scheduler, not a while loop
A 429 is the service saying that its current capacity is lower than your current demand. Retrying immediately merely proves it again. Use the response's Retry-After value where available, and apply per-account concurrency limits in the worker pool.
async function scheduleRateLimitedRetry(job, response) {
const seconds = Number(response.headers.get("retry-after")) || 30;
const delayMs = Math.min(seconds * 1_000, 5 * 60_000);
await jobs.update(job.id, {
status: "queued",
errorClass: "rate_limited",
nextRunAt: new Date(Date.now() + delayMs)
});
await queue.add("generate", { jobId: job.id }, { delay: delayMs });
}
Do not make a browser poll every few hundred milliseconds while the job waits. The client can poll your status endpoint every few seconds, or listen for a completion event. The queue decides when upstream work resumes.
For multi-account setups, keep the cap attached to each eligible account, not just the global queue. The account rotation operations guide explains why routing is a capacity decision, while the API authentication guide covers the credential boundary that keeps those sessions out of customer-facing code.
Timeouts are ambiguous; reconcile before resubmitting
A network timeout means your client did not receive an answer. It does not prove that the provider did not receive the request. Build a reconciliation path around a stable idempotency key or client request id.
async function submitWithReconciliation(job) {
try {
return await provider.submit({
prompt: job.prompt,
idempotencyKey: job.idempotencyKey
});
} catch (error) {
if (!error.isTimeout) throw error;
const existing = await provider.findByClientKey(job.idempotencyKey);
if (existing) return existing;
throw new Error("submission_outcome_unknown");
}
}
If the outcome remains unknown, mark it as such and investigate it through the worker control plane. Quietly firing a second generation is not recovery; it is guessing with somebody else's budget.
Use exponential backoff with a hard stop
Transient 5xx responses may be worth retrying. The correct loop increases delay, adds jitter so many jobs do not return at once, and stops after a small fixed budget. Four attempts is a reasonable starting point, but it is a product decision—not a universal number.
function retryDelayMs(attempt) {
const base = Math.min(60_000, 1_000 * 2 ** attempt);
const jitter = Math.floor(Math.random() * 500);
return base + jitter;
}
async function retryOrFail(job, error) {
const retryable = [429, 500, 502, 503, 504].includes(error.status);
if (!retryable || job.attemptCount >= 4) {
return jobs.update(job.id, { status: "failed", errorClass: classify(error) });
}
const delay = retryDelayMs(job.attemptCount);
await jobs.update(job.id, { status: "queued", attemptCount: job.attemptCount + 1 });
return queue.add("generate", { jobId: job.id }, { delay });
}
Never retry a rejected prompt, a revoked application credential, or a malformed payload. Those failures need a change in input or operations. A worker that separates permanent errors from temporary ones is cheaper and easier to diagnose than a generic catch block.
Keep errors useful without leaking implementation details
Your customer-facing API should provide a stable error code and a retryable flag. Save raw upstream payloads only where operations staff can inspect them, after credential redaction.
{
"jobId": "job_8ab3",
"status": "failed",
"error": {
"code": "UPSTREAM_UNAVAILABLE",
"retryable": true,
"message": "The generation provider did not respond. Your job will retry automatically."
}
}
Avoid returning session details, provider cookies, raw authorization headers, or an upstream stack trace. That information does not help the end user and can become an incident. A self-hosted platform makes this control plane yours: source code for the backend, frontend, REST API, and admin dashboard, rather than a rented black box.
Ready to self-host?
Full source code — backend, frontend, REST API, admin dashboard. One-time license from $399.
See pricingFail deliberately, then recover deliberately
Good error handling makes image generation feel uneventful even when an upstream system is not. Store a job before sending it, retry only classified transient failures, reconcile ambiguous timeouts, and surface a final failure that a human can act on. That is the boring engineering that lets a generation API survive real traffic.