effectmq
How-to guides

Make handler side effects idempotent

Use stable attempt-independent keys when calling external systems.

This guide shows you how to prevent repeated handler execution from repeating an externally visible side effect.

Derive a key from the task generation

Use the queue name, task id, and generation as the downstream idempotency key. Do not include the attempt number: retries of the same generation must reuse the same key.

import { Task, TaskQueue } from "@effectmq/core"
import { Effect, Schema } from "effect"
import {
  HttpClient,
  HttpClientRequest,
  HttpClientResponse
} from "effect/unstable/http"

class ChargeRequestFailed extends Schema.TaggedError<ChargeRequestFailed>()(
  "ChargeRequestFailed",
  {
    chargeId: Schema.String,
    cause: Schema.Defect()
  }
) {}

const ChargeResponse = Schema.Struct({ chargeId: Schema.String })

const ChargeTask = Task.make({
  name: "charge-card",
  schemaId: "charge-card/v1",
  payload: { chargeId: Schema.String, amount: Schema.Number },
  success: Schema.String,
  error: ChargeRequestFailed,
  idempotencyKey: ({ chargeId }) => chargeId
})

const chargeHandler: TaskQueue.TaskHandler<
  typeof ChargeTask.payloadSchema,
  typeof ChargeTask.successSchema,
  typeof ChargeTask.errorSchema,
  HttpClient.HttpClient
> = Effect.fn("chargeHandler")(
  function* (task) {
    return yield* Effect.gen(function* () {
      const client = (yield* HttpClient.HttpClient).pipe(
        HttpClient.filterStatusOk
      )
      const request = yield* HttpClientRequest.post(
        "https://payments.example/charges"
      ).pipe(
        HttpClientRequest.setHeader(
          "idempotency-key",
          `charges:${task.id}:${task.generation}`
        ),
        HttpClientRequest.schemaBodyJson(ChargeTask.payloadSchema)(task.payload)
      )
      const response = yield* client.execute(request)
      const body = yield* HttpClientResponse.schemaBodyJson(ChargeResponse)(
        response
      )
      return body.chargeId
    }).pipe(
      Effect.mapError(
        (cause) =>
          new ChargeRequestFailed({ chargeId: task.payload.chargeId, cause })
      )
    )
  }
)

The handler requires HttpClient.HttpClient; provide FetchHttpClient.layer once in the application layer graph. Tests can provide a stub client without patching global fetch. The client rejects non-2xx responses in the typed error channel, and the schema decoder validates the provider response before the handler returns it.

The downstream service must durably associate that key with the first outcome. If it accepts the side effect and the EffectMQ acknowledgement is lost, the next handler attempt receives the same stored outcome instead of creating a second charge.

Apply the same rule to local writes

For a database write, put the generation key in a unique column and commit the business mutation and key record in one transaction. Treat a unique-key conflict as a replay and return the previously stored outcome.

Do not use an in-memory set, worker process id, lease token, or attempt counter. Those values either disappear on restart or change between executions.

The handler is safe when executing it twice for the same task generation produces one externally visible effect and the same typed result. See why duplicate execution remains possible.

On this page