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.5.0] - 2026-11-05
No breaking change. Migrations run through a backend package's adapter:
they need dowser_opensearch 0.4 or dowser_elasticsearch 0.6.
Added
- Migrations.
use Dowser.Migrationdefines a migration of a repo's indices, like anEcto.Migration, inpriv/dowser/migrations/<UTC timestamp>_<name>.exs:create_index/2,drop_index/2, andalter_indexwithadd,modify,rename,remove,set,unsetandscript. Bodies are the engine's JSON as maps; values can come from functions, run when the migration runs — withrepo/0— so a migration can differ from one cluster to the next.change/0is inverted for a rollback (from:values and definitions make each operation reversible), orup/0/down/0are used. - Indices behind aliases. A migration's index name is an alias; the index
behind it is
<name>_<version>, its mapping's_meta.dowser_versionthe migration that last changed it, checked before each change (alter_index from:names it explicitly). The separator is per index:create_index "events", body, separator: "-"names itevents-<version>, and its later copies — and the index a rollback recreates — keep it. - Formatter export.
import_deps: [:dowser_client]in an application's.formatter.exskeeps the migration DSL (add :status, ...) free of parentheses. Dowser.Migrator, andmix dowser.gen.migration,mix dowser.migrate,mix dowser.rollback,mix dowser.migrationsandmix dowser.recover. A run is planned whole before any change — every check made, several migrations of one index folded together. What the engine changes in place (a field added, a dynamic setting) is changed in place; anything else copies the documents into a new index, only with--reindex: writes blocked during the copy (--no-write-block), the documents copied through one painless script generated from the operations — field names as parameters, safe for fields inside lists of objects and dotted keys — then the alias moved. The previous index is kept for a rollback to move back onto (--delete-previous). A failed copy is undone; a run that died mid-copy leaves the lock formix dowser.recover.--dry-runprints the requests.Dowser.Migration.Adapter, the behaviour a backend package implements to run migrations against its engine — tracking, lock, cluster calls. A repo names its adapter with__migration_adapter__/0.
[0.4.0] - 2026-11-04
Breaking — see UPGRADE_GUIDE_0_4.md for the path from 0.3.
Added
Dowser.Repo: one module per cluster, replacing contexts. A backend package builds on it (use Dowser.OpenSearch.Repo, otp_app: :my_app); the application configures the repo under its own app (config :my_app, MyApp.Search, endpoint: ...) and starts it in its supervision tree, like anEcto.Repo. Started, it resolves its configuration once — an optionalinit/2callback included, for secrets — into:persistent_term; not started, it resolves it per request. Each repo has its own:httpcprofile by default, exposesrequest/4and the HTTP verbs for any endpoint, and withverify: truechecks at startup that its endpoint runs the engine its backend package expects (engine/0).Dynamic repos. A repo module can be started any number of times under other names (
MyApp.Search.start_link(name: :tenant_a, endpoint: ...)), each with its own configuration and connection pool. A process picks one withput_dynamic_repo/1orwith_dynamic_repo/2, and a single call withrepo: {MyApp.Search, :tenant_a}.Mapping-driven casting, shared by
dowser_elasticsearchanddowser_opensearch. The casting both packages carried their own copy of now lives here, split three ways so that each concern is in one place:Module Concern Dowser.Type,Dowser.Type.*one leaf value: a date, a range, an IP address, … Dowser.Sourceone document: objects, nested fields, arrays, structs Dowser.Envelopea response body: finding the documents in it Dowser.Codecthe decoder/encoder engine a backend codec configures Dowser.MappingCacherthe mapping cache, one instance per backend ( use Dowser.MappingCacher, otp_app: ...)Dowser.MappingErrora mapping that could not be fetched A backend package supplies what differs: its application environment and telemetry prefix, its cacher, and the types that are opaque on it (
flat_objecton OpenSearch,flattenedon Elasticsearch).An application-wide mapping source.
:mappingdefaults from the backend's application environment (config :dowser_opensearch, mapping: MyApp.Mappings), then the backend's mapping cacher.Mock repos.
Dowser.Repo.request/5hands a request to any module implementingrequest/4— a Mox mock ofDowser.Repoincluded — which stands in for the whole transport.NaiveDateTimeandTimedate formats. Adatefield formattedstrict_date_hour_minute_second(or…_millis) loads to aNaiveDateTimeand dumps without an offset; one formattedstrict_hour_minute_second(or…_millis) loads to aTime.Casting options:
:mapping,:struct,:types,:unknown_fields,:mapping_failure.Dowser.Codecresolves all five per request, then from the codec's own options, then from the backend's application environment.:mappingtakes a map, aDowser.Mappingmodule (the new behaviour the mapping cachers implement), or a document module;:structloads every document into aDowser.Schemamodule's struct and dumps it back — the hit's_id,_seq_noand the rest reach it as:meta, and:keyscasts the envelope around it but never its fields;:unknown_fields: :raiseraises the newDowser.UnknownFieldErrorfor a field the mapping doesn't declare, loading or dumping.long_rangeis cast to aRange, likeinteger_range.-00:00is read as UTC.DateTime.from_iso8601/1rejects it, so such a value used to come back as a string.
Changed
Breaking:
:contextis now:repo, and named contexts are gone. Every request names its cluster withrepo:— aDowser.Repomodule, or[endpoint: ...]for a script.config :dowser_client, contexts: [...]is no longer read, anddowser_clienthas no default: a request without:reporeturns{:error, %Dowser.Client.Error{reason: :no_repo}}(the backend packages fall back to their own:default_repo). Passing:contextreturns an error pointing at:repo.Dowser.MappingCacher.put/4takes:repotoo, and raises without one rather than storing nothing.Breaking:
Dowser.Client.ContextisDowser.Repo.Config, the same struct, and a decoder or encoder receives it as:repo_configrather than:context.Breaking: the backend packages'
:codecoption is:types. It names theDowser.Typemodule leaf values are cast through; a request, a codec or an application environment still using:codecis now an error naming:types, where leaving it in place would have cast nothing without a word.Dowser.Source's own option is:typestoo.Breaking: a mapping cacher that isn't started is a failure. Its
lookup/2returns{:error, {:not_started, cacher}}where it returned{:ok, nil}, soDowser.Codecraises aDowser.MappingErrornaming it (or follows:mapping_failure) rather than leaving every value uncast. The backend packages no longer start their cacher; the application does.Breaking: only nine date formats are cast, each the one an Elixir function reads:
epoch_millis,epoch_second,strict_date,strict_date_hour_minute_second(_millis),strict_date_time(_no_millis)andstrict_hour_minute_second(_millis). Any other format — including none at all (strict_date_optional_time||epoch_millisto the backend), the non-strict spellings and Java patterns — returns its values as stored, in both directions. Give a date field one of the nine formats to have it cast.A date is written at exactly its format's precision. A
DateTime,NaiveDateTimeorTimeis truncated to the second or the millisecond as the format declares, and padded to three digits for a_millisformat —~U[2026-08-11 00:00:00Z]used to be written tostrict_date_timewithout the.000it requires. A date-time written tostrict_datekeeps its date.The mapping decides what a value is, never its shape. A value whose mapping entry has
"properties"(or is anobject/nested) is walked as an object; any other typed entry is a leaf, and its whole value goes to its type — even when that value is a map. The backend packages used to tell a range from an object by itsgte/ltekeys, and never handed ageo_pointwritten as%{"lat" => _, "lon" => _}to its type, so it came back as a map rather than{lat, lon}; both now follow the mapping. A leaf map that its type leaves uncast has its keys go through:keyslike the rest of the document.A
DateTimein any zone is dumped as its UTC instant. One built withtzdatain, say,America/New_Yorkused to be written with its own offset, so the same instant was written two ways. Loading already gave one UTCDateTime; both directions now behave the same with the default UTC-only time-zone database and withtzdata.Ranges are only cast when the Elixir struct holds them exactly. A
date_rangebecomes aDate.Rangeonly when its field is formattedstrict_date; with any other format, or none, a range whose bounds had a time used to have them loaded asDateTimes and handed toDate.range/2, which raised. Aninteger_range/long_rangebecomes aRangeonly with integergteandltebounds,gteno greater thanlte. Anything else — an open-ended range,gt/ltbounds, a descending pair — is returned as stored, and only a range stepping by 1 is dumped.A field the mapping doesn't know is left as it is (its keys still going through
:keys). It used to be walked against its parent's properties.:cryptois listed inextra_applications, and:telemetryis an optional dependency, for the mapping cacher's keys and the failed-fetch event.
Removed
- Breaking: named contexts —
config :dowser_client, contexts: [...]is no longer read — andDowser.Client.Context.fetch/1. A repo replaces both. - Breaking: the
{:unknown_context, name}error reason; a request now fails with:no_repo,{:unknown_repo, name}or{:invalid_repo, repo, message}.
[0.3.1] - 2026-11-02
Added
Compressed responses:
http_opts: [compressed: true].:httpcneither asks for a compressed response nor decompresses one, so every response came over the wire as plain JSON. With the option, a request sendsaccept-encoding: gzip(unless it already names an encoding), and the response is decompressed before it is decoded, itscontent-encodingandcontent-lengthheaders dropped with the compressed payload. A page of 100 large search hits went from 4.2 MB to 568 KB, and from 8.7 s to 1.7 s over a VPN.A gzipped response is decompressed even when it wasn't asked for, and one whose body isn't valid gzip returns
{:error, :invalid_gzip_body}. The option is off by default.
[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.