This guide shows you how to add HTTPower to your existing Tesla-based application without rewriting your code.
HTTPower provides circuit breaker, rate limiting, PCI-compliant logging, and smart retries on top of your Tesla setup. Your existing Tesla middleware continues to work - HTTPower wraps Tesla rather than replacing it.
Prerequisites
- Existing Elixir application using Tesla
- Tesla
~> 1.11or higher
Step 1: Add HTTPower Dependency
Update your mix.exs:
def deps do
[
{:tesla, "~> 1.11"}, # Your existing Tesla dependency
{:httpower, "~> 0.5.0"} # Add HTTPower
]
endRun mix deps.get.
Step 2: Keep Your Existing Tesla Client
Don't change your Tesla code! Your existing Tesla client works as-is:
defmodule MyApp.ApiClient do
use Tesla
# All your existing Tesla middleware still works
plug Tesla.Middleware.BaseURL, "https://api.example.com"
plug Tesla.Middleware.JSON
plug Tesla.Middleware.Headers, [{"user-agent", "MyApp/1.0"}]
plug Tesla.Middleware.Timeout, timeout: 30_000
plug Tesla.Middleware.Compression, format: "gzip"
# Add a function to expose the client
def client do
Tesla.client([]) # or Tesla.client(middleware()) if you build dynamically
end
# Your existing functions work unchanged
def get_users do
get("/users")
end
endThe only addition is the client/0 function to expose your Tesla client to HTTPower.
Step 3: Wrap with HTTPower (Gradual Migration)
Now create HTTPower wrappers for the endpoints you want to protect. You can do this gradually - one endpoint at a time:
defmodule MyApp.Users do
@doc "Fetch users with HTTPower reliability patterns"
def list_users do
client = HTTPower.new(
adapter: {HTTPower.Adapter.Tesla, MyApp.ApiClient.client()},
circuit_breaker: [
failure_threshold: 5,
timeout: 60_000
],
rate_limit: [
requests: 100,
per: :minute
]
)
HTTPower.get(client, "/users")
end
endYour Tesla middleware still runs - HTTPower calls Tesla, which executes all your plugs (BaseURL, Headers, Timeout, etc.).
Important: If your Tesla stack includes
Tesla.Middleware.JSON, remove it before using HTTPower. HTTPower now handles JSON encoding (via thejson:option) and response decoding (viaHTTPower.Codec) at its own layer. LeavingTesla.Middleware.JSONactive causes double-decoding: Tesla decodes the response body to a map, then HTTPower tries to decode it again and fails. See the JSON Encoding and Decoding section below.
Step 4: Configure Global Settings (Recommended)
Instead of passing options every time, configure globally in config/config.exs:
# config/config.exs
config :httpower,
# Circuit breaker configuration
circuit_breaker: [
enabled: true,
failure_threshold: 5,
window_size: 10,
timeout: 60_000,
half_open_requests: 1
],
# Rate limiting configuration
rate_limit: [
enabled: true,
requests: 100,
per: :minute,
strategy: :wait,
max_wait_time: 5000
],
# Logging configuration
logging: [
enabled: true,
level: :info,
sanitize_headers: ["authorization", "api-key"],
sanitize_body_fields: ["credit_card", "cvv", "password"]
]Now your code becomes simpler:
defmodule MyApp.Users do
def list_users do
client = HTTPower.new(
adapter: {HTTPower.Adapter.Tesla, MyApp.ApiClient.client()}
)
HTTPower.get(client, "/users")
end
endStep 5: Make Code Adapter-Agnostic (Recommended Final Step)
Encapsulate the adapter configuration so your application code is completely independent of Tesla. This allows you to swap adapters later without changing any code:
Before (adapter visible):
defmodule MyApp.ApiClient do
def list_users do
client = HTTPower.new(
adapter: {HTTPower.Adapter.Tesla, MyApp.TeslaClient.client()}, # Tesla-specific
base_url: "https://api.example.com"
)
HTTPower.get(client, "/users")
end
endAfter (adapter hidden):
defmodule MyApp.ApiClient do
def list_users do
HTTPower.get(client(), "/users")
end
def create_user(params) do
body = Jason.encode!(params)
HTTPower.post(client(), "/users",
body: body,
headers: %{"content-type" => "application/json"}
)
end
defp client do
HTTPower.new(
base_url: "https://api.example.com",
headers: %{"authorization" => "Bearer #{api_key()}"},
timeout: 30
)
# No adapter specified - uses default configuration
# Can be configured globally or per-environment
end
defp api_key, do: Application.fetch_env!(:myapp, :api_key)
endOption A: Configure adapter globally in config.exs:
# config/config.exs
config :httpower, adapter: {HTTPower.Adapter.Tesla, MyApp.TeslaClient.client()}
# Your client code - no adapter specified, uses global config
defp client do
HTTPower.new(
base_url: "https://api.example.com",
headers: %{"authorization" => "Bearer #{api_key()}"},
timeout: 30
)
endOption B: Configure adapter per-client:
# No global config needed
defp client do
HTTPower.new(
adapter: {HTTPower.Adapter.Tesla, MyApp.TeslaClient.client()},
base_url: "https://api.example.com",
headers: %{"authorization" => "Bearer #{api_key()}"},
timeout: 30
)
endWith Option A, you can switch from Tesla to Req by changing one line in config - no code changes needed.
Step 6: Update Tests to Use HTTPower.Test
Replace Tesla.Mock with HTTPower.Test for adapter-independent testing:
# In test_helper.exs
Application.put_env(:httpower, :test_mode, true)
# In your tests
test "fetches users" do
# Use HTTPower.Test, not Tesla.Mock
HTTPower.Test.stub(fn conn ->
Plug.Conn.resp(conn, 200, Jason.encode!(%{"users" => []}))
end)
assert {:ok, %{status: 200}} = MyApp.ApiClient.list_users()
end
test "creates user" do
HTTPower.Test.stub(fn conn ->
Plug.Conn.resp(conn, 201, Jason.encode!(%{"id" => 1}))
end)
assert {:ok, %{status: 201}} = MyApp.ApiClient.create_user(%{name: "John"})
endHTTPower.Test works with any adapter, so your tests remain valid even if you switch from Tesla to Req.
Step 7: Final Result
Your migration is complete! You now have:
# Clean, adapter-agnostic API client
defmodule MyApp.ApiClient do
def list_users do
HTTPower.get(client(), "/users")
end
def create_user(params) do
HTTPower.post(client(), "/users",
body: Jason.encode!(params),
headers: %{"content-type" => "application/json"}
)
end
defp client do
HTTPower.new(
base_url: "https://api.example.com",
headers: %{"authorization" => "Bearer #{api_key()}"}
# Circuit breaker, rate limiting configured globally in config.exs
# Adapter can be swapped without code changes
)
end
defp api_key, do: Application.fetch_env!(:myapp, :api_key)
endBenefits of this final state:
- No Tesla-specific code visible
- Can switch to Req adapter by changing config only
- Tests use HTTPower.Test (adapter-agnostic)
- Clean, maintainable API surface
- All Tesla middleware still works behind the scenes
JSON Encoding and Decoding
HTTPower handles JSON encoding and decoding independently of Tesla through HTTPower.Codec. This means you should remove Tesla.Middleware.JSON from your Tesla middleware stack when wrapping requests with HTTPower.
Why remove Tesla.Middleware.JSON?
When Tesla.Middleware.JSON is present in your stack and HTTPower also decodes the response, the response body is decoded twice:
- Tesla decodes the JSON response body into a map
- HTTPower's Codec sees a map (not a binary), fails to decode it, or returns unexpected results
How to migrate JSON handling
Before (Tesla handling JSON):
defmodule MyApp.TeslaClient do
use Tesla
plug Tesla.Middleware.BaseURL, "https://api.example.com"
plug Tesla.Middleware.JSON # <-- remove this
plug Tesla.Middleware.Headers, [{"user-agent", "MyApp/1.0"}]
endAfter (HTTPower handling JSON):
defmodule MyApp.TeslaClient do
use Tesla
plug Tesla.Middleware.BaseURL, "https://api.example.com"
# Tesla.Middleware.JSON removed
plug Tesla.Middleware.Headers, [{"user-agent", "MyApp/1.0"}]
end
# Use json: option to encode request bodies
HTTPower.post(client, "/users", json: %{name: "Alice"})
# Response bodies with a JSON Content-Type are decoded automatically
{:ok, response} = HTTPower.get(client, "/users")
# response.body is already a map when the server returns JSONOther Tesla middleware (BaseURL, Headers, Timeout, Compression, etc.) are unaffected and should remain in your stack.
Using the new encoding options
# JSON encoding — sets Content-Type: application/json and Accept: application/json
HTTPower.post(client, "/users", json: %{name: "Alice"})
# Form encoding — sets Content-Type: application/x-www-form-urlencoded
HTTPower.post(client, "/token", form: %{grant_type: "client_credentials"})
# Raw body — no encoding, set Content-Type manually
HTTPower.post(client, "/upload", body: binary_data, headers: %{"content-type" => "application/octet-stream"})
# Skip response decoding — get raw binary regardless of Content-Type
{:ok, response} = HTTPower.get(client, "/export", raw: true)Migration Patterns
Pattern 1: Wrapper Module (Recommended)
Keep Tesla client internal, expose HTTPower wrapper:
defmodule MyApp.StripeClient do
@tesla_client MyApp.TeslaClients.Stripe.client()
defp httpower_client do
HTTPower.new(
adapter: {HTTPower.Adapter.Tesla, @tesla_client},
circuit_breaker: [failure_threshold: 3],
rate_limit: [requests: 100, per: :second]
)
end
def create_charge(params) do
HTTPower.post(httpower_client(), "/v1/charges", body: params)
end
def get_customer(id) do
HTTPower.get(httpower_client(), "/v1/customers/#{id}")
end
endPattern 2: Drop-in Replacement
Replace Tesla calls directly:
# Before:
defmodule MyApp.ApiClient do
use Tesla
plug Tesla.Middleware.BaseURL, "https://api.example.com"
def fetch_data do
get("/data")
end
end
# After:
defmodule MyApp.ApiClient do
use Tesla
plug Tesla.Middleware.BaseURL, "https://api.example.com"
def client, do: Tesla.client([])
defp httpower, do: HTTPower.new(adapter: {HTTPower.Adapter.Tesla, client()})
def fetch_data do
# Changed from get("/data") to HTTPower.get
HTTPower.get(httpower(), "/data")
end
endPattern 3: Gradual Migration
Migrate one critical endpoint at a time:
defmodule MyApp.PaymentClient do
use Tesla
plug Tesla.Middleware.BaseURL, "https://api.stripe.com"
def client, do: Tesla.client([])
# Critical endpoint - migrated to HTTPower
def create_charge(params) do
client = HTTPower.new(adapter: {HTTPower.Adapter.Tesla, client()})
HTTPower.post(client, "/v1/charges", body: params)
end
# Non-critical endpoint - still using plain Tesla
def list_customers do
get("/v1/customers")
end
endCommon Scenarios
Scenario 1: Payment Processing (Stripe, PayPal, etc.)
config :httpower,
circuit_breaker: [
enabled: true,
failure_threshold: 3, # Open circuit after 3 failures
timeout: 30_000 # Try again after 30 seconds
],
rate_limit: [
enabled: true,
requests: 100,
per: :second,
strategy: :error # Return error instead of waiting
],
logging: [
enabled: true,
sanitize_body_fields: ["card_number", "cvv", "card_cvc"]
]Scenario 2: High-Volume API Integration
config :httpower,
circuit_breaker: [
enabled: true,
failure_threshold_percentage: 50, # Open at 50% failure rate
window_size: 100 # Track last 100 requests
],
rate_limit: [
enabled: true,
requests: 1000,
per: :minute,
strategy: :wait,
max_wait_time: 10_000
]Scenario 3: Microservice Communication
# Per-service configuration
defmodule MyApp.UserService do
def client do
HTTPower.new(
adapter: {HTTPower.Adapter.Tesla, MyApp.Tesla.UserService.client()},
circuit_breaker: [failure_threshold: 5, timeout: 60_000],
circuit_breaker_key: "user_service" # Isolate circuit per service
)
end
end
defmodule MyApp.OrderService do
def client do
HTTPower.new(
adapter: {HTTPower.Adapter.Tesla, MyApp.Tesla.OrderService.client()},
circuit_breaker: [failure_threshold: 3, timeout: 30_000],
circuit_breaker_key: "order_service"
)
end
endFAQ
Q: Do I need to rewrite my Tesla middleware?
No. Your Tesla middleware continues to work. HTTPower wraps Tesla, so all your plugs run normally.
Q: Can I use Tesla's built-in retry?
Not recommended. Disable Tesla.Middleware.Retry to avoid double-retrying. HTTPower's retry logic includes exponential backoff and jitter.
Q: What happens to Tesla.Middleware.Timeout?
Both work together. Tesla's timeout is the per-request timeout. HTTPower's timeout option does the same thing. If you set both, the lower value takes effect.
Q: Can I gradually migrate?
Yes. Migrate critical endpoints first (payments, auth), then gradually add others.
Q: Does this affect performance?
HTTPower adds ~1-5ms overhead for the reliability layer. The trade-off is worth it for production reliability (circuit breaker, rate limiting, retries).
Q: Can I see the circuit breaker state?
Yes:
HTTPower.Middleware.CircuitBreaker.get_state("my_api")
# Returns: :closed | :open | :half_open | nilTroubleshooting
Issue: Circuit breaker not opening
Symptom: Service is failing but circuit stays closed.
Solutions:
- Check if circuit breaker is enabled:
config :httpower, circuit_breaker: [enabled: true] - Verify failure threshold is being reached
- Check circuit key - different keys have different circuits
- Add logging to see what's happening:
require Logger Logger.info("Circuit state: #{inspect(HTTPower.Middleware.CircuitBreaker.get_state("my_key"))}")
Issue: Rate limiting too aggressive
Symptom: Getting :too_many_requests errors.
Solutions:
- Increase rate limit:
rate_limit: [requests: 200, per: :minute] - Use
:waitstrategy instead of:error - Use custom bucket keys to separate different endpoints
- Check if multiple instances are sharing the rate limiter
Issue: Response body is a map when expecting a decoded struct, or decoding errors
Symptom: JSON response body comes back as a raw string, or you get unexpected decoding errors.
Solution: Check for double-decoding caused by Tesla.Middleware.JSON still being in your Tesla stack. Remove it — HTTPower handles JSON decoding automatically:
# Remove Tesla.Middleware.JSON from your Tesla client
defmodule MyApp.TeslaClient do
use Tesla
plug Tesla.Middleware.BaseURL, "https://api.example.com"
# plug Tesla.Middleware.JSON <- remove this line
endIf you need to opt out of HTTPower's automatic decoding (e.g., for binary responses), use raw: true:
{:ok, response} = HTTPower.get(client, "/binary-file", raw: true)Issue: Tesla middleware not running
Symptom: Headers/transformations from Tesla plugs are missing.
Solutions:
- Make sure you're passing the Tesla client:
adapter: {HTTPower.Adapter.Tesla, MyApp.Client.client()} - Check that
client()function builds Tesla client with middleware - Verify Tesla middleware order - some plugs must come first
Next Steps
- Read Configuration Reference for all available options
- Read Production Deployment Guide for production setup
- Review runnable examples in
guides/examples/
Getting Help
If you encounter issues:
- Check this migration guide
- Review the configuration reference and production deployment guide
- Review examples in
guides/examples/ - Open an issue: https://github.com/mdepolli/httpower/issues