A Rails controller should not know how the upstream rate API authenticates or how long its responses are cached. Put that decision in one service object and return a small, explicit result to the rest of the application.
Key points
Use Net::HTTP from the Ruby standard library; no SDK is required.
Store the API key in Rails credentials or an environment variable.
Cache by currency pair and amount policy, not by controller action.
Raise explicit errors for timeouts, rate limits, and unavailable data.
Create a small conversion service
ruby · app/services/exchange_rate_client.rbcopy
require "net/http"
require "json"
class ExchangeRateClient
Error = Class.new(StandardError)
def convert(from:, to:, amount:)
from = from.to_s.upcase
to = to.to_s.upcase
raise Error, "invalid currency code" unless [from, to].all? { |code| code.match?(/A[A-Z]{3}z/) }
key = ["fx-convert", from, to, amount.to_d.to_s("F")]
Rails.cache.fetch(key, expires_in: 60.seconds) do
uri = URI("https://api.exchangerate.dev/v1/convert/#{from}/#{to}/#{amount}")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("EXCHANGERATE_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
open_timeout: 2, read_timeout: 5) { |http| http.request(request) }
raise Error, "exchange-rate API returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
rescue Net::OpenTimeout, Net::ReadTimeout => error
raise Error, "exchange-rate API timed out: #{error.class}"
end
end
Keep the controller thin
ruby · conversions_controller.rbcopy
class ConversionsController < ApplicationController
def show
amount = BigDecimal(params.require(:amount))
result = ExchangeRateClient.new.convert(
from: params.require(:from).upcase,
to: params.require(:to).upcase,
amount: amount
)
render json: result.slice("from", "to", "amount", "rate", "converted",
"source", "market_session", "data_updated_at")
rescue ExchangeRateClient::Error, ArgumentError => error
render json: { error: error.message }, status: :bad_gateway
end
end
Store evidence when the conversion becomes a record
A transient display can use a cached result. An invoice, ledger entry, or report should store the original amount, both currency codes, full-precision rate, converted amount, source, and data_updated_at. That makes the number reproducible after the live rate changes.
Use background jobs for recurring refreshes
If many users need the same base rates, fetch once in a scheduled Active Job and serve the cached snapshot. Do not let every request to a product page trigger an upstream currency request.
Do not silently turn failure into zero
A timeout or unavailable rate is an error state. Keep a bounded last-good value with its original timestamp, or stop the operation and ask the caller to retry.