Profiles·Public

inngest

semver>=3.0.0 <5.0.0postconditions12functions11last verified2026-06-24coverage score100%

Postconditions: what we check

  • send · send-no-try-catch
    error
    Wheninngest.send() called in an async context without a surrounding try/catch block. Network failures, authentication errors (invalid/missing INNGEST_EVENT_KEY), rate limit (429), and Inngest API errors all throw unhandled exceptions.
    ThrowsNetwork errors: ECONNREFUSED, ETIMEDOUT, fetch failures when Inngest API unreachable. Auth errors: thrown when eventKey / INNGEST_EVENT_KEY is missing or invalid. API errors: thrown on 4xx/5xx responses from the Inngest API.
    Required handlingCaller MUST wrap inngest.send() in try/catch (or equivalent .catch()). Minimum handling: try { await inngest.send({ name: "app/event.name", data: { ... } }); } catch (error) { // Log the failure — event not sent, functions will not trigger logger.error("Failed to send Inngest event", { error }); } For critical workflows (payment confirmation, user provisioning), consider adding retry logic or a queue to ensure event delivery under transient failures. Note: serve() from inngest/next, inngest/hono, etc. does NOT need try/catch — it is a factory function, not a network call.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1][2][3]
  • step.waitForEvent · wait-for-event-null-not-checked
    error
    Whenstep.waitForEvent() result is used without a null check before accessing properties. When the timeout elapses, the function returns null instead of an event payload. Accessing event.data, event.name, or any property on the null result throws a TypeError at runtime.
    ThrowsTypeError: Cannot read properties of null (reading 'data') — thrown when the waitForEvent result is accessed without checking for null first. This is a silent failure mode because the timeout is a normal operational condition (event simply wasn't received in time).
    Required handlingCallers MUST null-check the result before accessing any properties: const event = await step.waitForEvent("wait-for-approval", { event: "app/approval.submitted", timeout: "1h", }); if (!event) { // Timeout — handle the "no response" case await step.run("handle-timeout", async () => { await sendTimeoutNotification(); }); return { status: "timed_out" }; } // Safe to access event.data here const approved = event.data.approved;
    costmediumin prodimmediate exceptionusers seedegraded performancevisibilitysilent
    Sources[4][5]
  • step.invoke · invoke-no-try-catch
    warning
    Whenstep.invoke() called without a surrounding try/catch block inside an Inngest function handler. Invocation failures throw NonRetriableError — if uncaught, this terminates the entire calling function and marks it as failed. The docs explicitly recommend wrapping step.invoke() in try/catch to handle rate limiting and other failure scenarios.
    ThrowsNonRetriableError — thrown in all failure scenarios to prevent compounding retries in function chains. Specific cases: - Function ID not found - Invoked function exhausts all retries - Timeout duration exceeded (invoked function continues running) - Invoked function is rate-limited (skipped) - Invoked function is debounced (skipped after timeout)
    Required handlingCallers should wrap step.invoke() in try/catch when failure is recoverable: try { const result = await step.invoke("invoke-processing-fn", { function: processingFunction, data: { id: payload.id }, timeout: "30 mins", }); return { success: true, result }; } catch (error) { // NonRetriableError — the invoked function failed or was skipped await step.run("handle-invocation-failure", async () => { await notifyFailure(payload.id, error.message); }); return { success: false }; } Note: step.invoke throws NonRetriableError specifically to prevent exponential retry multiplication across nested function chains.
    costmediumin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[6][7]
  • step.run · step-run-step-error-uncaught
    warning
    Whenstep.run() is called in a function handler without awareness that after all retries are exhausted a StepError is thrown. Without try/catch around step.run(), step failures that exhaust retries mark the entire function as permanently failed with no opportunity for rollback or graceful recovery. This is particularly impactful for multi-step workflows where earlier steps have already committed side effects.
    ThrowsStepError (available in TypeScript SDK v3.12.0+) — thrown after a step exhausts all configured retries. Extends Error with step-specific context. If StepError propagates uncaught, the Inngest function is marked as failed and the onFailure handler fires (if configured).
    Required handlingFor multi-step workflows with side effects, wrap critical steps in try/catch to enable rollback or fallback behavior: try { await step.run("charge-payment", async () => { await stripe.charges.create({ amount: 1000 }); }); } catch (err) { // StepError: payment step exhausted all retries await step.run("rollback-order", async () => { await cancelOrder(orderId); }); return { status: "payment_failed" }; } Alternatively, use .catch() chaining for inline rollbacks: await step.run("create-record", async () => { ... }) .catch((err) => step.run("rollback-record", async () => { ... })); Note: for simple single-step functions, uncaught StepError is acceptable if the onFailure handler is configured to handle all terminal failures.
    costmediumin prodsilent failureusers seelost datavisibilityvisible
    Sources[8][7][9]
  • step.sendEvent · step-send-event-not-awaited
    warning
    Whenstep.sendEvent() is called without await or a Promise handler inside an Inngest function handler. The documentation explicitly states this MUST be awaited. Without await, the function execution continues before the event is confirmed sent, breaking the function's execution flow and potentially causing the event to never be delivered.
    ThrowsSilent failure: the event send may be incomplete or never executed if the function completes before the Promise resolves. In retry scenarios, the non-awaited call may cause duplicate or missed event delivery since memoization only works correctly with awaited calls.
    Required handlingAlways await step.sendEvent(): await step.sendEvent("emit-fan-out-events", [ { name: "app/user.welcome-email", data: { userId } }, { name: "app/user.setup-workspace", data: { userId } }, ]); Use inside function handlers for fan-out patterns. Do NOT use inngest.send() inside function handlers — it is not memoized and will re-send on retries.
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[10][5]
  • step.sleep · step-sleep-not-awaited
    warning
    Whenstep.sleep() is called without await (or .then/.catch) inside an Inngest function handler. The documentation explicitly states this must be awaited. Without await, the function continues executing immediately rather than pausing, breaking the durable-pause contract. Subsequent steps that depend on the elapsed delay (rate-limited API calls, scheduled follow-ups, debounced batches) fire too early.
    ThrowsSilent failure: no exception is raised, but the pause does not occur. The function execution proceeds immediately past the step.sleep() call. On retry, memoization will not match the expected step shape, which may cause non-deterministic execution.
    Required handlingAlways await step.sleep(): await step.sleep("wait-before-follow-up", "1h"); For dynamic delays, the time argument can be a number (ms), a ms-compatible string ("30 mins", "2.5d"), or a Temporal.Duration. Use step.sleepUntil() for absolute date targets instead.
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[11][5]
  • step.sleepUntil · step-sleep-until-invalid-date
    error
    Whenstep.sleepUntil() is called with a time argument that cannot be parsed as a Date, ISO 8601 string, Temporal.Instant, or Temporal.ZonedDateTime. The SDK throws synchronously inside step.sleepUntil() with the message "Invalid Date, date string, Temporal.Instant, or Temporal.ZonedDateTime passed to sleepUntil: <time>". Because this is a programmer-error throw (not a runtime step failure), it bypasses the retry machinery and crashes the function run immediately.
    ThrowsError("Invalid Date, date string, Temporal.Instant, or Temporal.ZonedDateTime passed to sleepUntil: <time>") — thrown synchronously from the SDK. Common triggers: passing a string that does not parse as ISO 8601, passing undefined/null when user data was expected, passing a Number-of-ms (which is sleep's signature, not sleepUntil's).
    Required handlingValidate the time argument before calling step.sleepUntil(), OR wrap step.sleepUntil() in try/catch to handle the synchronous throw: const target = parseUserDate(userInput); if (!target || isNaN(target.getTime())) { return { status: "invalid_target_date" }; } await step.sleepUntil("wait-until-target", target); When validating dynamic dates from user input, prefer Date construction with explicit validation over implicit string parsing.
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12][13]
  • step.sleepUntil · step-sleep-until-not-awaited
    warning
    Whenstep.sleepUntil() is called without await (or .then/.catch). The documentation explicitly states this must be awaited. Without await the function does not pause, and the rest of the handler executes immediately — defeating the purpose of the scheduled wait and causing non-deterministic behavior on retry due to a memoization mismatch.
    ThrowsSilent failure: the function does not pause at the scheduled time. The handler continues executing as if step.sleepUntil() resolved instantly. Memoization shape differs from awaited form, so retries may produce inconsistent results.
    Required handlingAlways await step.sleepUntil(): await step.sleepUntil("wait-until-trial-end", trialEndDate);
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[12]
  • step.fetch · step-fetch-network-error-no-try-catch
    error
    Whenstep.fetch() called in an async context without a surrounding try/catch block. Network-layer failures (DNS resolution failure, ECONNREFUSED, TLS handshake error, AbortController abort, malformed URL) throw a TypeError. Without try/catch, this terminates the enclosing step.run() with a StepError after retries exhaust, or bubbles uncaught when used outside a step.run() wrapper.
    ThrowsTypeError — thrown by the underlying fetch implementation on: - DNS resolution failure ("getaddrinfo ENOTFOUND") - Connection refused ("ECONNREFUSED") - Connection reset ("ECONNRESET") - TLS errors ("self signed certificate", "unable to verify the first certificate") - AbortController abort ("The operation was aborted") - Malformed URL ("Invalid URL") HTTP 4xx/5xx do NOT throw — they resolve with response.ok=false. Callers MUST also check response.ok to handle HTTP-level errors.
    Required handlingWrap step.fetch() in try/catch AND check response.ok: try { const res = await step.fetch("call-external-api", "https://api.example.com/data"); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${await res.text()}`); } return await res.json(); } catch (err) { // TypeError (network) or thrown HTTP error from above logger.error("step.fetch failed", { err }); throw err; // propagate to trigger step retry } When step.fetch is called inside step.run(), the step retry policy handles transient failures — but the inner try/catch + response.ok check is still required to distinguish recoverable vs terminal errors.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[14][15]
  • step.ai.infer · ai-infer-provider-error-no-try-catch
    error
    Whenstep.ai.infer() called without a surrounding try/catch. Provider errors (rate limit, auth, model not found, token limit, provider 5xx) all reject the returned Promise. After step retries exhaust, the uncaught error becomes a StepError and marks the Inngest function as failed — for agentic workflows, this kills the entire chain.
    ThrowsProvider-specific errors propagated through Inngest: - AuthenticationError / 401 — invalid or missing API key for the provider - RateLimitError / 429 — provider rate limit exceeded (especially during burst inference or token-heavy batches) - InvalidRequestError / 400 — unsupported model, malformed prompt, input exceeds context window, missing required params - APIError / 5xx — provider downtime, gateway timeout, transient failure Inngest's step retry policy will retry retryable errors automatically; a final StepError surfaces after retries exhaust.
    Required handlingWrap step.ai.infer() in try/catch and decide per-error-class whether to fall back to a different model, queue for later, or mark the step permanently failed: try { const result = await step.ai.infer("classify-message", { model: openai({ model: "gpt-4o-mini" }), body: { messages: [{ role: "user", content: input }] }, }); return result.choices[0].message.content; } catch (err) { // Provider error after Inngest retries exhausted if (isRateLimitError(err)) { throw new RetryAfterError("AI rate limit", "5 mins"); } if (isAuthError(err)) { throw new NonRetriableError("AI auth failed — check API key"); } // Fallback model / human review await step.run("queue-for-human-review", () => enqueue(input)); return null; } For agentic workflows that compose multiple step.ai.infer() calls, each call needs its own error policy — failures should not silently truncate the chain.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
  • step.realtime.publish · step-realtime-publish-no-try-catch
    warning
    Whenstep.realtime.publish() called without a surrounding try/catch. The SDK throws synchronously inside the step on either schema validation failure (when the channel topic declares a Standard Schema validator) or a non-ok response from the Inngest realtime API. After step retries exhaust, the uncaught error becomes a StepError and marks the enclosing function as failed.
    ThrowsError("Schema validation failed for topic <topic>") — thrown when the published data fails the topic's Standard Schema validator. This is a permanent failure that retries cannot resolve. Error("Failed to publish to realtime: <reason>") — thrown when the Inngest realtime API returns a non-ok response. Transient network / API issues are auto-retried by Inngest's step retry policy.
    Required handlingWrap step.realtime.publish() in try/catch and distinguish schema errors (permanent — fix the publisher) from transport errors (transient — Inngest retries handle it): try { await step.realtime.publish( "emit-progress", channel.progress, { percent: 50, message: "Halfway done" } ); } catch (err) { if (err.message.includes("Schema validation failed")) { // Programmer error — log and proceed, do not retry logger.error("Realtime payload schema violation", { err }); return; } throw err; // transport — let step retry handle it }
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[19][13]
  • realtime.publish · realtime-publish-no-try-catch
    warning
    Wheninngest.realtime.publish() called without a surrounding try/catch. Unlike the step-level variant, this is NOT retried — a single non-ok response from the realtime API causes a permanent throw. Schema validation failures (when the topic declares a Standard Schema validator) also throw permanently. Failures here often surface inside API route handlers, where an uncaught throw becomes an HTTP 500 to the calling client.
    ThrowsError("Schema validation failed for topic <topic>") — thrown when the published data fails the topic's Standard Schema validator. Error("Failed to publish to realtime: <reason>") — thrown when the Inngest realtime API returns a non-ok response (network failure, auth failure, rate limit, downtime). No automatic retry.
    Required handlingWrap inngest.realtime.publish() in try/catch — there is no step-level retry safety net here: try { await inngest.realtime.publish(channel.status, { percent: progress, message: "Working...", }); } catch (err) { // Progress update lost — log but do NOT fail the request. // Realtime is best-effort by design at the client level. logger.warn("Failed to publish realtime update", { err }); } For critical updates that MUST be delivered, use step.realtime.publish() inside an Inngest function handler instead — it benefits from step retry semantics.
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[19][20]

Sources

Every postcondition cites at least one of these. Grouped by source type; numbered to match the footnotes above.

Official documentation
Source code

Research notes

Curator notes from SOURCES.md captured when the profile was written so you can verify the reasoning, not just the rules.

Sources: inngest

Reference documentation for the inngest contract.

Official Documentation

URLDescription
https://www.inngest.com/docs/reference/typescript/events/sendinngest.send() reference — signature, return type, error behavior
https://www.inngest.com/docs/reference/typescript/client/createnew Inngest() constructor — id required, eventKey vs env var
https://www.inngest.com/docs/reference/functions/createinngest.createFunction() — config, trigger, handler params
https://www.inngest.com/docs/reference/functions/step-runstep.run() — memoized execution, retry behavior
https://www.inngest.com/docs/reference/functions/step-sleepstep.sleep() — must be awaited
https://www.inngest.com/docs/reference/functions/step-sleep-untilstep.sleepUntil() — must be awaited
https://www.inngest.com/docs/reference/functions/step-wait-for-eventstep.waitForEvent() — returns null on timeout
https://www.inngest.com/docs/reference/functions/step-send-eventstep.sendEvent() — use inside handlers instead of inngest.send()
https://www.inngest.com/docs/reference/functions/step-invokestep.invoke() — invoke other Inngest functions
https://www.inngest.com/docs/reference/typescript/functions/errorsError types: NonRetriableError, RetryAfterError, StepError
https://www.inngest.com/docs/reference/typescript/functions/handling-failuresonFailure handler — fires after all retries exhausted
https://www.inngest.com/docs/guides/error-handlingError vs failure distinction, retry semantics
https://www.inngest.com/docs/functions/retriesRetry configuration, NonRetriableError, RetryAfterError
https://www.inngest.com/docs/getting-started/nextjs-quick-startNext.js quickstart — canonical usage patterns
https://www.inngest.com/docs/learn/inngest-stepsAll step methods overview

Package

Real-World Evidence

These are confirmed real-world examples of the anti-patterns this contract detects. They establish that the postconditions identify genuine bugs in production code — not theoretical edge cases.

ProjectStarsPattern FoundFileClassification
documenso~9kawait this._client.send({...}) without try-catch inside triggerJob()packages/lib/jobs/client/inngest.tsTRUE_POSITIVE

Why this matters: documenso is a production document-signing SaaS used by thousands. The triggerJob() method calls inngest.send() without error handling, meaning any Inngest API outage or misconfigured event key silently propagates as an unhandled exception through the job dispatch layer.

Note on the official docs: The Inngest Next.js quickstart itself omits try-catch on send() — which is exactly why AI-generated code reproduces this anti-pattern. Our contract corrects this documentation gap.


Key Findings from Sources

  1. inngest.send() throws on failure — the SDK makes an HTTP request and throws on network/API errors. The official docs do NOT wrap send() in try-catch in their quickstart examples, which is why AI-generated code often omits this.

  2. step.waitForEvent() returns null on timeout — explicitly documented in the reference. The null case represents a timed-out wait (the event was never received). Accessing .data on a null result throws TypeError.

  3. step.sendEvent() inside handlers — the docs explicitly recommend using step.sendEvent() instead of inngest.send() when sending events from within an Inngest function, because step.sendEvent() is memoized (won't re-send on retries).

  4. serve() no error handling needed — confirmed in docs and examples. The serve() adapter (from inngest/next, inngest/hono, etc.) is a factory that returns route handlers. It does not make network calls.

Need a different package?
Request a profile