Dowser.Client
View Sourcedowser_client is the low-level HTTP/JSON transport shared by the Dowser
family of search-engine clients. It knows how to talk to a search backend
over HTTP, encode and decode request/response bodies, retry transient
failures, and normalize errors — but it knows nothing about any particular
search engine's API (queries, mappings, indices, etc.).
That backend-specific knowledge lives in dedicated packages built on top of
this one, such as
dowser_elasticsearch,
which implements one database's search API using dowser_client as its
transport. Similar packages for other NoSQL search databases are planned but not
yet ready for use.
Install dowser_elasticsearch (or whichever backend package you need), not
dowser_client directly. dowser_client is a dependency those packages
pull in for you; it isn't meant to be used stand-alone in application code.
Why it exists
Every search backend needs the same plumbing: build a URL, attach auth
headers, serialize a body to JSON (or newline-delimited JSON for bulk APIs),
send it over HTTP, retry on transient failures, and decode the response.
Rather than duplicate that plumbing in every backend-specific package, it
lives once in dowser_client, and each backend package only has to
implement the parts that are actually specific to it — the API surface.
It needs Elixir 1.18+ (for the built-in JSON module) and OTP 26+ — TLS
verification reads the OS trust store through :public_key.cacerts_get/0, which
exists from OTP 25, but 26 is the oldest release CI covers.
dowser_client has no dependencies. HTTP is OTP's own :httpc — every
search backend it targets speaks HTTP/1.1, which is exactly what :httpc does —
sent through its own :dowser_client profile (connection pool, keep-alive
settings, cookie store) rather than :httpc's shared :default one, with a
separate profile per cluster when you want one. JSON is Elixir's built-in JSON
module — no Jason, no Poison, nothing to pick.
How it works
Contexts
A Dowser.Client.Context is one search backend to talk to: its :endpoint,
:auth, the :httpc profile to send through, and how to cast what it returns
and what you send it. The name follows elastic/cli's
notion of a context — a named cluster you address by name instead of
repeating its coordinates at every call site. Contexts are plain structs —
build one inline, or configure named ones at compile time:
config :dowser_client,
contexts: [
default: [endpoint: "http://localhost:9200", auth: {:basic, "user", "changeme"}],
logs: [endpoint: "https://logs.internal:9200", profile: :logs]
]Any function that accepts a :context option resolves nil to the :default
entry above (there is no other built-in fallback — a :default entry must be
configured, or :context must be passed explicitly on every call), an atom to
a named entry, and a map/keyword list to an ad-hoc inline context.
:auth accepts, each applied as an authorization header:
{:basic, "dXNlcjpwYXNz"} # Basic <value>, already encoded
{:basic, "user", "changeme"} # Basic <base64("user:changeme")>
{:api_key, "VnVhQ2ZHY0..."} # ApiKey <value>
{:api_key, "id", "api_key"} # ApiKey <base64("id:api_key")>
{:bearer, "token"} # Bearer <token>
{:header, "x-custom-auth", "..."} # any header you likeRequests
Dowser.Client.request/4 (and its get/post/put/patch/delete shortcuts) is
the entry point:
Dowser.Client.get("/my-index/_search", context: :logs, params: %{size: 10})
Dowser.Client.post("/my-index/_doc", %{title: "hello"})Given a method, path, body and options, it resolves everything about the
request — merged headers, joined URL and query string, the casting to apply,
and the encoded body — into a Dowser.Client.Request struct, hands it to
Dowser.Client.HTTP, retries it if needed, and decodes the response body. The
result is always {:ok, %Dowser.Client.Response{}} or {:error, exception};
Dowser.unwrap/1 turns that into the plain response or a raised exception when
you'd rather not pattern-match on every call.
:params takes a map or keyword list; a list value is comma-joined
(params: [_source: ["a", "b"]] becomes ?_source=a%2Cb), which is how these
APIs spell a multi-value parameter.
A %Dowser.Client.Response{} carries the :status, the :headers as a map of
downcased string names (duplicates comma-joined), and the decoded :body. Any
completed exchange is {:ok, response} whatever its status — a 404 is a
response, not an error — so only transport, encoding and casting failures come
back as {:error, exception}.
Request/response bodies have a :format — :json (default), :ndjson for
bulk-style newline-delimited payloads, or :raw to pass bytes through
untouched. :req_format/:resp_format set each direction independently.
A :json/:ndjson response body is always JSON-decoded into plain,
string-keyed terms, and that is where dowser_client stops. Casting a value
beyond JSON — "2026-09-19" into ~D[2026-09-19] — means knowing where
documents sit in the envelope, which index each came from, and what that index'
mapping says. All of that is one particular search engine's API, which
dowser_client knows nothing about. So the second pass is a black box the
backend package supplies:
:keys—:strings(default, no casting),:atoms,:atoms!, or any(String.t() -> term)function.:atoms!usesString.to_existing_atom/1, so an unknown key surfaces as an error instead of silently growing the atom table.:decoder— the decoder and the options it needs:MyDecoder # module exporting decode/2 {MyDecoder, mapping: mapping} # ... with its options fun # (body, opts -> term) {fun, mapping: mapping} # ... with its optionsIt is called with the decoded body and those options plus
:key_fnand:context, and owns the result: it walks its own envelope, resolves its own mappings, casts its own fields, and applies:key_fnitself.
Without a :decoder, :keys is applied on its own. With neither, there is no
second pass at all. Both resolve per request, then from the context, then from
config :dowser_client, ...; a request setting :decoder replaces the
context's outright, options included.
Dowser.Client.get("/articles/_search",
keys: :atoms,
decoder: Dowser.Elasticsearch.Decoder
)
#=> {:ok, %Dowser.Client.Response{body: %{hits: %{hits: [
#=> %{_index: "articles", _id: "1", _source: %{
#=> title: "hello",
#=> published_at: ~D[2026-09-19],
#=> run_window: %{"gte" => ~D[2026-09-01], "lte" => ~D[2026-09-30]}
#=> }}
#=> ]}}}}A raising decoder comes back as
{:error, %Dowser.Client.Error{reason: {:decode_failed, exception}}} rather than
crashing the caller.
The request direction encodes document sources only — never a query. A
source's fields are described by the mapping of the index it is going to, so it
can be encoded exactly; a query has no such anchor, because the same value means
different things in a range clause, a script parameter, an aggregation boundary
or a suggester. So build queries in the shape the backend expects, and tell
dowser_client where the source is when there is one:
:encoder— the same four shapes as:decoderabove, withencode/2as the module callback and(source, opts -> term)as the function, resolved the same way. Its options are whatever the encoder needs — the target index, a mapping — plus:context, which is always added.:encode— where the source sits in this request's body:false(default),true(the body is the source), a path (["doc"]), or a list of paths ([["doc"], ["upsert"]]). Per request only — the call site is the only place that knows.
# the body is the source
Dowser.Client.put("/articles/_doc/1", document,
encoder: {Dowser.Elasticsearch.Encoder, index: "articles"},
encode: true
)
# a partial update: the source is under "doc"
Dowser.Client.post("/articles/_update/1", %{"doc" => partial},
encoder: {Dowser.Elasticsearch.Encoder, index: "articles"},
encode: ["doc"]
)
# a search: nothing to encode, so :encode is simply not passed
Dowser.Client.post("/articles/_search", query)An encoder whose options never vary — one that resolves the index from the
document itself, say — belongs on the context, leaving call sites to pass only
:encode.
A path is a list of Access keys, and a path that isn't present is skipped
rather than created.
An :ndjson payload is cast a line at a time, in both directions — the unit
of a bulk or multi-search body is the line, not the list. So the encoder sees one
entry per call and can tell an action line from a document, and a path applies
within each entry:
# the encoder passes actions through and casts documents
def encode(%{"index" => _action} = line, _opts), do: line
def encode(document, opts), do: cast(document, opts[:index])
# ... or let a path do it: every line with a "doc" is encoded, the rest skipped
Dowser.Client.post("/_bulk", entries, format: :ndjson, encode: ["doc"])A response decoded as :ndjson reaches the decoder the same way, entry by
entry.
Date, Time, NaiveDateTime and DateTime need no encoder at all — Elixir's
JSON already writes them as ISO 8601. An encoder is for shapes JSON has no
opinion about (Date.Range, Decimal) or a format the mapping dictates. The
pass is skipped for a nil or :raw body, and a raising encoder comes back as
{:error, %Dowser.Client.Error{reason: {:encode_failed, exception}}}.
What a decoder and encoder look like
dowser_client never inspects a value, so both hooks are ordinary modules in the
backend package. A decoder walks its own envelope — it is the only place that
knows a hit carries its index, and therefore which mapping to cast it against:
defmodule Dowser.Elasticsearch.Decoder do
def decode(body, opts), do: do_decode(body, Keyword.fetch!(opts, :key_fn))
# A hit carries its own index, so the mapping to cast it against can be
# resolved right here.
defp do_decode(%{"_index" => index, "_source" => _source} = hit, key_fn) do
Map.new(hit, fn
{"_source" = key, source} -> {key_fn.(key), decode_source(source, index, key_fn)}
{key, value} -> {key_fn.(key), do_decode(value, key_fn)}
end)
end
defp do_decode(value, key_fn) when is_non_struct_map(value) do
Map.new(value, fn {key, value} -> {key_fn.(key), do_decode(value, key_fn)} end)
end
defp do_decode(value, key_fn) when is_list(value), do: Enum.map(value, &do_decode(&1, key_fn))
defp do_decode(value, _key_fn), do: value
defp decode_source(source, index, key_fn) do
{:ok, %{"properties" => properties}} = MappingCache.fetch(index)
Enum.reduce(properties, %{}, fn {field, options}, acc ->
case Map.fetch(source, field) do
{:ok, value} -> Map.put(acc, key_fn.(field), load(value, options))
:error -> acc
end
end)
end
# Per-field casting, however that backend prefers to organise it.
defp load(value, %{"type" => "date"}) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> date
{:error, _reason} -> value
end
end
defp load(value, _options), do: value
endAn encoder is the mirror: one function over a document source, given whatever it needs through its options.
defmodule Dowser.Elasticsearch.Encoder do
def encode(source, opts) do
{:ok, %{"properties" => properties}} = MappingCache.fetch(opts[:index])
Map.new(source, fn {field, value} -> {field, dump(value, properties[field])} end)
end
defp dump(%Date{} = date, %{"type" => "date"}), do: Date.to_iso8601(date)
defp dump(value, _options), do: value
enddowser_client used to ship a Dowser.Client.Field behaviour and a
Codec.Builder macro for that per-field layer. They are gone: nothing in the
pipeline ever called them, and how a backend organises its own casting — a
cast/2 dispatcher, a case on "type", or a protocol — is its business.
Dowser.CoreExt.Keyable.transform_keys/2 remains for a decoder that only needs
the key half.
Transport
Dowser.Client.HTTP is the whole transport: given a method, absolute URL,
headers and an already-encoded body, it performs the request with :httpc and
returns a normalized Dowser.Client.Response or an error. It never touches
encoding/decoding, and there is nothing to choose or install — :inets and
:ssl ship with OTP (they're already in this package's
extra_applications).
Every request is HTTP/1.1 with keep-alive, sent through a named :httpc
profile — see HTTP configuration below for profiles,
timeouts and TLS.
:httpc's request tuple has no body slot for GET, but search backends do
expect one (Elasticsearch's GET /_search), so a GET carrying a body is sent
as a POST on the same path — which every such backend accepts — and logged at
debug level. A body on HEAD/TRACE, where it is meaningless, returns
{:error, {:unsupported_body, method}} rather than being dropped silently.
JSON
Dowser.Client.JSON wraps Elixir's built-in JSON module: encode/1 to
iodata, decode/1 to string-keyed terms, both returning a
Dowser.Client.JSON.Error rather than raising.
Dowser.Client.NDJSON does the same line by line for bulk-style payloads.
There is no adapter to choose and no optional dependency to install.
Errors
Errors are always Dowser exceptions, never a dependency's own exception type, so callers only ever need to handle one set of shapes:
Dowser.Client.Error— context resolution, an invalid:auth/:format/:keys/:decoder/:encoder/:encode/:retry, or a:keys,:decoderor:encoderfunction that raised while casting a body.Dowser.Client.JSON.Error— body encoding or decoding failure.Dowser.Client.HTTP.Error— transport failure, after retries are exhausted. Carries:httpc's own reason —:timeout,:socket_closed_remotely, or a{:failed_connect, _}tuple, which is where a refused connection and a TLS{:tls_alert, _}both show up — plus the method, URL, profile and attempt count.
Testing
Dowser.Client.HTTP.Stub.stub/1 intercepts every request made by the calling
process, so the rest of the pipeline (URL building, headers, retries, JSON
encoding/decoding) runs exactly as it would against a real backend — no server
required, and nothing to point at the stub.
It replaces the transport and only the transport: the context is still resolved,
so a test needs one — either the configured :default or an inline one, as
here.
test "indexes a document" do
Dowser.Client.HTTP.Stub.stub(fn :put, url, _headers, body, _opts ->
assert url == "http://localhost:9200/my-index/_doc/1"
assert IO.iodata_to_binary(body) == ~s({"title":"hello"})
Dowser.Client.HTTP.Stub.json(201, %{"_id" => "1", "result" => "created"})
end)
assert {:ok, %{status: 201, body: %{"result" => "created"}}} =
Dowser.Client.put("/my-index/_doc/1", %{title: "hello"},
context: [endpoint: "http://localhost:9200"]
)
endThe stub lives in the calling process, so async: true tests don't interfere
with each other, and an unstubbed process still talks to the network; see the
module docs for scripting multiple endpoints and for testing from a process
other than the one that called stub/1.
HTTP configuration
Requests go over :httpc, pinned to HTTP/1.1 with keep-alive, with
connect/receive timeouts, connection pooling and retry behavior tuned for
talking to a search database cluster. Everything below can be set globally
(config :dowser_client, ...), per context, or per request — most specific
wins, with :http_opts, :ssl and :profile_opts merging per key across the
three tiers rather than replacing each other wholesale.
Profiles
A :httpc profile is an isolated manager with its own connection pool,
session settings and cookie store. dowser_client sends through its own
:dowser_client profile rather than :httpc's shared :default one, so its
settings and its keep-alive sessions are never shared with the rest of the
application. Give each cluster its own profile when they should not share a
pool:
config :dowser_client,
contexts: [
default: [endpoint: "https://search.internal:9200", profile: :search],
logs: [endpoint: "https://logs.internal:9200", profile: :logs]
]Profiles are started on demand, the first time a request needs one, and
configured once with :httpc.set_options/2 from :profile_opts. The defaults:
| Setting | Default | Why |
|---|---|---|
max_sessions | 20 | concurrent keep-alive connections per host/port (:httpc's own default is 2) |
max_keep_alive_length | 100 | requests queued on one session |
keep_alive_timeout | 120_000 | how long an idle session is kept |
cookies | :disabled | a search API has no use for a cookie store |
Anything :httpc.set_options/2 accepts can be set — :pipeline_timeout,
:max_pipeline_length, :proxy, :https_proxy, :ipfamily, :socket_opts,
:unix_socket, ...:
config :dowser_client, profile_opts: [max_sessions: 50, pipeline_timeout: 5_000]Dowser.Client.HTTP.Profile.info/1 reports :httpc's own view of a profile —
its open sessions, queued requests and current options — or
{:error, {:not_started, name}} before the first request has started it, since
profiles are created on demand.
Headers
http_opts: [headers: ...] takes a map or a list of {name, value} pairs at any
tier, merged by name (case-insensitively). Weakest to strongest:
- the
content-type/acceptderived from the request/response format, config :dowser_client, http_opts: [headers: ...],- the context's
:auth, as anauthorizationheader, - the context's own
http_opts[:headers], - the request's
http_opts[:headers].
So a request can override anything — including the derived content-type — and a
context header can override the authorization its own :auth produced.
Dowser.Client.post("/_search", query, http_opts: [headers: %{"x-opaque-id" => "trace-42"}])Timeouts
| Setting | Default | Override via |
|---|---|---|
| connect | 2s | http_opts: [connect_timeout: ...] |
| receive | 30s | http_opts: [timeout: ...] |
Dowser.Client.request(:get, "/_search", nil, http_opts: [timeout: 5_000]):autoredirect, :proxy_auth, :relaxed, :full_result, :headers_as_is,
:socket_opts and :ipv6_host_with_brackets are passed to :httpc too, and
http_opts: [http_options: [...], options: [...]] are escape hatches merged
last into :httpc's own two option lists. An unrecognized key is an error
({:unknown_http_opts, keys}) rather than a silently ignored setting.
TLS
An https:// endpoint is verified by default: the chain against the OS trust
store (:public_key.cacerts_get/0), the certificate identity against the URL
host, SNI sent, TLS 1.2+. Nothing to configure for a normal cluster.
http_opts: [ssl: [...]] adjusts it. Keys not listed below are passed to
:ssl verbatim, and override the computed value:
# localhost cluster with a self-signed certificate: no verification at all
config :dowser_client,
contexts: [dev: [endpoint: "https://localhost:9200", http_opts: [ssl: [insecure: true]]]]
# SSH tunnel or port-forward: real CA, but the hostname will never match
http_opts: [ssl: [verify_hostname: false]]
# a private CA
http_opts: [ssl: [cacertfile: "/etc/ssl/certs/internal-ca.pem"]]:verify—true(default) orfalse;:verify_peer/:verify_nonealso accepted.:insecure—trueis shorthand forverify: false.:verify_hostname—falsekeeps full chain verification but accepts any name in the certificate.:cacerts/:cacertfile— an explicit trust store, replacing the OS one.:sni—:auto(default; disabled for an IP-address host, which cannot legally be sent as an SNI value),:disable, or a name.:versions— defaults to[:"tlsv1.2", :"tlsv1.3"];:depthdefaults to3.
See Dowser.Client.HTTP.SSL for the full contract.
Retries
Requests retry automatically on transient failures, using exponential backoff with full jitter — but only when the retry is safe. What that means depends on how the request failed:
- It never reached the server (refused, unresolvable, unreachable) or
the server rejected it (
429,503): nothing was applied, so the request is always retried. - Ambiguous (a timeout, a connection dropped in flight,
502/504): the request may have been applied, with no answer to say so, so it is retried only when it is idempotent.
:idempotent defaults to the method — GET, HEAD, PUT, DELETE,
OPTIONS and TRACE yes, POST and PATCH no — which is what stops a
timed-out POST /_bulk from indexing everything twice. A caller who knows
better overrides it:
# A search is a POST, but it writes nothing
Dowser.Client.request(:post, "/_search", query, retry: [idempotent: true])
# Override any key
Dowser.Client.request(:post, "/_bulk", docs, retry: [max_attempts: 5])
# Disable retries for this request
Dowser.Client.request(:get, "/_search", nil, retry: false)A Retry-After response header (seconds or HTTP date) is obeyed in place of
the computed backoff, up to :max_retry_after_ms; :max_elapsed_ms bounds the
whole request in wall-clock time, however many attempts are left.
Default policy: max_attempts: 3, max_elapsed_ms: nil, base_delay_ms: 200,
max_delay_ms: 2_000, retryable_statuses: [429, 503],
ambiguous_statuses: [502, 504], respect_retry_after: true,
max_retry_after_ms: 60_000.
Installation
Don't add dowser_client to your application directly. Add the
backend-specific package instead — currently
dowser_elasticsearch:
def deps do
[
{:dowser_elasticsearch, "~> 0.1.0"}
]
enddowser_elasticsearch depends on dowser_client itself, so it's pulled in
automatically. Other backend packages (Meilisearch, Typesense, and others)
will follow the same pattern once they're ready.