Changelog
View SourceAll notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.3.0] - 2026-09-25
Changed
A retry can no longer repeat a write the server already applied.
Dowser.Client.Retryretried every failure it called transient, for every method — including a timeout or a504, where the request may well have been applied and only the answer was lost. A timed-outPOST /_bulkor auto-idPOST /_docwas re-sent, and the documents written twice.Failures are now classified by what they say about the request:
Failure Retried Never reached the server ( :econnrefused,:nxdomain,:enetunreach,:ehostunreach,{:failed_connect, _})always Rejected by the server ( :retryable_statuses, now[429, 503])always Ambiguous ( :timeout,:etimedout,:econnreset,:closed,:socket_closed_remotely, and:ambiguous_statuses,[502, 504])only when idempotent :idempotentdefaults to the method (GET,HEAD,PUT,DELETE,OPTIONS,TRACE→true;POST,PATCH→false) and is overridable per request, which is how a read that happens to be aPOSTkeeps its retries:Dowser.Client.request(:post, "/_search", query, retry: [idempotent: true])502and504moved out of:retryable_statusesinto the new:ambiguous_statuses, so the default policy retries fewer things for a non-idempotent request and exactly as many for an idempotent one.
Added
Retry-Afteris obeyed. A429/503that names a delay — in seconds or as an HTTP date — is waited out as asked rather than on the computed backoff, up to:max_retry_after_ms(default60_000), past which the request gives up instead of sleeping. Turn it off withrespect_retry_after: false.:max_elapsed_ms, a wall-clock budget for the whole request (defaultnil, no budget). Three attempts two seconds apart is the wrong shape for a429burst; a budget bounds what the caller waits for, however many attempts the policy allows.Dowser.Client.Retry.ambiguous?/1, which says whether a failure leaves it unknown that the request was applied.Dowser.Client.Retry.resolve/2takes the request method, to derive:idempotent. The one-argument form still works and assumes a non-idempotent request.
[0.2.1] - 2026-09-20
Maintenance release. No API or behavior changes.
Fixed
- Dropped the stale
poisonentry left inmix.lockby the 0.2.0 dependency removal. It never reached users —mix.lockis not part of the published package — but it mademix deps.getfetch a dependency the project no longer declares.
[0.2.0] - 2026-09-20
dowser_client now has no dependencies at all, optional ones included, and
one code path per job instead of three.
Every search backend it targets speaks HTTP/1.1, which is exactly what OTP's
:httpc does, and Elixir ships a JSON module — so the pluggable HTTP and JSON
adapters bought customization nobody needed at the cost of code paths to keep
working. In their place: one :httpc transport with real connection management
and TLS, and one pair of hooks for the casting only a backend package can do.
This is a breaking release. Every removal below is a compile- or request-time error rather than a silent behavior change, and each entry names its replacement.
Added
Dowser.Client.HTTP— the transport,:httpconly. Flat:http_opts(:connect_timeout2s,:timeout30s,:ssl,:autoredirect, ...) instead of:httpc's nestedHTTPOptions/Optionslists, withhttp_opts: [http_options: [...], options: [...]]as escape hatches merged last. An unrecognized key is an error ({:unknown_http_opts, keys}) rather than a silently ignored setting.Dowser.Client.HTTP.Profile— requests go through a dedicated:httpcprofile (:dowser_clientby default) instead of:httpc's shared:defaultone, so connection pools, session settings and cookies are never shared with the rest of the application.:profileand:profile_opts(per request, per context, or global) name the profile and its:httpc.set_options/2settings —max_sessions,max_keep_alive_length,keep_alive_timeout,pipeline_timeout,cookies,proxy, ... Profiles start on demand and are configured once; the defaults are tuned for a search cluster (max_sessions: 20,max_keep_alive_length: 100,keep_alive_timeout: 120_000,cookies: :disabled).Profile.info/1reports:httpc's own view of a profile,Profile.reset/2re-applies options at runtime, andprofile: :defaultstill opts into:httpc's shared profile.Dowser.Client.HTTP.SSL— TLS for anhttps://endpoint, secure by default: chain verification against the OS trust store (:public_key.cacerts_get/0), certificate identity checked against the URL host, SNI, TLS 1.2+, depth 3.http_opts: [ssl: [...]]adjusts it —insecure: truefor alocalhostcluster with a self-signed certificate,verify_hostname: falsefor an SSH tunnel or port-forward where the hostname can never match,:cacerts/:cacertfilefor a private CA,:sni,:versions,:depth— and any other key reaches:sslverbatim. An IP-address host keeps its identity checked (against the certificate's IP SAN) even though SNI cannot carry one.Dowser.Client.JSON—encode/1toiodata,decode/1to string-keyed terms, both returning aDowser.Client.JSON.Errorrather than raising.Dowser.Client.NDJSON.encode/2anddecode/2take the casting options and apply them per entry, since a line is the unit of an ndjson payload: an encoder sees one entry at a time (so a bulk encoder can pass action lines through and cast only documents), an:encodepath applies within each entry, and a decoder is called with each decoded line. A body that isn't a list is{:error, %Dowser.Client.Error{reason: {:invalid_ndjson, body}}}rather than a raisedFunctionClauseError.Dowser.Client.Decoder— the optional second pass over a decoded response body, since casting one needs to know where documents sit in the envelope and which index each came from, which is knowledge only a backend package has::keys—:strings(default, no casting),:atoms,:atoms!, or any(String.t() -> term)function. Applied on its own, throughDowser.CoreExt.Keyable, when there is no:decoder.:decoder— a module exportingdecode/2, a(body, opts -> term)function, or either paired with its options ({MyDecoder, mapping: mapping}). It receives the decoded body plus those options,:key_fnand:context, and owns the result — envelope walking, mapping lookups, field casting and key casting included.
Both resolve per request, then from the context, then from
config :dowser_client, ...; the most specific wins as a whole, options included. With neither set there is no second pass and a body stays exactly as JSON decoded it.Dowser.Client.Encoder— the mirror pass over a document source in a request body. Only a source is ever encoded: its fields are described by the mapping of the index it is going to, while a query has no such anchor (the same value means different things in a range clause, a script parameter, an aggregation boundary, a suggester), so a query body is never touched.:encoder— same four shapes as:decoder, withencode/2as the module callback, and the same resolution across request, context and global.:encode— where the source sits in this request's body:false(default),true(the body is the source), a path (["doc"]for a partial update), or a list of paths ([["doc"], ["upsert"]]). Per request only, since the call site is the only place that knows. A path is a list ofAccesskeys (["items", Access.at(0)]reaches through a list-valued field), it applies within each entry of an ndjson body, and one pointing at nothing is skipped rather than created.
Two more
:authshapes on a context, so a credential can be given either already encoded or as a pair:{:basic, value}sendsBasic <value>and{:api_key, id, api_key}sendsApiKey <base64("id:api_key")>, alongside the existing{:basic, username, password},{:api_key, value},{:bearer, token}and{:header, name, value}.New
Dowser.Client.Errorreasons, so a misconfiguration is a result rather than a crash:{:invalid_auth, auth},{:invalid_keys, value}(whose message suggests the nearest valid value for a typo —:atom→ "did you mean :atoms?"),{:invalid_decoder, value},{:invalid_encoder, value},{:invalid_encode, value},{:decode_failed, exception},{:encode_failed, exception}and{:removed_option, :http_adapter}.Dowser.CoreExt.Keyableis documented rather than private: the key half of the response pass, for a decoder that casts a body's keys on its own. It also leaves non-binary keys alone instead of handing them to a key function that expects a binary.Dowser.Client.Retryalso classifies:socket_closed_remotely(:httpc's term for a keep-alive session the server closed under us),:econnresetand:etimedoutas transient.UPGRADE_GUIDE_0_2.md, a step-by-step path from 0.1.1, shipped with the package and published with the docs.The library says so when a request carries an option 0.1.x understood:
:configand:http_adapterare errors, since ignoring either could send the request somewhere the caller didn't ask for;:codec_adapter,:codec_opts,:json_adapterand:json_optslog a warning naming their replacement and the request proceeds.A typespec on every public function, and a
@type tforDowser.Client.ContextandDowser.Client.Request.mix dialyzerpasses clean with:error_handling,:extra_return,:missing_returnand:unmatched_returnsenabled.
Changed
- Breaking. A config is now a context, matching
elastic/cli's terminology for a named cluster:Dowser.Client.Config→Dowser.Client.Context(same struct andnew/1/resolve/1;fetch_configuration/1→fetch/1).- the
:configrequest option →:context. config :dowser_client, configs: [...]→contexts: [...].{:unknown_config, name}→{:unknown_context, name}.
- Breaking. A response body decodes to string-keyed maps and nothing else
happens to it by default:
:keysis no longer applied unless asked for. A request body is likewise encoded as given unless:encodepoints at a document source in it. - Breaking. A
GETcarrying a body is sent as aPOSTon the same path (logged at debug level) instead of returning{:error, {:unsupported_body, :get}}—:httpc's request tuple has no body slot forGET, and every backend that expects one (Elasticsearch'sGET /_search) accepts the same request as aPOST. A body onHEAD/TRACE, where it is meaningless, still returns{:error, {:unsupported_body, method}}. - Breaking.
Dowser.Client.HTTP.Stubis no longer an HTTP adapter and is no longer selected withhttp_adapter: Dowser.Client.HTTP.Stub:stub/1intercepts every request made by the calling process on its own, whatever the context. Drop:http_adapterfrom test contexts; everything else — the process-local state,async: truesafety,json/3,raw/3— is unchanged. Addedclear/0andfetch/0. A process with no stub no longer raises; it performs a real request. - Breaking.
Dowser.Client.NDJSON.encode/3/decode/3lose their adapter/options arguments and becomeencode/1/decode/1. - Breaking.
Dowser.Client.HTTP.Error's:adapterfield is now:profile. :ssl,:profile_optsand:http_optsmerge per key across the global, context and request tiers, so overriding one TLS setting on a request no longer discards the trust store configured on the context.
Removed
- Breaking.
Dowser.Client.HTTP.Adapterand its implementationsDowser.Client.HTTP.Httpc,Dowser.Client.HTTP.ReqandDowser.Client.HTTP.Hackney, along with thereqandhackneyoptional dependencies. HTTP isDowser.Client.HTTPover:httpc, always. - Breaking. The
:http_adapteroption, in all three tiers. A request still passing it fails with aDowser.Client.Errornaming the migration rather than quietly sending the request somewhere else; on a context it is ignored like any unknown key. - Breaking.
Dowser.Client.JSON.Adapterand its implementationsDowser.Client.JSON.Native,Dowser.Client.JSON.JasonandDowser.Client.JSON.Poison, along with thejasonandpoisonoptional dependencies and the:json_adapter/:json_optsoptions. JSON is Elixir's built-inJSONmodule, always. - Breaking. The whole codec layer:
Dowser.Client.Codec,Dowser.Client.Codec.Default,Dowser.Client.Codec.Errorand the:codec_adapter/:codec_optsoptions, replaced by:keys/:decoderfor reading and:encoder/:encodefor writing. - Breaking.
Dowser.Client.Codec.BuilderandDowser.Client.Field, the per-field casting layer. Nothing indowser_clientever called them: the pipeline only knows a:decoderand an:encoder, and how to dispatch inside one is the backend package's business, not the transport's. Move them into yours — acast/2dispatcher is ause-able macro and a behaviour, with no ties to this library — or write whatever suits that backend's mapping instead.:formatter.exs'locals_without_parens: [cast: 2]goes with them. - Breaking. Both aliases deprecated in 0.1.1 are gone, as announced there —
and so are the modules they delegated to:
Dowser.Client.CodecBuilder(forDowser.Client.Codec.Builder) andDowser.Client.Codecs.DefaultCodec(forDowser.Client.Codec.Default).
[0.1.1] - 2026-08-17
Changed
- Codec modules now live under the
Dowser.Client.Codecnamespace, for consistency withDowser.Client.HTTP.*andDowser.Client.JSON.*:Dowser.Client.CodecBuilderis nowDowser.Client.Codec.Builder, andDowser.Client.Codecs.DefaultCodec— the default:codec_adapter— is nowDowser.Client.Codec.Default. Behavior is unchanged.
Deprecated
Dowser.Client.CodecBuilder— kept as an alias delegating toDowser.Client.Codec.Builder;use Dowser.Client.CodecBuilderstill works but now emits a deprecation warning at compile time. To be removed in a future release.Dowser.Client.Codecs.DefaultCodec— kept as an alias delegating toDowser.Client.Codec.Default, so a config or request that names it explicitly as:codec_adapterkeeps working. To be removed in a future release.
Fixed
- The docs no longer reference the private
Dowser.CoreExt.Keyableprotocol, which mademix docswarn about documentation referencing a hidden function from the README,Dowser.Client.CodecandDowser.Client.Codec.Default.
[0.1.0] - 2026-08-17
Initial release.
Added
Dowser.Client— low-level entry point for querying a search backend over HTTP (request/4, plusget/post/put/patch/deleteshortcuts). Resolves a config and request options, performs the HTTP call, retries transient failures, and decodes the response body.Dowser.Client.Config— bundles an:endpoint,:auth, and the HTTP/JSON/ codec adapters and options to use for one search backend. Configs can be built inline withnew/1or configured by name at compile time (config :dowser_client, configs: [...]) and resolved by atom, map/keyword list, or omitted entirely (resolves the:defaultnamed entry — there is no other built-in fallback).inspect/1redacts the:authsecret.- Pluggable HTTP transport via
Dowser.Client.HTTP.Adapter, with three bundled implementations, all pinned to HTTP/1.1 with keep-alive:Dowser.Client.HTTP.Httpc— Erlang's built-in:httpc(no extra dependency), used by default.Dowser.Client.HTTP.Req— theReqlibrary (optional dependency).Dowser.Client.HTTP.Hackney—:hackney(optional dependency).- Sensible default connect (2s) and receive (30s) timeouts on every
adapter, always overridable per request via
:http_opts. - Adapter selection honors runtime application config
(
config :dowser_client, :http_adapter | :json_adapter | :codec_adapter), not just compile-time settings.
- Automatic retries on transient failures — connection errors (refused,
closed, timed out, unreachable) and retryable HTTP statuses (
429,502,503,504) — with exponential backoff and full jitter. On by default for every request/method; configurable or disable-able via:retry. - Pluggable JSON codec via
Dowser.Client.JSON.Adapter, with three bundled implementations: - Request/response body formats:
:json(default),:ndjsonfor bulk-style newline-delimited payloads, and:rawto pass bytes through untouched;:req_format/:resp_formatset each direction independently. Dowser.Client.Field— a behaviour for casting a single value to and from its wire representation (load/2/dump/2), for the backend-specific knowledgedowser_clientdoesn't have; backend packages likedowser_elasticsearchship their own field implementations.Dowser.Client.CodecBuilder—use-able macro that builds aload/2/dump/2dispatcher from a list ofDowser.Client.Fieldmappings declared withcast/2, pattern-matched against field metadata. Expands to plain pattern-matched function clauses at compile time, with:inherit,:fallbackand:niloptions.Dowser.Client.Codec— a behaviour for casting a whole request/response body (encode/2/decode/2), wired onto a config/request as:codec_adapter.Dowser.Client.Codecs.DefaultCodecis the built-in default, applying only:keyscasting; a backend package composes its ownCodecon top of aCodecBuilder-built dispatcher for per-field value casting (dates, geo points, ...), and:codec_opts(settable as aDowser.Client.Configdefault and per-request overridable) is forwarded to it.:keys(:stringsdefault,:atoms,:atoms!) to cast a decoded body's keys — however deeply nested — resolved into a:key_fnpassed to:codec_adapter; settable as aDowser.Client.Configdefault and per-request overridable.- Authentication helpers on
Dowser.Client.Configfor:basic,:bearer,:api_key, and arbitrary:headerauth, applied as request headers. - Header merging across global (
config :dowser_client, :http_opts, headers: ...), config, and per-request sources, with later (more specific) sources winning on name clashes. - Normalized error types so callers never see a dependency's own exception:
Dowser.Client.Error(config resolution, invalid:format/:keys/:retry, missing optional adapter dependency),Dowser.Client.JSON.Error(encode/decode failures),Dowser.Client.Codec.Error(:codec_adapterencode/2/decode/2failures), andDowser.Client.HTTP.Error(transport failures, reported with the number of attempts made). Dowser.unwrap/1to unwrap a{:ok, value} | {:error, exception}result into the plain value or a raised exception.Dowser.Client.HTTP.Stub— a fourthHTTP.Adapterfor tests.stub/1scripts the response for every request made by the calling process (no shared/global state, safe underasync: true), andjson/3/raw/3build matching responses, so a test can drive the full request/response pipeline without a real backend running.
Known limitations
Dowser.Client.HTTP.Httpccannot send a request body onGET/HEAD/TRACE(Erlang's:httpchas no body slot for those methods) — it returns{:error, {:unsupported_body, method}}rather than silently dropping the body. Backends that expect a body onGET(e.g. Elasticsearch'sGET /_search) needDowser.Client.HTTP.ReqorDowser.Client.HTTP.Hackney.