Tesla middleware for request deduplication - Prevents concurrent identical requests from causing duplicate side effects (double charges, duplicate orders, race conditions).
Request deduplication is a concurrency coordination pattern that prevents multiple identical HTTP requests from executing simultaneously. When duplicate requests arrive, only the first executes - the rest wait and receive the same response.
Deduplication is orthogonal to caching:
- Deduplication: Prevents concurrent duplicates during request execution (milliseconds to seconds)
- Caching: Stores responses for reuse over time (minutes to hours)
# Deduplication: Only concurrent requests are shared
# Timeline: [Request 1 starts] -> [Duplicate waits] -> [Both get response] -> [Done]
# |------------------500ms-------------------|
# Caching: Responses stored for future use
# Timeline: [Request 1] -> [Response cached for 5min] -> [Later request uses cache]- Payment Processing: Prevent double charges from double-clicks or retry storms
- Order Creation: Ensure idempotency for POST/PUT operations
- Critical Mutations: Prevent duplicate side effects in distributed systems
- API Rate Limiting: Reduce duplicate requests that would count against rate limits
Add tesla_dedup to your mix.exs dependencies:
def deps do
[
{:tesla, "~> 1.4"},
{:tesla_dedup, "~> 0.1.0"}
]
enddefmodule MyClient do
use Tesla
# Add deduplication middleware
plug Tesla.Middleware.Dedup
# Other middleware
plug Tesla.Middleware.JSON
plug Tesla.Adapter.Hackney
end
# If these requests happen concurrently, only one executes
# The second request waits and receives the same response
Task.async(fn -> MyClient.post("/charge", %{amount: 100}) end)
Task.async(fn -> MyClient.post("/charge", %{amount: 100}) end)By default, deduplication uses method + URL + body. Customize this:
defmodule PaymentClient do
use Tesla
# Deduplicate by URL only (ignore body differences)
plug Tesla.Middleware.Dedup,
key_fn: fn env -> env.url end
plug Tesla.Adapter.Hackney
end┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Request 1 │ │ Request 2 │ │ Request 3 │
│ (identical)│ │ (identical)│ │ (identical)│
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────┼────────────────┘
▼
┌────────────────────────┐
│ Tesla.Middleware.Dedup │
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ Hash: method+url+body │
└───────────┬────────────┘
│
┌──────────────┼──────────────┐
│ │ │
[Execute] [Wait for] [Wait for]
│ Response Response
▼ │ │
┌─────────┐ │ │
│ Adapter │ │ │
└────┬────┘ │ │
│ │ │
[Response]──────────┴──────────────┘
│
└──────────> All get same response
:execute- First request executes normally:wait- Duplicate requests wait for response (blocking in middleware):completed- Brief window (500ms) to catch race conditions
| Option | Type | Default | Description |
|---|---|---|---|
:key_fn |
(Tesla.Env -> any()) |
nil |
Custom function to generate deduplication key |
# Include headers in deduplication key
plug Tesla.Middleware.Dedup,
key_fn: fn env ->
{env.method, env.url, env.body, env.headers}
end
# Use custom business logic
plug Tesla.Middleware.Dedup,
key_fn: fn env ->
# Extract idempotency key from headers
idempotency_key = get_header(env, "idempotency-key")
{env.url, idempotency_key}
endThe middleware emits telemetry events:
[:tesla_dedup, :execute]- First request, will execute[:tesla_dedup, :wait]- Duplicate request, waiting[:tesla_dedup, :cache_hit]- Request completed recently
:telemetry.attach_many(
"dedup-handler",
[
[:tesla_dedup, :execute],
[:tesla_dedup, :wait],
[:tesla_dedup, :cache_hit]
],
fn event, _measurements, metadata, _config ->
Logger.info("Dedup: #{inspect(event)}, key: #{metadata.dedup_key}")
end,
nil
)Place Tesla.Middleware.Dedup early in your middleware stack:
defmodule MyClient do
use Tesla
# ✅ GOOD: Dedup first
plug Tesla.Middleware.Dedup
plug Tesla.Middleware.RateLimit
plug Tesla.Middleware.CircuitBreaker
plug Tesla.Middleware.JSON
plug Tesla.Adapter.Hackney
endUse Tesla.Mock for testing:
defmodule MyClientTest do
use ExUnit.Case
test "prevents duplicate charges" do
Tesla.Mock.mock(fn env ->
Process.sleep(100)
{:ok, %{env | status: 201, body: %{charge_id: "ch_123"}}}
end)
tasks = [
Task.async(fn -> MyClient.charge(100) end),
Task.async(fn -> MyClient.charge(100) end)
]
results = Task.await_many(tasks)
# Both get same successful response
assert Enum.all?(results, fn {:ok, env} ->
env.status == 201 && env.body.charge_id == "ch_123"
end)
end
endFull documentation available at https://hexdocs.pm/tesla_dedup
MIT License - see LICENSE for details.
Inspired by the deduplication middleware in HTTPower.