diff --git a/.golangci.yml b/.golangci.yml
index 6f8c15cb..e182de8f 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -161,7 +161,7 @@ issues:
       linters:
         - gocyclo
         - funlen
-    - path: providers/dns/checkdomain/client.go
+    - path: providers/dns/checkdomain/internal/types.go
       text: '`payed` is a misspelling of `paid`'
     - path: providers/dns/namecheap/namecheap_test.go
       text: 'cognitive complexity (\d+) of func `TestDNSProvider_getHosts` is high'
@@ -174,7 +174,7 @@ issues:
       text: 'yodaStyleExpr'
     - path: providers/dns/dns_providers.go
       text: 'Function name: NewDNSChallengeProviderByName,'
-    - path: providers/dns/sakuracloud/client.go
+    - path: providers/dns/sakuracloud/wrapper.go
       text: 'mu is a global variable'
     - path: providers/dns/hosttech/internal/client_test.go
       text: 'Duplicate words \(0\) found'
diff --git a/README.md b/README.md
index b35ba09e..9daf227e 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
 |---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
 | [Akamai EdgeDNS](https://go-acme.github.io/lego/dns/edgedns/)                   | [Alibaba Cloud DNS](https://go-acme.github.io/lego/dns/alidns/)                 | [all-inkl](https://go-acme.github.io/lego/dns/allinkl/)                         | [Amazon Lightsail](https://go-acme.github.io/lego/dns/lightsail/)               |
 | [Amazon Route 53](https://go-acme.github.io/lego/dns/route53/)                  | [ArvanCloud](https://go-acme.github.io/lego/dns/arvancloud/)                    | [Aurora DNS](https://go-acme.github.io/lego/dns/auroradns/)                     | [Autodns](https://go-acme.github.io/lego/dns/autodns/)                          |
-| [Azure](https://go-acme.github.io/lego/dns/azure/)                              | [Bindman](https://go-acme.github.io/lego/dns/bindman/)                          | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/)                          | [BRANDIT](https://go-acme.github.io/lego/dns/brandit/)                          |
+| [Azure](https://go-acme.github.io/lego/dns/azure/)                              | [Bindman](https://go-acme.github.io/lego/dns/bindman/)                          | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/)                          | [Brandit](https://go-acme.github.io/lego/dns/brandit/)                          |
 | [Bunny](https://go-acme.github.io/lego/dns/bunny/)                              | [Checkdomain](https://go-acme.github.io/lego/dns/checkdomain/)                  | [Civo](https://go-acme.github.io/lego/dns/civo/)                                | [CloudDNS](https://go-acme.github.io/lego/dns/clouddns/)                        |
 | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/)                    | [ClouDNS](https://go-acme.github.io/lego/dns/cloudns/)                          | [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/)                        | [ConoHa](https://go-acme.github.io/lego/dns/conoha/)                            |
 | [Constellix](https://go-acme.github.io/lego/dns/constellix/)                    | [deSEC.io](https://go-acme.github.io/lego/dns/desec/)                           | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/)               |
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index f839e949..59414b82 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -335,7 +335,7 @@ func displayDNSHelp(w io.Writer, name string) error {
 
 	case "brandit":
 		// generated from: providers/dns/brandit/brandit.toml
-		ew.writeln(`Configuration for BRANDIT.`)
+		ew.writeln(`Configuration for Brandit.`)
 		ew.writeln(`Code:	'brandit'`)
 		ew.writeln(`Since:	'v4.11.0'`)
 		ew.writeln()
diff --git a/docs/content/dns/zz_gen_brandit.md b/docs/content/dns/zz_gen_brandit.md
index 307dfa57..237c02af 100644
--- a/docs/content/dns/zz_gen_brandit.md
+++ b/docs/content/dns/zz_gen_brandit.md
@@ -1,5 +1,5 @@
 ---
-title: "BRANDIT"
+title: "Brandit"
 date: 2019-03-03T16:39:46+01:00
 draft: false
 slug: brandit
@@ -14,7 +14,7 @@ dnsprovider:
 <!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
 
 
-Configuration for [BRANDIT](https://www.brandit.com/).
+Configuration for [Brandit](https://www.brandit.com/).
 
 
 <!--more-->
@@ -23,7 +23,7 @@ Configuration for [BRANDIT](https://www.brandit.com/).
 - Since: v4.11.0
 
 
-Here is an example bash command using the BRANDIT provider:
+Here is an example bash command using the Brandit provider:
 
 ```bash
 BRANDIT_API_KEY=xxxxxxxxxxxxxxxxxxxxx \
diff --git a/docs/content/dns/zz_gen_otc.md b/docs/content/dns/zz_gen_otc.md
index 836f623e..0a7136cb 100644
--- a/docs/content/dns/zz_gen_otc.md
+++ b/docs/content/dns/zz_gen_otc.md
@@ -61,7 +61,7 @@ More information [here]({{< ref "dns#configuration-and-credentials" >}}).
 
 ## More information
 
-- [API documentation](https://docs.otc.t-systems.com/en-us/dns/index.html)
+- [API documentation](https://docs.otc.t-systems.com/domain-name-service/api-ref/index.html)
 
 <!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
 <!-- providers/dns/otc/otc.toml -->
diff --git a/go.mod b/go.mod
index 8353b374..9aac58bb 100644
--- a/go.mod
+++ b/go.mod
@@ -63,9 +63,9 @@ require (
 	github.com/vultr/govultr/v2 v2.17.2
 	github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f
 	github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997
-	golang.org/x/crypto v0.5.0
-	golang.org/x/net v0.7.0
-	golang.org/x/oauth2 v0.5.0
+	golang.org/x/crypto v0.7.0
+	golang.org/x/net v0.8.0
+	golang.org/x/oauth2 v0.6.0
 	golang.org/x/time v0.3.0
 	google.golang.org/api v0.111.0
 	gopkg.in/ns1/ns1-go.v2 v2.6.5
@@ -126,10 +126,10 @@ require (
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	go.uber.org/ratelimit v0.2.0 // indirect
-	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
-	golang.org/x/sys v0.5.0 // indirect
-	golang.org/x/text v0.7.0 // indirect
-	golang.org/x/tools v0.1.12 // indirect
+	golang.org/x/mod v0.8.0 // indirect
+	golang.org/x/sys v0.6.0 // indirect
+	golang.org/x/text v0.8.0 // indirect
+	golang.org/x/tools v0.6.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect
 	google.golang.org/grpc v1.53.0 // indirect
diff --git a/go.sum b/go.sum
index 60f610f0..fc925961 100644
--- a/go.sum
+++ b/go.sum
@@ -595,8 +595,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
-golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
+golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -619,8 +619,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -650,14 +650,14 @@ golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
-golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
-golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
+golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
+golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -712,12 +712,12 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
+golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@@ -726,8 +726,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -758,8 +758,8 @@ golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4X
 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/platform/wait/wait.go b/platform/wait/wait.go
index d0c078b8..6ad817b2 100644
--- a/platform/wait/wait.go
+++ b/platform/wait/wait.go
@@ -1,7 +1,6 @@
 package wait
 
 import (
-	"errors"
 	"fmt"
 	"time"
 
@@ -18,9 +17,9 @@ func For(msg string, timeout, interval time.Duration, f func() (bool, error)) er
 		select {
 		case <-timeUp:
 			if lastErr == nil {
-				return errors.New("time limit exceeded")
+				return fmt.Errorf("%s: time limit exceeded", msg)
 			}
-			return fmt.Errorf("time limit exceeded: last error: %w", lastErr)
+			return fmt.Errorf("%s: time limit exceeded: last error: %w", msg, lastErr)
 		default:
 		}
 
diff --git a/providers/dns/alidns/alidns.go b/providers/dns/alidns/alidns.go
index 7520be85..23320b23 100644
--- a/providers/dns/alidns/alidns.go
+++ b/providers/dns/alidns/alidns.go
@@ -198,7 +198,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) {
 
 	authZone, err := dns01.FindZoneByFqdn(domain)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
 	}
 
 	var hostedZone alidns.DomainInDescribeDomains
diff --git a/providers/dns/allinkl/allinkl.go b/providers/dns/allinkl/allinkl.go
index b82ba379..6525a119 100644
--- a/providers/dns/allinkl/allinkl.go
+++ b/providers/dns/allinkl/allinkl.go
@@ -2,6 +2,7 @@
 package allinkl
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -49,7 +50,9 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
-	client *internal.Client
+
+	identifier *internal.Identifier
+	client     *internal.Client
 
 	recordIDs   map[string]string
 	recordIDsMu sync.Mutex
@@ -80,16 +83,23 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("allinkl: missing credentials")
 	}
 
-	client := internal.NewClient(config.Login, config.Password)
+	identifier := internal.NewIdentifier(config.Login, config.Password)
+
+	if config.HTTPClient != nil {
+		identifier.HTTPClient = config.HTTPClient
+	}
+
+	client := internal.NewClient(config.Login)
 
 	if config.HTTPClient != nil {
 		client.HTTPClient = config.HTTPClient
 	}
 
 	return &DNSProvider{
-		config:    config,
-		client:    client,
-		recordIDs: make(map[string]string),
+		config:     config,
+		identifier: identifier,
+		client:     client,
+		recordIDs:  make(map[string]string),
 	}, nil
 }
 
@@ -105,14 +115,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("allinkl: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("allinkl: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	credential, err := d.client.Authentication(60, true)
+	ctx := context.Background()
+
+	credential, err := d.identifier.Authentication(ctx, 60, true)
 	if err != nil {
 		return fmt.Errorf("allinkl: %w", err)
 	}
 
+	ctx = internal.WithContext(ctx, credential)
+
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 	if err != nil {
 		return fmt.Errorf("allinkl: %w", err)
@@ -125,7 +139,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		RecordData: info.Value,
 	}
 
-	recordID, err := d.client.AddDNSSettings(credential, record)
+	recordID, err := d.client.AddDNSSettings(ctx, record)
 	if err != nil {
 		return fmt.Errorf("allinkl: %w", err)
 	}
@@ -141,11 +155,15 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	credential, err := d.client.Authentication(60, true)
+	ctx := context.Background()
+
+	credential, err := d.identifier.Authentication(ctx, 60, true)
 	if err != nil {
 		return fmt.Errorf("allinkl: %w", err)
 	}
 
+	ctx = internal.WithContext(ctx, credential)
+
 	// gets the record's unique ID from when we created it
 	d.recordIDsMu.Lock()
 	recordID, ok := d.recordIDs[token]
@@ -154,7 +172,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("allinkl: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	_, err = d.client.DeleteDNSSettings(credential, recordID)
+	_, err = d.client.DeleteDNSSettings(ctx, recordID)
 	if err != nil {
 		return fmt.Errorf("allinkl: %w", err)
 	}
diff --git a/providers/dns/allinkl/internal/client.go b/providers/dns/allinkl/internal/client.go
index 75eefaff..87894433 100644
--- a/providers/dns/allinkl/internal/client.go
+++ b/providers/dns/allinkl/internal/client.go
@@ -2,126 +2,64 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
-	"encoding/xml"
 	"fmt"
-	"io"
 	"net/http"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	"github.com/mitchellh/mapstructure"
 )
 
-const (
-	authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php"
-	apiEndpoint  = "https://kasapi.kasserver.com/soap/KasApi.php"
-)
+const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php"
+
+type Authentication interface {
+	Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error)
+}
 
 // Client a KAS server client.
 type Client struct {
-	login    string
-	password string
+	login string
 
-	authEndpoint string
-	apiEndpoint  string
-	HTTPClient   *http.Client
-	floodTime    time.Time
+	floodTime   time.Time
+	muFloodTime sync.Mutex
+
+	baseURL    string
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new Client.
-func NewClient(login string, password string) *Client {
+func NewClient(login string) *Client {
 	return &Client{
-		login:        login,
-		password:     password,
-		authEndpoint: authEndpoint,
-		apiEndpoint:  apiEndpoint,
-		HTTPClient:   &http.Client{Timeout: 10 * time.Second},
+		login:      login,
+		baseURL:    apiEndpoint,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}
 }
 
-// Authentication Creates a credential token.
-// - sessionLifetime: Validity of the token in seconds.
-// - sessionUpdateLifetime: with `true` the session is extended with every request.
-func (c Client) Authentication(sessionLifetime int, sessionUpdateLifetime bool) (string, error) {
-	sul := "N"
-	if sessionUpdateLifetime {
-		sul = "Y"
-	}
-
-	ar := AuthRequest{
-		Login:                 c.login,
-		AuthData:              c.password,
-		AuthType:              "plain",
-		SessionLifetime:       sessionLifetime,
-		SessionUpdateLifetime: sul,
-	}
-
-	body, err := json.Marshal(ar)
-	if err != nil {
-		return "", fmt.Errorf("request marshal: %w", err)
-	}
-
-	payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body)))
-
-	req, err := http.NewRequest(http.MethodPost, c.authEndpoint, bytes.NewReader(payload))
-	if err != nil {
-		return "", fmt.Errorf("request creation: %w", err)
-	}
-
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return "", fmt.Errorf("request execution: %w", err)
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return "", fmt.Errorf("invalid status code: %d %s", resp.StatusCode, string(data))
-	}
-
-	data, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return "", fmt.Errorf("response read: %w", err)
-	}
-
-	var e KasAuthEnvelope
-	decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(data))})
-	err = decoder.Decode(&e)
-	if err != nil {
-		return "", fmt.Errorf("response xml decode: %w", err)
-	}
-
-	if e.Body.Fault != nil {
-		return "", e.Body.Fault
-	}
-
-	return e.Body.KasAuthResponse.Return.Text, nil
-}
-
 // GetDNSSettings Reading out the DNS settings of a zone.
 // - zone: host zone.
 // - recordID: the ID of the resource record (optional).
-func (c *Client) GetDNSSettings(credentialToken, zone, recordID string) ([]ReturnInfo, error) {
+func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]ReturnInfo, error) {
 	requestParams := map[string]string{"zone_host": zone}
 
 	if recordID != "" {
 		requestParams["record_id"] = recordID
 	}
 
-	item, err := c.do(credentialToken, "get_dns_settings", requestParams)
+	req, err := c.newRequest(ctx, "get_dns_settings", requestParams)
 	if err != nil {
 		return nil, err
 	}
 
-	raw := getValue(item)
-
 	var g GetDNSSettingsAPIResponse
-	err = mapstructure.Decode(raw, &g)
+	err = c.do(req, &g)
 	if err != nil {
-		return nil, fmt.Errorf("response struct decode: %w", err)
+		return nil, err
 	}
 
 	c.updateFloodTime(g.Response.KasFloodDelay)
@@ -130,18 +68,16 @@ func (c *Client) GetDNSSettings(credentialToken, zone, recordID string) ([]Retur
 }
 
 // AddDNSSettings Creation of a DNS resource record.
-func (c *Client) AddDNSSettings(credentialToken string, record DNSRequest) (string, error) {
-	item, err := c.do(credentialToken, "add_dns_settings", record)
+func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) {
+	req, err := c.newRequest(ctx, "add_dns_settings", record)
 	if err != nil {
 		return "", err
 	}
 
-	raw := getValue(item)
-
 	var g AddDNSSettingsAPIResponse
-	err = mapstructure.Decode(raw, &g)
+	err = c.do(req, &g)
 	if err != nil {
-		return "", fmt.Errorf("response struct decode: %w", err)
+		return "", err
 	}
 
 	c.updateFloodTime(g.Response.KasFloodDelay)
@@ -150,20 +86,18 @@ func (c *Client) AddDNSSettings(credentialToken string, record DNSRequest) (stri
 }
 
 // DeleteDNSSettings Deleting a DNS Resource Record.
-func (c *Client) DeleteDNSSettings(credentialToken, recordID string) (bool, error) {
+func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (bool, error) {
 	requestParams := map[string]string{"record_id": recordID}
 
-	item, err := c.do(credentialToken, "delete_dns_settings", requestParams)
+	req, err := c.newRequest(ctx, "delete_dns_settings", requestParams)
 	if err != nil {
 		return false, err
 	}
 
-	raw := getValue(item)
-
 	var g DeleteDNSSettingsAPIResponse
-	err = mapstructure.Decode(raw, &g)
+	err = c.do(req, &g)
 	if err != nil {
-		return false, fmt.Errorf("response struct decode: %w", err)
+		return false, err
 	}
 
 	c.updateFloodTime(g.Response.KasFloodDelay)
@@ -171,65 +105,72 @@ func (c *Client) DeleteDNSSettings(credentialToken, recordID string) (bool, erro
 	return g.Response.ReturnInfo, nil
 }
 
-func (c Client) do(credentialToken, action string, requestParams interface{}) (*Item, error) {
-	time.Sleep(time.Until(c.floodTime))
-
+func (c *Client) newRequest(ctx context.Context, action string, requestParams any) (*http.Request, error) {
 	ar := KasRequest{
 		Login:         c.login,
 		AuthType:      "session",
-		AuthData:      credentialToken,
+		AuthData:      getToken(ctx),
 		Action:        action,
 		RequestParams: requestParams,
 	}
 
 	body, err := json.Marshal(ar)
 	if err != nil {
-		return nil, fmt.Errorf("request marshal: %w", err)
+		return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 	}
 
 	payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body)))
 
-	req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(payload))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload))
 	if err != nil {
-		return nil, fmt.Errorf("request creation: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
+	return req, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	c.muFloodTime.Lock()
+	time.Sleep(time.Until(c.floodTime))
+	c.muFloodTime.Unlock()
+
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("request execution: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return nil, fmt.Errorf("invalid status code: %d %s", resp.StatusCode, string(data))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	data, err := io.ReadAll(resp.Body)
+	envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body)
 	if err != nil {
-		return nil, fmt.Errorf("response read: %w", err)
+		return err
 	}
 
-	var e KasAPIResponseEnvelope
-	decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(data))})
-	err = decoder.Decode(&e)
+	if envlp.Body.Fault != nil {
+		return envlp.Body.Fault
+	}
+
+	raw := getValue(envlp.Body.KasAPIResponse.Return)
+
+	err = mapstructure.Decode(raw, result)
 	if err != nil {
-		return nil, fmt.Errorf("response xml decode: %w", err)
+		return fmt.Errorf("response struct decode: %w", err)
 	}
 
-	if e.Body.Fault != nil {
-		return nil, e.Body.Fault
-	}
-
-	return e.Body.KasAPIResponse.Return, nil
+	return nil
 }
 
 func (c *Client) updateFloodTime(delay float64) {
+	c.muFloodTime.Lock()
 	c.floodTime = time.Now().Add(time.Duration(delay * float64(time.Second)))
+	c.muFloodTime.Unlock()
 }
 
-func getValue(item *Item) interface{} {
+func getValue(item *Item) any {
 	switch {
 	case item.Raw != "":
 		v, _ := strconv.ParseBool(item.Raw)
@@ -253,7 +194,7 @@ func getValue(item *Item) interface{} {
 		return getValue(item.Value)
 
 	case len(item.Items) > 0 && item.Type == "SOAP-ENC:Array":
-		var v []interface{}
+		var v []any
 		for _, i := range item.Items {
 			v = append(v, getValue(i))
 		}
@@ -261,7 +202,7 @@ func getValue(item *Item) interface{} {
 		return v
 
 	case len(item.Items) > 0:
-		v := map[string]interface{}{}
+		v := map[string]any{}
 		for _, i := range item.Items {
 			v[getKey(i)] = getValue(i)
 		}
diff --git a/providers/dns/allinkl/internal/client_test.go b/providers/dns/allinkl/internal/client_test.go
index e2b51d1e..3eb7c21a 100644
--- a/providers/dns/allinkl/internal/client_test.go
+++ b/providers/dns/allinkl/internal/client_test.go
@@ -13,36 +13,6 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_Authentication(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
-	mux.HandleFunc("/", testHandler("auth.xml"))
-
-	client := NewClient("user", "secret")
-	client.authEndpoint = server.URL
-
-	credentialToken, err := client.Authentication(60, false)
-	require.NoError(t, err)
-
-	assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken)
-}
-
-func TestClient_Authentication_error(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
-	mux.HandleFunc("/", testHandler("auth_fault.xml"))
-
-	client := NewClient("user", "secret")
-	client.authEndpoint = server.URL
-
-	_, err := client.Authentication(60, false)
-	require.Error(t, err)
-}
-
 func TestClient_GetDNSSettings(t *testing.T) {
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
@@ -50,12 +20,10 @@ func TestClient_GetDNSSettings(t *testing.T) {
 
 	mux.HandleFunc("/", testHandler("get_dns_settings.xml"))
 
-	client := NewClient("user", "secret")
-	client.apiEndpoint = server.URL
+	client := NewClient("user")
+	client.baseURL = server.URL
 
-	token := "sha1secret"
-
-	records, err := client.GetDNSSettings(token, "example.com", "")
+	records, err := client.GetDNSSettings(mockContext(), "example.com", "")
 	require.NoError(t, err)
 
 	expected := []ReturnInfo{
@@ -134,10 +102,8 @@ func TestClient_AddDNSSettings(t *testing.T) {
 
 	mux.HandleFunc("/", testHandler("add_dns_settings.xml"))
 
-	client := NewClient("user", "secret")
-	client.apiEndpoint = server.URL
-
-	token := "sha1secret"
+	client := NewClient("user")
+	client.baseURL = server.URL
 
 	record := DNSRequest{
 		ZoneHost:   "42cnc.de.",
@@ -146,7 +112,7 @@ func TestClient_AddDNSSettings(t *testing.T) {
 		RecordData: "abcdefgh",
 	}
 
-	recordID, err := client.AddDNSSettings(token, record)
+	recordID, err := client.AddDNSSettings(mockContext(), record)
 	require.NoError(t, err)
 
 	assert.Equal(t, "57347444", recordID)
@@ -159,12 +125,10 @@ func TestClient_DeleteDNSSettings(t *testing.T) {
 
 	mux.HandleFunc("/", testHandler("delete_dns_settings.xml"))
 
-	client := NewClient("user", "secret")
-	client.apiEndpoint = server.URL
+	client := NewClient("user")
+	client.baseURL = server.URL
 
-	token := "sha1secret"
-
-	r, err := client.DeleteDNSSettings(token, "57347450")
+	r, err := client.DeleteDNSSettings(mockContext(), "57347450")
 	require.NoError(t, err)
 
 	assert.True(t, r)
diff --git a/providers/dns/allinkl/internal/identity.go b/providers/dns/allinkl/internal/identity.go
new file mode 100644
index 00000000..4353ece3
--- /dev/null
+++ b/providers/dns/allinkl/internal/identity.go
@@ -0,0 +1,104 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// authEndpoint represents the Identity API endpoint to call.
+const authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php"
+
+type token string
+
+const tokenKey token = "token"
+
+// Identifier generates credential tokens.
+type Identifier struct {
+	login    string
+	password string
+
+	authEndpoint string
+	HTTPClient   *http.Client
+}
+
+// NewIdentifier creates a new Identifier.
+func NewIdentifier(login string, password string) *Identifier {
+	return &Identifier{
+		login:        login,
+		password:     password,
+		authEndpoint: authEndpoint,
+		HTTPClient:   &http.Client{Timeout: 10 * time.Second},
+	}
+}
+
+// Authentication Creates a credential token.
+// - sessionLifetime: Validity of the token in seconds.
+// - sessionUpdateLifetime: with `true` the session is extended with every request.
+func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) {
+	sul := "N"
+	if sessionUpdateLifetime {
+		sul = "Y"
+	}
+
+	ar := AuthRequest{
+		Login:                 c.login,
+		AuthData:              c.password,
+		AuthType:              "plain",
+		SessionLifetime:       sessionLifetime,
+		SessionUpdateLifetime: sul,
+	}
+
+	body, err := json.Marshal(ar)
+	if err != nil {
+		return "", fmt.Errorf("failed to create request JSON body: %w", err)
+	}
+
+	payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body)))
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authEndpoint, bytes.NewReader(payload))
+	if err != nil {
+		return "", fmt.Errorf("unable to create request: %w", err)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return "", errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	envlp, err := decodeXML[KasAuthEnvelope](resp.Body)
+	if err != nil {
+		return "", err
+	}
+
+	if envlp.Body.Fault != nil {
+		return "", envlp.Body.Fault
+	}
+
+	return envlp.Body.KasAuthResponse.Return.Text, nil
+}
+
+func WithContext(ctx context.Context, credential string) context.Context {
+	return context.WithValue(ctx, tokenKey, credential)
+}
+
+func getToken(ctx context.Context) string {
+	credential, ok := ctx.Value(tokenKey).(string)
+	if !ok {
+		return ""
+	}
+
+	return credential
+}
diff --git a/providers/dns/allinkl/internal/identity_test.go b/providers/dns/allinkl/internal/identity_test.go
new file mode 100644
index 00000000..0753f386
--- /dev/null
+++ b/providers/dns/allinkl/internal/identity_test.go
@@ -0,0 +1,45 @@
+package internal
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func mockContext() context.Context {
+	return context.WithValue(context.Background(), tokenKey, "593959ca04f0de9689b586c6a647d15d")
+}
+
+func TestIdentifier_Authentication(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc("/", testHandler("auth.xml"))
+
+	client := NewIdentifier("user", "secret")
+	client.authEndpoint = server.URL
+
+	credentialToken, err := client.Authentication(context.Background(), 60, false)
+	require.NoError(t, err)
+
+	assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken)
+}
+
+func TestIdentifier_Authentication_error(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc("/", testHandler("auth_fault.xml"))
+
+	client := NewIdentifier("user", "secret")
+	client.authEndpoint = server.URL
+
+	_, err := client.Authentication(context.Background(), 60, false)
+	require.Error(t, err)
+}
diff --git a/providers/dns/allinkl/internal/types.go b/providers/dns/allinkl/internal/types.go
index ac2ddd39..b5c6ba0d 100644
--- a/providers/dns/allinkl/internal/types.go
+++ b/providers/dns/allinkl/internal/types.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/xml"
 	"fmt"
+	"io"
 )
 
 // Trimmer trim all XML fields.
@@ -44,3 +45,18 @@ type Item struct {
 	Value *Item   `xml:"value" json:"value,omitempty"`
 	Items []*Item `xml:"item" json:"item,omitempty"`
 }
+
+func decodeXML[T any](reader io.Reader) (*T, error) {
+	raw, err := io.ReadAll(reader)
+	if err != nil {
+		return nil, fmt.Errorf("read response body: %w", err)
+	}
+
+	var result T
+	err = xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}).Decode(&result)
+	if err != nil {
+		return nil, fmt.Errorf("decode XML response: %w", err)
+	}
+
+	return &result, nil
+}
diff --git a/providers/dns/allinkl/internal/types_api.go b/providers/dns/allinkl/internal/types_api.go
index 49db25a3..9207dc1a 100644
--- a/providers/dns/allinkl/internal/types_api.go
+++ b/providers/dns/allinkl/internal/types_api.go
@@ -35,7 +35,7 @@ type KasRequest struct {
 	// Action API function.
 	Action string `json:"kas_action,omitempty"`
 	// RequestParams Parameters to the API function.
-	RequestParams interface{} `json:"KasRequestParams,omitempty"`
+	RequestParams any `json:"KasRequestParams,omitempty"`
 }
 
 type DNSRequest struct {
@@ -64,13 +64,13 @@ type GetDNSSettingsResponse struct {
 }
 
 type ReturnInfo struct {
-	ID         interface{} `json:"record_id,omitempty" mapstructure:"record_id"`
-	Zone       string      `json:"record_zone,omitempty" mapstructure:"record_zone"`
-	Name       string      `json:"record_name,omitempty" mapstructure:"record_name"`
-	Type       string      `json:"record_type,omitempty" mapstructure:"record_type"`
-	Data       string      `json:"record_data,omitempty" mapstructure:"record_data"`
-	Changeable string      `json:"record_changeable,omitempty" mapstructure:"record_changeable"`
-	Aux        int         `json:"record_aux,omitempty" mapstructure:"record_aux"`
+	ID         any    `json:"record_id,omitempty" mapstructure:"record_id"`
+	Zone       string `json:"record_zone,omitempty" mapstructure:"record_zone"`
+	Name       string `json:"record_name,omitempty" mapstructure:"record_name"`
+	Type       string `json:"record_type,omitempty" mapstructure:"record_type"`
+	Data       string `json:"record_data,omitempty" mapstructure:"record_data"`
+	Changeable string `json:"record_changeable,omitempty" mapstructure:"record_changeable"`
+	Aux        int    `json:"record_aux,omitempty" mapstructure:"record_aux"`
 }
 
 type AddDNSSettingsAPIResponse struct {
diff --git a/providers/dns/arvancloud/arvancloud.go b/providers/dns/arvancloud/arvancloud.go
index 6e5935b4..dde75724 100644
--- a/providers/dns/arvancloud/arvancloud.go
+++ b/providers/dns/arvancloud/arvancloud.go
@@ -2,6 +2,7 @@
 package arvancloud
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -108,11 +109,13 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	authZone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("arvancloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
+	authZone = dns01.UnFqdn(authZone)
+
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 	if err != nil {
 		return fmt.Errorf("arvancloud: %w", err)
@@ -131,7 +134,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		},
 	}
 
-	newRecord, err := d.client.CreateRecord(authZone, record)
+	newRecord, err := d.client.CreateRecord(context.Background(), authZone, record)
 	if err != nil {
 		return fmt.Errorf("arvancloud: failed to add TXT record: fqdn=%s: %w", info.EffectiveFQDN, err)
 	}
@@ -147,11 +150,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	authZone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("arvancloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
+	authZone = dns01.UnFqdn(authZone)
+
 	// gets the record's unique ID from when we created it
 	d.recordIDsMu.Lock()
 	recordID, ok := d.recordIDs[token]
@@ -160,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("arvancloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	if err := d.client.DeleteRecord(authZone, recordID); err != nil {
+	if err := d.client.DeleteRecord(context.Background(), authZone, recordID); err != nil {
 		return fmt.Errorf("arvancloud: failed to delate TXT record: id=%s: %w", recordID, err)
 	}
 
@@ -171,12 +176,3 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	return nil
 }
-
-func getZone(fqdn string) (string, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return "", err
-	}
-
-	return dns01.UnFqdn(authZone), nil
-}
diff --git a/providers/dns/arvancloud/internal/client.go b/providers/dns/arvancloud/internal/client.go
index 9cf5b85a..3caff392 100644
--- a/providers/dns/arvancloud/internal/client.go
+++ b/providers/dns/arvancloud/internal/client.go
@@ -2,39 +2,45 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
 	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // defaultBaseURL represents the API endpoint to call.
 const defaultBaseURL = "https://napi.arvancloud.ir"
 
-const authHeader = "Authorization"
+const authorizationHeader = "Authorization"
 
 // Client the ArvanCloud client.
 type Client struct {
-	HTTPClient *http.Client
-	BaseURL    string
-
 	apiKey string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
-// NewClient Creates a new ArvanCloud client.
+// NewClient Creates a new Client.
 func NewClient(apiKey string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    defaultBaseURL,
 		apiKey:     apiKey,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // GetTxtRecord gets a TXT record.
-func (c *Client) GetTxtRecord(domain, name, value string) (*DNSRecord, error) {
-	records, err := c.getRecords(domain, name)
+func (c *Client) GetTxtRecord(ctx context.Context, domain, name, value string) (*DNSRecord, error) {
+	records, err := c.getRecords(ctx, domain, name)
 	if err != nil {
 		return nil, err
 	}
@@ -49,11 +55,8 @@ func (c *Client) GetTxtRecord(domain, name, value string) (*DNSRecord, error) {
 }
 
 // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.list
-func (c *Client) getRecords(domain, search string) ([]DNSRecord, error) {
-	endpoint, err := c.createEndpoint("cdn", "4.0", "domains", domain, "dns-records")
-	if err != nil {
-		return nil, fmt.Errorf("failed to create endpoint: %w", err)
-	}
+func (c *Client) getRecords(ctx context.Context, domain, search string) ([]DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records")
 
 	if search != "" {
 		query := endpoint.Query()
@@ -61,123 +64,110 @@ func (c *Client) getRecords(domain, search string) ([]DNSRecord, error) {
 		endpoint.RawQuery = query.Encode()
 	}
 
-	resp, err := c.do(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	body, err := io.ReadAll(resp.Body)
+	response := &apiResponse[[]DNSRecord]{}
+	err = c.do(req, http.StatusOK, response)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
+		return nil, fmt.Errorf("could not get records %s: Domain: %s: %w", search, domain, err)
 	}
 
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("could not get records %s: Domain: %s; Status: %s; Body: %s",
-			search, domain, resp.Status, string(body))
-	}
-
-	response := &apiResponse{}
-	err = json.Unmarshal(body, response)
-	if err != nil {
-		return nil, fmt.Errorf("failed to decode response body: %w", err)
-	}
-
-	var records []DNSRecord
-	err = json.Unmarshal(response.Data, &records)
-	if err != nil {
-		return nil, fmt.Errorf("failed to decode records: %w", err)
-	}
-
-	return records, nil
+	return response.Data, nil
 }
 
 // CreateRecord creates a DNS record.
 // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.create
-func (c *Client) CreateRecord(domain string, record DNSRecord) (*DNSRecord, error) {
-	reqBody, err := json.Marshal(record)
+func (c *Client) CreateRecord(ctx context.Context, domain string, record DNSRecord) (*DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return nil, err
 	}
 
-	endpoint, err := c.createEndpoint("cdn", "4.0", "domains", domain, "dns-records")
+	response := &apiResponse[*DNSRecord]{}
+	err = c.do(req, http.StatusCreated, response)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create endpoint: %w", err)
+		return nil, fmt.Errorf("could not create record; Domain: %s: %w", domain, err)
 	}
 
-	resp, err := c.do(http.MethodPost, endpoint.String(), bytes.NewReader(reqBody))
-	if err != nil {
-		return nil, err
-	}
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
-	}
-
-	if resp.StatusCode != http.StatusCreated {
-		return nil, fmt.Errorf("could not create record %s; Domain: %s; Status: %s; Body: %s", string(reqBody), domain, resp.Status, string(body))
-	}
-
-	response := &apiResponse{}
-	err = json.Unmarshal(body, response)
-	if err != nil {
-		return nil, fmt.Errorf("failed to decode response body: %w", err)
-	}
-
-	var newRecord DNSRecord
-	err = json.Unmarshal(response.Data, &newRecord)
-	if err != nil {
-		return nil, fmt.Errorf("failed to decode record: %w", err)
-	}
-
-	return &newRecord, nil
+	return response.Data, nil
 }
 
 // DeleteRecord deletes a DNS record.
 // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.remove
-func (c *Client) DeleteRecord(domain, id string) error {
-	endpoint, err := c.createEndpoint("cdn", "4.0", "domains", domain, "dns-records", id)
-	if err != nil {
-		return fmt.Errorf("failed to create endpoint: %w", err)
-	}
+func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error {
+	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records", id)
 
-	resp, err := c.do(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
 
-	if resp.StatusCode != http.StatusOK {
-		body, _ := io.ReadAll(resp.Body)
-		return fmt.Errorf("could not delete record %s; Domain: %s; Status: %s; Body: %s", id, domain, resp.Status, string(body))
+	err = c.do(req, http.StatusOK, nil)
+	if err != nil {
+		return fmt.Errorf("could not delete record %s; Domain: %s: %w", id, domain, err)
 	}
 
 	return nil
 }
 
-func (c *Client) do(method, endpoint string, body io.Reader) (*http.Response, error) {
-	req, err := http.NewRequest(method, endpoint, body)
+func (c *Client) do(req *http.Request, expectedStatus int, result any) error {
+	req.Header.Set(authorizationHeader, c.apiKey)
+
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, err
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != expectedStatus {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Accept", "application/json")
-	if body != nil {
+
+	if payload != nil {
 		req.Header.Set("Content-Type", "application/json")
 	}
-	req.Header.Set(authHeader, c.apiKey)
 
-	return c.HTTPClient.Do(req)
-}
-
-func (c *Client) createEndpoint(parts ...string) (*url.URL, error) {
-	baseURL, err := url.Parse(c.BaseURL)
-	if err != nil {
-		return nil, err
-	}
-
-	return baseURL.JoinPath(parts...), nil
+	return req, nil
 }
 
 func equalsTXTRecord(record DNSRecord, name, value string) bool {
@@ -189,7 +179,7 @@ func equalsTXTRecord(record DNSRecord, name, value string) bool {
 		return false
 	}
 
-	data, ok := record.Value.(map[string]interface{})
+	data, ok := record.Value.(map[string]any)
 	if !ok {
 		return false
 	}
diff --git a/providers/dns/arvancloud/internal/client_test.go b/providers/dns/arvancloud/internal/client_test.go
index f21311ae..5c9154c6 100644
--- a/providers/dns/arvancloud/internal/client_test.go
+++ b/providers/dns/arvancloud/internal/client_test.go
@@ -1,10 +1,12 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -12,21 +14,34 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_GetTxtRecord(t *testing.T) {
+func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	const domain = "example.com"
+	client := NewClient(apiKey)
+	client.baseURL, _ = url.Parse(server.URL)
+	client.HTTPClient = server.Client()
+
+	return client, mux
+}
+
+func TestClient_GetTxtRecord(t *testing.T) {
 	const apiKey = "myKeyA"
 
+	client, mux := setupTest(t, apiKey)
+
+	const domain = "example.com"
+
 	mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
 			return
 		}
 
-		auth := req.Header.Get(authHeader)
+		auth := req.Header.Get(authorizationHeader)
 		if auth != apiKey {
 			http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
 			return
@@ -46,20 +61,16 @@ func TestClient_GetTxtRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient(apiKey)
-	client.BaseURL = server.URL
-
-	_, err := client.GetTxtRecord(domain, "_acme-challenge", "txtxtxt")
+	_, err := client.GetTxtRecord(context.Background(), domain, "_acme-challenge", "txtxtxt")
 	require.NoError(t, err)
 }
 
 func TestClient_CreateRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	const apiKey = "myKeyB"
+
+	client, mux := setupTest(t, apiKey)
 
 	const domain = "example.com"
-	const apiKey = "myKeyB"
 
 	mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
@@ -67,7 +78,7 @@ func TestClient_CreateRecord(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get(authHeader)
+		auth := req.Header.Get(authorizationHeader)
 		if auth != apiKey {
 			http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
 			return
@@ -88,9 +99,6 @@ func TestClient_CreateRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient(apiKey)
-	client.BaseURL = server.URL
-
 	record := DNSRecord{
 		Name:  "_acme-challenge",
 		Type:  "txt",
@@ -98,7 +106,7 @@ func TestClient_CreateRecord(t *testing.T) {
 		TTL:   600,
 	}
 
-	newRecord, err := client.CreateRecord(domain, record)
+	newRecord, err := client.CreateRecord(context.Background(), domain, record)
 	require.NoError(t, err)
 
 	expected := &DNSRecord{
@@ -119,12 +127,11 @@ func TestClient_CreateRecord(t *testing.T) {
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	const apiKey = "myKeyC"
+
+	client, mux := setupTest(t, apiKey)
 
 	const domain = "example.com"
-	const apiKey = "myKeyC"
 	const recordID = "recordId"
 
 	mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records/"+recordID, func(rw http.ResponseWriter, req *http.Request) {
@@ -133,16 +140,13 @@ func TestClient_DeleteRecord(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get(authHeader)
+		auth := req.Header.Get(authorizationHeader)
 		if auth != apiKey {
 			http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized)
 			return
 		}
 	})
 
-	client := NewClient(apiKey)
-	client.BaseURL = server.URL
-
-	err := client.DeleteRecord(domain, recordID)
+	err := client.DeleteRecord(context.Background(), domain, recordID)
 	require.NoError(t, err)
 }
diff --git a/providers/dns/arvancloud/internal/model.go b/providers/dns/arvancloud/internal/types.go
similarity index 80%
rename from providers/dns/arvancloud/internal/model.go
rename to providers/dns/arvancloud/internal/types.go
index f26043bc..dc6e04e5 100644
--- a/providers/dns/arvancloud/internal/model.go
+++ b/providers/dns/arvancloud/internal/types.go
@@ -1,17 +1,15 @@
 package internal
 
-import "encoding/json"
-
-type apiResponse struct {
-	Message string          `json:"message"`
-	Data    json.RawMessage `json:"data"`
+type apiResponse[T any] struct {
+	Message string `json:"message"`
+	Data    T      `json:"data"`
 }
 
 // DNSRecord a DNS record.
 type DNSRecord struct {
 	ID            string        `json:"id,omitempty"`
 	Type          string        `json:"type"`
-	Value         interface{}   `json:"value,omitempty"`
+	Value         any           `json:"value,omitempty"`
 	Name          string        `json:"name,omitempty"`
 	TTL           int           `json:"ttl,omitempty"`
 	UpstreamHTTPS string        `json:"upstream_https,omitempty"`
diff --git a/providers/dns/auroradns/auroradns.go b/providers/dns/auroradns/auroradns.go
index 743a226d..700046c4 100644
--- a/providers/dns/auroradns/auroradns.go
+++ b/providers/dns/auroradns/auroradns.go
@@ -108,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("aurora: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("aurora: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// 1. Aurora will happily create the TXT record when it is provided a fqdn,
@@ -155,24 +155,24 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	d.recordIDsMu.Unlock()
 
 	if !ok {
-		return fmt.Errorf("unknown recordID for %q", info.EffectiveFQDN)
+		return fmt.Errorf("aurora: unknown recordID for %q", info.EffectiveFQDN)
 	}
 
 	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN))
 	if err != nil {
-		return fmt.Errorf("could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("aurora: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	authZone = dns01.UnFqdn(authZone)
 
 	zone, err := d.getZoneInformationByName(authZone)
 	if err != nil {
-		return err
+		return fmt.Errorf("aurora: %w", err)
 	}
 
 	_, _, err = d.client.DeleteRecord(zone.ID, recordID)
 	if err != nil {
-		return err
+		return fmt.Errorf("aurora: %w", err)
 	}
 
 	d.recordIDsMu.Lock()
diff --git a/providers/dns/autodns/autodns.go b/providers/dns/autodns/autodns.go
index abc3433d..3ab31ab1 100644
--- a/providers/dns/autodns/autodns.go
+++ b/providers/dns/autodns/autodns.go
@@ -2,6 +2,7 @@
 package autodns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/autodns/internal"
 )
 
 // Environment variables names.
@@ -27,11 +29,6 @@ const (
 	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
 )
 
-const (
-	defaultEndpointContext int = 4
-	defaultTTL             int = 600
-)
-
 // Config is used to configure the creation of the DNSProvider.
 type Config struct {
 	Endpoint           *url.URL
@@ -46,12 +43,12 @@ type Config struct {
 
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
-	endpoint, _ := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, defaultEndpoint))
+	endpoint, _ := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, internal.DefaultEndpoint))
 
 	return &Config{
 		Endpoint:           endpoint,
-		Context:            env.GetOrDefaultInt(EnvAPIEndpointContext, defaultEndpointContext),
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		Context:            env.GetOrDefaultInt(EnvAPIEndpointContext, internal.DefaultEndpointContext),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 600),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second),
 		HTTPClient: &http.Client{
@@ -63,6 +60,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for autoDNS.
@@ -94,7 +92,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("autodns: missing password")
 	}
 
-	return &DNSProvider{config: config}, nil
+	client := internal.NewClient(config.Username, config.Password, config.Context)
+
+	if config.Endpoint != nil {
+		client.BaseURL = config.Endpoint
+	}
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -107,7 +115,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	records := []*ResourceRecord{{
+	records := []*internal.ResourceRecord{{
 		Name:  info.EffectiveFQDN,
 		TTL:   int64(d.config.TTL),
 		Type:  "TXT",
@@ -115,7 +123,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	}}
 
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	_, err := d.addTxtRecord(domain, records)
+	_, err := d.client.AddTxtRecords(context.Background(), domain, records)
 	if err != nil {
 		return fmt.Errorf("autodns: %w", err)
 	}
@@ -127,7 +135,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	records := []*ResourceRecord{{
+	records := []*internal.ResourceRecord{{
 		Name:  info.EffectiveFQDN,
 		TTL:   int64(d.config.TTL),
 		Type:  "TXT",
@@ -135,7 +143,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	}}
 
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	if err := d.removeTXTRecord(domain, records); err != nil {
+	if err := d.client.RemoveTXTRecords(context.Background(), domain, records); err != nil {
 		return fmt.Errorf("autodns: %w", err)
 	}
 
diff --git a/providers/dns/autodns/client.go b/providers/dns/autodns/client.go
deleted file mode 100644
index 1c58ed81..00000000
--- a/providers/dns/autodns/client.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package autodns
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"strconv"
-)
-
-const (
-	defaultEndpoint = "https://api.autodns.com/v1/"
-)
-
-type ResponseMessage struct {
-	Text     string   `json:"text"`
-	Messages []string `json:"messages"`
-	Objects  []string `json:"objects"`
-	Code     string   `json:"code"`
-	Status   string   `json:"status"`
-}
-
-type ResponseStatus struct {
-	Code string `json:"code"`
-	Text string `json:"text"`
-	Type string `json:"type"`
-}
-
-type ResponseObject struct {
-	Type    string `json:"type"`
-	Value   string `json:"value"`
-	Summary int32  `json:"summary"`
-	Data    string
-}
-
-type DataZoneResponse struct {
-	STID     string             `json:"stid"`
-	CTID     string             `json:"ctid"`
-	Messages []*ResponseMessage `json:"messages"`
-	Status   *ResponseStatus    `json:"status"`
-	Object   interface{}        `json:"object"`
-	Data     []*Zone            `json:"data"`
-}
-
-// ResourceRecord holds a resource record.
-type ResourceRecord struct {
-	Name  string `json:"name"`
-	TTL   int64  `json:"ttl"`
-	Type  string `json:"type"`
-	Value string `json:"value"`
-	Pref  int32  `json:"pref,omitempty"`
-}
-
-// Zone is an autodns zone record with all for us relevant fields.
-type Zone struct {
-	Name              string            `json:"origin"`
-	ResourceRecords   []*ResourceRecord `json:"resourceRecords"`
-	Action            string            `json:"action"`
-	VirtualNameServer string            `json:"virtualNameServer"`
-}
-
-type ZoneStream struct {
-	Adds    []*ResourceRecord `json:"adds"`
-	Removes []*ResourceRecord `json:"rems"`
-}
-
-func (d *DNSProvider) addTxtRecord(domain string, records []*ResourceRecord) (*Zone, error) {
-	zoneStream := &ZoneStream{Adds: records}
-
-	return d.makeZoneUpdateRequest(zoneStream, domain)
-}
-
-func (d *DNSProvider) removeTXTRecord(domain string, records []*ResourceRecord) error {
-	zoneStream := &ZoneStream{Removes: records}
-
-	_, err := d.makeZoneUpdateRequest(zoneStream, domain)
-	return err
-}
-
-func (d *DNSProvider) makeZoneUpdateRequest(zoneStream *ZoneStream, domain string) (*Zone, error) {
-	reqBody := &bytes.Buffer{}
-	if err := json.NewEncoder(reqBody).Encode(zoneStream); err != nil {
-		return nil, err
-	}
-
-	endpoint := d.config.Endpoint.JoinPath("zone", domain, "_stream")
-
-	req, err := d.makeRequest(http.MethodPost, endpoint.String(), reqBody)
-	if err != nil {
-		return nil, err
-	}
-
-	var resp *Zone
-	if err := d.sendRequest(req, &resp); err != nil {
-		return nil, err
-	}
-	return resp, nil
-}
-
-func (d *DNSProvider) makeRequest(method, endpoint string, body io.Reader) (*http.Request, error) {
-	req, err := http.NewRequest(method, endpoint, body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("X-Domainrobot-Context", strconv.Itoa(d.config.Context))
-	req.SetBasicAuth(d.config.Username, d.config.Password)
-
-	return req, nil
-}
-
-func (d *DNSProvider) sendRequest(req *http.Request, result interface{}) error {
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-
-	if err = checkResponse(resp); err != nil {
-		return err
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	if result == nil {
-		return nil
-	}
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-
-	err = json.Unmarshal(raw, result)
-	if err != nil {
-		return fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", result, resp.StatusCode, err, string(raw))
-	}
-	return err
-}
-
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode < http.StatusBadRequest {
-		return nil
-	}
-
-	if resp.Body == nil {
-		return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode)
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err)
-	}
-
-	return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw))
-}
diff --git a/providers/dns/autodns/internal/client.go b/providers/dns/autodns/internal/client.go
new file mode 100644
index 00000000..363250d0
--- /dev/null
+++ b/providers/dns/autodns/internal/client.go
@@ -0,0 +1,132 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// DefaultEndpoint default API endpoint.
+const DefaultEndpoint = "https://api.autodns.com/v1/"
+
+// DefaultEndpointContext default API endpoint context.
+const DefaultEndpointContext int = 4
+
+// Client the Autodns API client.
+type Client struct {
+	username string
+	password string
+	context  int
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(username string, password string, clientContext int) *Client {
+	baseURL, _ := url.Parse(DefaultEndpoint)
+
+	return &Client{
+		username:   username,
+		password:   password,
+		context:    clientContext,
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// AddTxtRecords adds TXT records.
+func (c *Client) AddTxtRecords(ctx context.Context, domain string, records []*ResourceRecord) (*Zone, error) {
+	zoneStream := &ZoneStream{Adds: records}
+
+	return c.updateZone(ctx, domain, zoneStream)
+}
+
+// RemoveTXTRecords removes TXT records.
+func (c *Client) RemoveTXTRecords(ctx context.Context, domain string, records []*ResourceRecord) error {
+	zoneStream := &ZoneStream{Removes: records}
+
+	_, err := c.updateZone(ctx, domain, zoneStream)
+	return err
+}
+
+// https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L21090
+func (c *Client) updateZone(ctx context.Context, domain string, zoneStream *ZoneStream) (*Zone, error) {
+	endpoint := c.BaseURL.JoinPath("zone", domain, "_stream")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, zoneStream)
+	if err != nil {
+		return nil, err
+	}
+
+	var zone *Zone
+	if err := c.do(req, &zone); err != nil {
+		return nil, err
+	}
+
+	return zone, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	req.Header.Set("X-Domainrobot-Context", strconv.Itoa(c.context))
+	req.SetBasicAuth(c.username, c.password)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/autodns/internal/client_test.go b/providers/dns/autodns/internal/client_test.go
new file mode 100644
index 00000000..f8743b24
--- /dev/null
+++ b/providers/dns/autodns/internal/client_test.go
@@ -0,0 +1,96 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, method, pattern string, status int, file string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		apiUser, apiKey, ok := req.BasicAuth()
+		if apiUser != "user" || apiKey != "secret" || !ok {
+			http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	client := NewClient("user", "secret", 123)
+	client.HTTPClient = server.Client()
+	client.BaseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func TestClient_AddTxtRecords(t *testing.T) {
+	client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json")
+
+	records := []*ResourceRecord{{}}
+
+	zone, err := client.AddTxtRecords(context.Background(), "example.com", records)
+	require.NoError(t, err)
+
+	expected := &Zone{
+		Name: "example.com",
+		ResourceRecords: []*ResourceRecord{{
+			Name:  "example.com",
+			TTL:   120,
+			Type:  "TXT",
+			Value: "txt",
+			Pref:  1,
+		}},
+		Action:            "xxx",
+		VirtualNameServer: "yyy",
+	}
+
+	assert.Equal(t, expected, zone)
+}
+
+func TestClient_RemoveTXTRecords(t *testing.T) {
+	client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json")
+
+	records := []*ResourceRecord{{}}
+
+	err := client.RemoveTXTRecords(context.Background(), "example.com", records)
+	require.NoError(t, err)
+}
diff --git a/providers/dns/autodns/internal/fixtures/add-record.json b/providers/dns/autodns/internal/fixtures/add-record.json
new file mode 100644
index 00000000..4a95f078
--- /dev/null
+++ b/providers/dns/autodns/internal/fixtures/add-record.json
@@ -0,0 +1,14 @@
+{
+  "origin": "example.com",
+  "resourceRecords": [
+    {
+      "name": "example.com",
+      "ttl": 120,
+      "type": "TXT",
+      "value": "txt",
+      "pref": 1
+    }
+  ],
+  "action": "xxx",
+  "virtualNameServer": "yyy"
+}
diff --git a/providers/dns/autodns/internal/fixtures/remove-record.json b/providers/dns/autodns/internal/fixtures/remove-record.json
new file mode 100644
index 00000000..4a95f078
--- /dev/null
+++ b/providers/dns/autodns/internal/fixtures/remove-record.json
@@ -0,0 +1,14 @@
+{
+  "origin": "example.com",
+  "resourceRecords": [
+    {
+      "name": "example.com",
+      "ttl": 120,
+      "type": "TXT",
+      "value": "txt",
+      "pref": 1
+    }
+  ],
+  "action": "xxx",
+  "virtualNameServer": "yyy"
+}
diff --git a/providers/dns/autodns/internal/types.go b/providers/dns/autodns/internal/types.go
new file mode 100644
index 00000000..93fd678c
--- /dev/null
+++ b/providers/dns/autodns/internal/types.go
@@ -0,0 +1,57 @@
+package internal
+
+type ResponseMessage struct {
+	Text     string   `json:"text"`
+	Messages []string `json:"messages"`
+	Objects  []string `json:"objects"`
+	Code     string   `json:"code"`
+	Status   string   `json:"status"`
+}
+
+type ResponseStatus struct {
+	Code string `json:"code"`
+	Text string `json:"text"`
+	Type string `json:"type"`
+}
+
+type ResponseObject struct {
+	Type    string `json:"type"`
+	Value   string `json:"value"`
+	Summary int32  `json:"summary"`
+	Data    string
+}
+
+type DataZoneResponse struct {
+	STID     string             `json:"stid"`
+	CTID     string             `json:"ctid"`
+	Messages []*ResponseMessage `json:"messages"`
+	Status   *ResponseStatus    `json:"status"`
+	Object   any                `json:"object"`
+	Data     []*Zone            `json:"data"`
+}
+
+// ResourceRecord holds a resource record.
+// https://help.internetx.com/display/APIXMLEN/Resource+Record+Object
+type ResourceRecord struct {
+	Name  string `json:"name"`
+	TTL   int64  `json:"ttl"`
+	Type  string `json:"type"`
+	Value string `json:"value"`
+	Pref  int32  `json:"pref,omitempty"`
+}
+
+// Zone is an autodns zone record with all for us relevant fields.
+// https://help.internetx.com/display/APIXMLEN/Zone+Object
+type Zone struct {
+	Name              string            `json:"origin"`
+	ResourceRecords   []*ResourceRecord `json:"resourceRecords"`
+	Action            string            `json:"action"`
+	VirtualNameServer string            `json:"virtualNameServer"`
+}
+
+// ZoneStream body of the requests.
+// https://github.com/InterNetX/domainrobot-api/blob/bdc8fe92a2f32fcbdb29e30bf6006ab446f81223/src/domainrobot.json#L35914-L35932
+type ZoneStream struct {
+	Adds    []*ResourceRecord `json:"adds"`
+	Removes []*ResourceRecord `json:"rems"`
+}
diff --git a/providers/dns/azure/azure.go b/providers/dns/azure/azure.go
index 8ace21fb..4b693efe 100644
--- a/providers/dns/azure/azure.go
+++ b/providers/dns/azure/azure.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/Azure/go-autorest/autorest"
@@ -14,6 +15,7 @@ import (
 	"github.com/Azure/go-autorest/autorest/azure/auth"
 	"github.com/go-acme/lego/v4/challenge"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultMetadataEndpoint = "http://169.254.169.254"
@@ -122,7 +124,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	}
 
 	if config.HTTPClient == nil {
-		config.HTTPClient = http.DefaultClient
+		config.HTTPClient = &http.Client{Timeout: 5 * time.Second}
 	}
 
 	authorizer, err := getAuthorizer(config)
@@ -208,8 +210,12 @@ func getMetadata(config *Config, field string) (string, error) {
 		metadataEndpoint = defaultMetadataEndpoint
 	}
 
-	resource := fmt.Sprintf("%s/metadata/instance/compute/%s", metadataEndpoint, field)
-	req, err := http.NewRequest(http.MethodGet, resource, nil)
+	endpoint, err := url.JoinPath(metadataEndpoint, "metadata", "instance", "compute", field)
+	if err != nil {
+		return "", err
+	}
+
+	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
 	if err != nil {
 		return "", err
 	}
@@ -223,14 +229,15 @@ func getMetadata(config *Config, field string) (string, error) {
 
 	resp, err := config.HTTPClient.Do(req)
 	if err != nil {
-		return "", err
+		return "", errutils.NewHTTPDoError(req, err)
 	}
-	defer resp.Body.Close()
 
-	respBody, err := io.ReadAll(resp.Body)
+	defer func() { _ = resp.Body.Close() }()
+
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return "", err
+		return "", errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	return string(respBody), nil
+	return string(raw), nil
 }
diff --git a/providers/dns/azure/private.go b/providers/dns/azure/private.go
index 3994bf20..6f1aa822 100644
--- a/providers/dns/azure/private.go
+++ b/providers/dns/azure/private.go
@@ -118,7 +118,7 @@ func (d *dnsProviderPrivate) getHostedZoneID(ctx context.Context, fqdn string) (
 
 	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	dc := privatedns.NewPrivateZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID)
diff --git a/providers/dns/azure/public.go b/providers/dns/azure/public.go
index 4f3c1ff9..aca6869b 100644
--- a/providers/dns/azure/public.go
+++ b/providers/dns/azure/public.go
@@ -118,7 +118,7 @@ func (d *dnsProviderPublic) getHostedZoneID(ctx context.Context, fqdn string) (s
 
 	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	dc := dns.NewZonesClientWithBaseURI(d.config.ResourceManagerEndpoint, d.config.SubscriptionID)
diff --git a/providers/dns/bluecat/bluecat.go b/providers/dns/bluecat/bluecat.go
index 3e14d309..58ac2147 100644
--- a/providers/dns/bluecat/bluecat.go
+++ b/providers/dns/bluecat/bluecat.go
@@ -2,6 +2,7 @@
 package bluecat
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -97,7 +98,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("bluecat: credentials missing")
 	}
 
-	client := internal.NewClient(config.BaseURL)
+	client := internal.NewClient(config.BaseURL, config.UserName, config.Password)
 
 	if config.HTTPClient != nil {
 		client.HTTPClient = config.HTTPClient
@@ -112,17 +113,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err := d.client.Login(d.config.UserName, d.config.Password)
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
 		return fmt.Errorf("bluecat: login: %w", err)
 	}
 
-	viewID, err := d.client.LookupViewID(d.config.ConfigName, d.config.DNSView)
+	viewID, err := d.client.LookupViewID(ctx, d.config.ConfigName, d.config.DNSView)
 	if err != nil {
 		return fmt.Errorf("bluecat: lookupViewID: %w", err)
 	}
 
-	parentZoneID, name, err := d.client.LookupParentZoneID(viewID, info.EffectiveFQDN)
+	parentZoneID, name, err := d.client.LookupParentZoneID(ctx, viewID, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("bluecat: lookupParentZoneID: %w", err)
 	}
@@ -137,17 +138,17 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", d.config.TTL, info.EffectiveFQDN, info.Value),
 	}
 
-	_, err = d.client.AddEntity(parentZoneID, txtRecord)
+	_, err = d.client.AddEntity(ctx, parentZoneID, txtRecord)
 	if err != nil {
 		return fmt.Errorf("bluecat: add TXT record: %w", err)
 	}
 
-	err = d.client.Deploy(parentZoneID)
+	err = d.client.Deploy(ctx, parentZoneID)
 	if err != nil {
 		return fmt.Errorf("bluecat: deploy: %w", err)
 	}
 
-	err = d.client.Logout()
+	err = d.client.Logout(ctx)
 	if err != nil {
 		return fmt.Errorf("bluecat: logout: %w", err)
 	}
@@ -159,37 +160,37 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err := d.client.Login(d.config.UserName, d.config.Password)
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
 		return fmt.Errorf("bluecat: login: %w", err)
 	}
 
-	viewID, err := d.client.LookupViewID(d.config.ConfigName, d.config.DNSView)
+	viewID, err := d.client.LookupViewID(ctx, d.config.ConfigName, d.config.DNSView)
 	if err != nil {
 		return fmt.Errorf("bluecat: lookupViewID: %w", err)
 	}
 
-	parentZoneID, name, err := d.client.LookupParentZoneID(viewID, info.EffectiveFQDN)
+	parentZoneID, name, err := d.client.LookupParentZoneID(ctx, viewID, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("bluecat: lookupParentZoneID: %w", err)
 	}
 
-	txtRecord, err := d.client.GetEntityByName(parentZoneID, name, internal.TXTType)
+	txtRecord, err := d.client.GetEntityByName(ctx, parentZoneID, name, internal.TXTType)
 	if err != nil {
 		return fmt.Errorf("bluecat: get TXT record: %w", err)
 	}
 
-	err = d.client.Delete(txtRecord.ID)
+	err = d.client.Delete(ctx, txtRecord.ID)
 	if err != nil {
 		return fmt.Errorf("bluecat: delete TXT record: %w", err)
 	}
 
-	err = d.client.Deploy(parentZoneID)
+	err = d.client.Deploy(ctx, parentZoneID)
 	if err != nil {
 		return fmt.Errorf("bluecat: deploy: %w", err)
 	}
 
-	err = d.client.Logout()
+	err = d.client.Logout(ctx)
 	if err != nil {
 		return fmt.Errorf("bluecat: logout: %w", err)
 	}
diff --git a/providers/dns/bluecat/internal/client.go b/providers/dns/bluecat/internal/client.go
index bb61f9da..e6451343 100644
--- a/providers/dns/bluecat/internal/client.go
+++ b/providers/dns/bluecat/internal/client.go
@@ -2,14 +2,18 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 	"regexp"
 	"strconv"
 	"strings"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // Object types.
@@ -20,153 +24,88 @@ const (
 	TXTType    = "TXTRecord"
 )
 
+const authorizationHeader = "Authorization"
+
 type Client struct {
-	HTTPClient *http.Client
+	username string
+	password string
 
-	baseURL string
-
-	token    string
 	tokenExp *regexp.Regexp
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
-func NewClient(baseURL string) *Client {
+func NewClient(baseURL string, username, password string) *Client {
+	bu, _ := url.Parse(baseURL)
+
 	return &Client{
-		HTTPClient: &http.Client{Timeout: 30 * time.Second},
-		baseURL:    baseURL,
+		username:   username,
+		password:   password,
 		tokenExp:   regexp.MustCompile("BAMAuthToken: [^ ]+"),
+		baseURL:    bu,
+		HTTPClient: &http.Client{Timeout: 30 * time.Second},
 	}
 }
 
-// Login Logs in as API user.
-// Authenticates and receives a token to be used in for subsequent requests.
-// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/login/9.1.0
-func (c *Client) Login(username, password string) error {
-	queryArgs := map[string]string{
-		"username": username,
-		"password": password,
-	}
-
-	resp, err := c.sendRequest(http.MethodGet, "login", nil, queryArgs)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return &APIError{
-			StatusCode: resp.StatusCode,
-			Resource:   "login",
-			Message:    string(data),
-		}
-	}
-
-	authBytes, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-
-	authResp := string(authBytes)
-	if strings.Contains(authResp, "Authentication Error") {
-		return fmt.Errorf("request failed: %s", strings.Trim(authResp, `"`))
-	}
-
-	// Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username"
-	c.token = c.tokenExp.FindString(authResp)
-
-	return nil
-}
-
-// Logout Logs out of the current API session.
-// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/logout/9.1.0
-func (c *Client) Logout() error {
-	if c.token == "" {
-		// nothing to do
-		return nil
-	}
-
-	resp, err := c.sendRequest(http.MethodGet, "logout", nil, nil)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return &APIError{
-			StatusCode: resp.StatusCode,
-			Resource:   "logout",
-			Message:    string(data),
-		}
-	}
-
-	authBytes, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-
-	authResp := string(authBytes)
-	if !strings.Contains(authResp, "successfully") {
-		return fmt.Errorf("request failed to delete session: %s", strings.Trim(authResp, `"`))
-	}
-
-	c.token = ""
-
-	return nil
-}
-
 // Deploy the DNS config for the specified entity to the authoritative servers.
-// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/quickDeploy/9.1.0
-func (c *Client) Deploy(entityID uint) error {
-	queryArgs := map[string]string{
-		"entityId": strconv.FormatUint(uint64(entityID), 10),
-	}
+// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/quickDeploy/9.5.0
+func (c *Client) Deploy(ctx context.Context, entityID uint) error {
+	endpoint := c.createEndpoint("quickDeploy")
 
-	resp, err := c.sendRequest(http.MethodPost, "quickDeploy", nil, queryArgs)
+	q := endpoint.Query()
+	q.Set("entityId", strconv.FormatUint(uint64(entityID), 10))
+	endpoint.RawQuery = q.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil)
 	if err != nil {
 		return err
 	}
-	defer resp.Body.Close()
+
+	resp, err := c.doAuthenticated(ctx, req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
 
 	// The API doc says that 201 is expected but in the reality 200 is return.
 	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return &APIError{
-			StatusCode: resp.StatusCode,
-			Resource:   "quickDeploy",
-			Message:    string(data),
-		}
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	return nil
 }
 
 // AddEntity A generic method for adding configurations, DNS zones, and DNS resource records.
-// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/addEntity/9.1.0
-func (c *Client) AddEntity(parentID uint, entity Entity) (uint64, error) {
-	queryArgs := map[string]string{
-		"parentId": strconv.FormatUint(uint64(parentID), 10),
-	}
+// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/addEntity/9.5.0
+func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (uint64, error) {
+	endpoint := c.createEndpoint("addEntity")
 
-	resp, err := c.sendRequest(http.MethodPost, "addEntity", entity, queryArgs)
+	q := endpoint.Query()
+	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
+	endpoint.RawQuery = q.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, entity)
 	if err != nil {
 		return 0, err
 	}
-	defer resp.Body.Close()
 
-	if resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return 0, &APIError{
-			StatusCode: resp.StatusCode,
-			Resource:   "addEntity",
-			Message:    string(data),
-		}
+	resp, err := c.doAuthenticated(ctx, req)
+	if err != nil {
+		return 0, errutils.NewHTTPDoError(req, err)
 	}
 
-	addTxtBytes, _ := io.ReadAll(resp.Body)
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return 0, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, _ := io.ReadAll(resp.Body)
 
 	// addEntity responds only with body text containing the ID of the created record
-	addTxtResp := string(addTxtBytes)
+	addTxtResp := string(raw)
 	id, err := strconv.ParseUint(addTxtResp, 10, 64)
 	if err != nil {
 		return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp)
@@ -176,73 +115,84 @@ func (c *Client) AddEntity(parentID uint, entity Entity) (uint64, error) {
 }
 
 // GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated.
-// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/getEntityById/9.1.0
-func (c *Client) GetEntityByName(parentID uint, name, objType string) (*EntityResponse, error) {
-	queryArgs := map[string]string{
-		"parentId": strconv.FormatUint(uint64(parentID), 10),
-		"name":     name,
-		"type":     objType,
-	}
+// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/getEntityById/9.5.0
+func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objType string) (*EntityResponse, error) {
+	endpoint := c.createEndpoint("getEntityByName")
 
-	resp, err := c.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs)
+	q := endpoint.Query()
+	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
+	q.Set("name", name)
+	q.Set("type", objType)
+	endpoint.RawQuery = q.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
+
+	resp, err := c.doAuthenticated(ctx, req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return nil, &APIError{
-			StatusCode: resp.StatusCode,
-			Resource:   "getEntityByName",
-			Message:    string(data),
-		}
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	var txtRec EntityResponse
-	if err = json.NewDecoder(resp.Body).Decode(&txtRec); err != nil {
-		return nil, fmt.Errorf("JSON decode: %w", err)
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	return &txtRec, nil
+	var entity EntityResponse
+	err = json.Unmarshal(raw, &entity)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return &entity, nil
 }
 
 // Delete Deletes an object using the generic delete method.
-// https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/DELETE/v1/delete/9.1.0
-func (c *Client) Delete(objectID uint) error {
-	queryArgs := map[string]string{
-		"objectId": strconv.FormatUint(uint64(objectID), 10),
-	}
+// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/DELETE/v1/delete/9.5.0
+func (c *Client) Delete(ctx context.Context, objectID uint) error {
+	endpoint := c.createEndpoint("delete")
 
-	resp, err := c.sendRequest(http.MethodDelete, "delete", nil, queryArgs)
+	q := endpoint.Query()
+	q.Set("objectId", strconv.FormatUint(uint64(objectID), 10))
+	endpoint.RawQuery = q.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
 
-	defer resp.Body.Close()
+	resp, err := c.doAuthenticated(ctx, req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
 
-	// The API doc says that 204 is expected but in the reality 200 is return.
+	defer func() { _ = resp.Body.Close() }()
+
+	// The API doc says that 204 is expected but in the reality 200 is returned.
 	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-		return &APIError{
-			StatusCode: resp.StatusCode,
-			Resource:   "delete",
-			Message:    string(data),
-		}
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	return nil
 }
 
 // LookupViewID Find the DNS view with the given name within.
-func (c *Client) LookupViewID(configName, viewName string) (uint, error) {
+func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) (uint, error) {
 	// Lookup the entity ID of the configuration named in our properties.
-	conf, err := c.GetEntityByName(0, configName, ConfigType)
+	conf, err := c.GetEntityByName(ctx, 0, configName, ConfigType)
 	if err != nil {
 		return 0, err
 	}
 
-	view, err := c.GetEntityByName(conf.ID, viewName, ViewType)
+	view, err := c.GetEntityByName(ctx, conf.ID, viewName, ViewType)
 	if err != nil {
 		return 0, err
 	}
@@ -252,7 +202,7 @@ func (c *Client) LookupViewID(configName, viewName string) (uint, error) {
 
 // LookupParentZoneID Return the entityId of the parent zone by recursing from the root view.
 // Also return the simple name of the host.
-func (c *Client) LookupParentZoneID(viewID uint, fqdn string) (uint, string, error) {
+func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) {
 	if fqdn == "" {
 		return viewID, "", nil
 	}
@@ -263,7 +213,7 @@ func (c *Client) LookupParentZoneID(viewID uint, fqdn string) (uint, string, err
 	parentViewID := viewID
 
 	for i := len(zones) - 1; i > -1; i-- {
-		zone, err := c.GetEntityByName(parentViewID, zones[i], ZoneType)
+		zone, err := c.GetEntityByName(ctx, parentViewID, zones[i], ZoneType)
 		if err != nil {
 			return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err)
 		}
@@ -282,32 +232,39 @@ func (c *Client) LookupParentZoneID(viewID uint, fqdn string) (uint, string, err
 	return parentViewID, name, nil
 }
 
-// Send a REST request, using query parameters specified.
-// The Authorization header will be set if we have an active auth token.
-func (c *Client) sendRequest(method, resource string, payload interface{}, queryParams map[string]string) (*http.Response, error) {
-	url := fmt.Sprintf("%s/Services/REST/v1/%s", c.baseURL, resource)
+func (c *Client) createEndpoint(resource string) *url.URL {
+	return c.baseURL.JoinPath("Services", "REST", "v1", resource)
+}
 
-	body, err := json.Marshal(payload)
-	if err != nil {
-		return nil, err
+func (c *Client) doAuthenticated(ctx context.Context, req *http.Request) (*http.Response, error) {
+	tok := getToken(ctx)
+	if tok != "" {
+		req.Header.Set(authorizationHeader, tok)
 	}
 
-	req, err := http.NewRequest(method, url, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-
-	if c.token != "" {
-		req.Header.Set("Authorization", c.token)
-	}
-
-	q := req.URL.Query()
-	for k, v := range queryParams {
-		q.Set(k, v)
-	}
-	req.URL.RawQuery = q.Encode()
-
 	return c.HTTPClient.Do(req)
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/bluecat/internal/client_test.go b/providers/dns/bluecat/internal/client_test.go
index 072f6254..206d7d1a 100644
--- a/providers/dns/bluecat/internal/client_test.go
+++ b/providers/dns/bluecat/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"net/http"
 	"net/http/httptest"
@@ -15,7 +16,8 @@ func TestClient_LookupParentZoneID(t *testing.T) {
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	client := NewClient(server.URL)
+	client := NewClient(server.URL, "user", "secret")
+	client.HTTPClient = server.Client()
 
 	mux.HandleFunc("/Services/REST/v1/getEntityByName", func(rw http.ResponseWriter, req *http.Request) {
 		query := req.URL.Query()
@@ -33,7 +35,7 @@ func TestClient_LookupParentZoneID(t *testing.T) {
 		http.Error(rw, "{}", http.StatusOK)
 	})
 
-	parentID, name, err := client.LookupParentZoneID(2, "foo.example.com")
+	parentID, name, err := client.LookupParentZoneID(context.Background(), 2, "foo.example.com")
 	require.NoError(t, err)
 
 	assert.EqualValues(t, 2, parentID)
diff --git a/providers/dns/bluecat/internal/identity.go b/providers/dns/bluecat/internal/identity.go
new file mode 100644
index 00000000..425e9cd8
--- /dev/null
+++ b/providers/dns/bluecat/internal/identity.go
@@ -0,0 +1,115 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+type token string
+
+const tokenKey token = "token"
+
+// login Logs in as API user.
+// Authenticates and receives a token to be used in for subsequent requests.
+// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/login/9.5.0
+func (c *Client) login(ctx context.Context) (string, error) {
+	endpoint := c.createEndpoint("login")
+
+	q := endpoint.Query()
+	q.Set("username", c.username)
+	q.Set("password", c.password)
+	endpoint.RawQuery = q.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return "", err
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return "", errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	authResp := string(raw)
+	if strings.Contains(authResp, "Authentication Error") {
+		return "", fmt.Errorf("request failed: %s", strings.Trim(authResp, `"`))
+	}
+
+	// Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username"
+	tok := c.tokenExp.FindString(authResp)
+
+	return tok, nil
+}
+
+// Logout Logs out of the current API session.
+// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/logout/9.5.0
+func (c *Client) Logout(ctx context.Context) error {
+	if getToken(ctx) == "" {
+		// nothing to do
+		return nil
+	}
+
+	endpoint := c.createEndpoint("logout")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	resp, err := c.doAuthenticated(ctx, req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	authResp := string(raw)
+	if !strings.Contains(authResp, "successfully") {
+		return fmt.Errorf("request failed to delete session: %s", strings.Trim(authResp, `"`))
+	}
+
+	return nil
+}
+
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+	tok, err := c.login(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return context.WithValue(ctx, tokenKey, tok), nil
+}
+
+func getToken(ctx context.Context) string {
+	tok, ok := ctx.Value(tokenKey).(string)
+	if !ok {
+		return ""
+	}
+
+	return tok
+}
diff --git a/providers/dns/bluecat/internal/identity_test.go b/providers/dns/bluecat/internal/identity_test.go
new file mode 100644
index 00000000..378f6ab3
--- /dev/null
+++ b/providers/dns/bluecat/internal/identity_test.go
@@ -0,0 +1,59 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const fakeToken = "BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM="
+
+func TestClient_CreateAuthenticatedContext(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	client := NewClient(server.URL, "user", "secret")
+	client.HTTPClient = server.Client()
+
+	mux.HandleFunc("/Services/REST/v1/login", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodGet {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		query := req.URL.Query()
+		if query.Get("username") != "user" {
+			http.Error(rw, fmt.Sprintf("invalid username %s", query.Get("username")), http.StatusUnauthorized)
+			return
+		}
+
+		if query.Get("password") != "secret" {
+			http.Error(rw, fmt.Sprintf("invalid password %s", query.Get("password")), http.StatusUnauthorized)
+			return
+		}
+
+		_, _ = fmt.Fprint(rw, fakeToken)
+	})
+	mux.HandleFunc("/Services/REST/v1/delete", func(rw http.ResponseWriter, req *http.Request) {
+		authorization := req.Header.Get(authorizationHeader)
+		if authorization != fakeToken {
+			http.Error(rw, fmt.Sprintf("invalid credential: %s", authorization), http.StatusUnauthorized)
+			return
+		}
+	})
+
+	ctx, err := client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
+
+	at := getToken(ctx)
+	assert.Equal(t, fakeToken, at)
+
+	err = client.Delete(ctx, 123)
+	require.NoError(t, err)
+}
diff --git a/providers/dns/bluecat/internal/types.go b/providers/dns/bluecat/internal/types.go
index b3b7b412..5f1bf772 100644
--- a/providers/dns/bluecat/internal/types.go
+++ b/providers/dns/bluecat/internal/types.go
@@ -1,7 +1,5 @@
 package internal
 
-import "fmt"
-
 // Entity JSON body for Bluecat entity requests.
 type Entity struct {
 	ID         string `json:"id,omitempty"`
@@ -17,13 +15,3 @@ type EntityResponse struct {
 	Type       string `json:"type"`
 	Properties string `json:"properties"`
 }
-
-type APIError struct {
-	StatusCode int
-	Resource   string
-	Message    string
-}
-
-func (a APIError) Error() string {
-	return fmt.Sprintf("resource: %s, status code: %d, message: %s", a.Resource, a.StatusCode, a.Message)
-}
diff --git a/providers/dns/brandit/brandit.go b/providers/dns/brandit/brandit.go
index 0b80f490..33af186c 100644
--- a/providers/dns/brandit/brandit.go
+++ b/providers/dns/brandit/brandit.go
@@ -1,9 +1,11 @@
 package brandit
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"strconv"
 	"sync"
 	"time"
 
@@ -12,8 +14,6 @@ import (
 	"github.com/go-acme/lego/v4/providers/dns/brandit/internal"
 )
 
-const defaultTTL = 600
-
 // Environment variables names.
 const (
 	envNamespace = "BRANDIT_"
@@ -21,11 +21,10 @@ const (
 	EnvAPIKey      = envNamespace + "API_KEY"
 	EnvAPIUsername = envNamespace + "API_USERNAME"
 
-	EnvTTL                           = envNamespace + "TTL"
-	EnvPropagationTimeout            = envNamespace + "PROPAGATION_TIMEOUT"
-	EnvPollingInterval               = envNamespace + "POLLING_INTERVAL"
-	EnvHTTPTimeout                   = envNamespace + "HTTP_TIMEOUT"
-	DefaultBrandItPropagationTimeout = 600 * time.Second
+	EnvTTL                = envNamespace + "TTL"
+	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
+	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
 )
 
 // Config is used to configure the creation of the DNSProvider.
@@ -42,8 +41,8 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
-		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, DefaultBrandItPropagationTimeout),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 600),
+		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
 		HTTPClient: &http.Client{
 			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
@@ -97,13 +96,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	}, nil
 }
 
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+	return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
 // Present creates a TXT record using the specified parameters.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("brandit: %w", err)
+		return fmt.Errorf("brandit: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -111,6 +116,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("brandit: %w", err)
 	}
 
+	ctx := context.Background()
+
 	record := internal.Record{
 		Type:    "TXT",
 		Name:    subDomain,
@@ -119,18 +126,18 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	}
 
 	// find the account associated with the domain
-	account, err := d.client.StatusDomain(dns01.UnFqdn(authZone))
+	account, err := d.client.StatusDomain(ctx, dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("brandit: status domain: %w", err)
 	}
 
 	// Find the next record id
-	recordID, err := d.client.ListRecords(account.Response.Registrar[0], dns01.UnFqdn(authZone))
+	recordID, err := d.client.ListRecords(ctx, account.Registrar[0], dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("brandit: list records: %w", err)
 	}
 
-	result, err := d.client.AddRecord(dns01.UnFqdn(authZone), account.Response.Registrar[0], fmt.Sprint(recordID.Response.Total[0]), record)
+	result, err := d.client.AddRecord(ctx, dns01.UnFqdn(authZone), account.Registrar[0], strconv.Itoa(recordID.Total[0]), record)
 	if err != nil {
 		return fmt.Errorf("brandit: add record: %w", err)
 	}
@@ -148,7 +155,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("brandit: %w", err)
+		return fmt.Errorf("brandit: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// gets the record's unique ID
@@ -159,25 +166,27 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("brandit: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
+	ctx := context.Background()
+
 	// find the account associated with the domain
-	account, err := d.client.StatusDomain(dns01.UnFqdn(authZone))
+	account, err := d.client.StatusDomain(ctx, dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("brandit: status domain: %w", err)
 	}
 
-	records, err := d.client.ListRecords(account.Response.Registrar[0], dns01.UnFqdn(authZone))
+	records, err := d.client.ListRecords(ctx, account.Registrar[0], dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("brandit: list records: %w", err)
 	}
 
 	var recordID int
-	for i, r := range records.Response.RR {
+	for i, r := range records.RR {
 		if r == dnsRecord {
 			recordID = i
 		}
 	}
 
-	_, err = d.client.DeleteRecord(dns01.UnFqdn(authZone), account.Response.Registrar[0], dnsRecord, fmt.Sprint(recordID))
+	err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), account.Registrar[0], dnsRecord, strconv.Itoa(recordID))
 	if err != nil {
 		return fmt.Errorf("brandit: delete record: %w", err)
 	}
@@ -189,9 +198,3 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	return nil
 }
-
-// Timeout returns the timeout and interval to use when checking for DNS propagation.
-// Adjusting here to cope with spikes in propagation times.
-func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
-	return d.config.PropagationTimeout, d.config.PollingInterval
-}
diff --git a/providers/dns/brandit/brandit.toml b/providers/dns/brandit/brandit.toml
index 07346c2b..acf61bd7 100644
--- a/providers/dns/brandit/brandit.toml
+++ b/providers/dns/brandit/brandit.toml
@@ -1,4 +1,4 @@
-Name = "BRANDIT"
+Name = "Brandit"
 Description = ''''''
 URL = "https://www.brandit.com/"
 Code = "brandit"
diff --git a/providers/dns/brandit/internal/client.go b/providers/dns/brandit/internal/client.go
index d145315d..12e28fdf 100644
--- a/providers/dns/brandit/internal/client.go
+++ b/providers/dns/brandit/internal/client.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"crypto/hmac"
 	"crypto/sha256"
 	"encoding/hex"
@@ -12,6 +13,8 @@ import (
 	"net/url"
 	"strings"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://portal.brandit.com/api/v3/"
@@ -20,8 +23,9 @@ const defaultBaseURL = "https://portal.brandit.com/api/v3/"
 type Client struct {
 	apiUsername string
 	apiKey      string
-	BaseURL     string
-	HTTPClient  *http.Client
+
+	baseURL    string
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new Client.
@@ -33,70 +37,69 @@ func NewClient(apiUsername, apiKey string) (*Client, error) {
 	return &Client{
 		apiUsername: apiUsername,
 		apiKey:      apiKey,
-		BaseURL:     defaultBaseURL,
+		baseURL:     defaultBaseURL,
 		HTTPClient:  &http.Client{Timeout: 10 * time.Second},
 	}, nil
 }
 
 // ListRecords lists all records.
 // https://portal.brandit.com/apidocv3#listDNSRR
-func (c *Client) ListRecords(account, dnsZone string) (*ListRecords, error) {
-	// Create a new query
+func (c *Client) ListRecords(ctx context.Context, account, dnsZone string) (*ListRecordsResponse, error) {
 	query := url.Values{}
 	query.Add("command", "listDNSRR")
 	query.Add("account", account)
 	query.Add("dnszone", dnsZone)
 
-	result := &ListRecords{}
+	result := &Response[*ListRecordsResponse]{}
 
-	err := c.do(query, result)
+	err := c.do(ctx, query, result)
 	if err != nil {
-		return nil, fmt.Errorf("do: %w", err)
+		return nil, err
 	}
 
 	for len(result.Response.RR) < result.Response.Total[0] {
 		query.Add("first", fmt.Sprint(result.Response.Last[0]+1))
 
-		tmp := &ListRecords{}
-		err := c.do(query, tmp)
+		tmp := &Response[*ListRecordsResponse]{}
+		err := c.do(ctx, query, tmp)
 		if err != nil {
-			return nil, fmt.Errorf("do: %w", err)
+			return nil, err
 		}
 
 		result.Response.RR = append(result.Response.RR, tmp.Response.RR...)
 		result.Response.Last = tmp.Response.Last
 	}
 
-	return result, nil
+	return result.Response, nil
 }
 
 // AddRecord adds a DNS record.
 // https://portal.brandit.com/apidocv3#addDNSRR
-func (c *Client) AddRecord(domainName, account, newRecordID string, record Record) (*AddRecord, error) {
-	// Create a new query
+func (c *Client) AddRecord(ctx context.Context, domainName, account, newRecordID string, record Record) (*AddRecord, error) {
+	value := strings.Join([]string{record.Name, fmt.Sprint(record.TTL), "IN", record.Type, record.Content}, " ")
 
 	query := url.Values{}
 	query.Add("command", "addDNSRR")
 	query.Add("account", account)
 	query.Add("dnszone", domainName)
-	query.Add("rrdata", strings.Join([]string{record.Name, fmt.Sprint(record.TTL), "IN", record.Type, record.Content}, " "))
+	query.Add("rrdata", value)
 	query.Add("key", newRecordID)
 
 	result := &AddRecord{}
 
-	err := c.do(query, result)
+	err := c.do(ctx, query, result)
 	if err != nil {
-		return nil, fmt.Errorf("do: %w", err)
+		return nil, err
 	}
-	result.Record = strings.Join([]string{record.Name, fmt.Sprint(record.TTL), "IN", record.Type, record.Content}, " ")
+
+	result.Record = value
 
 	return result, nil
 }
 
 // DeleteRecord deletes a DNS record.
 // https://portal.brandit.com/apidocv3#deleteDNSRR
-func (c *Client) DeleteRecord(domainName, account, dnsRecord, recordID string) (*DeleteRecord, error) {
-	// Create a new query
+func (c *Client) DeleteRecord(ctx context.Context, domainName, account, dnsRecord, recordID string) error {
 	query := url.Values{}
 	query.Add("command", "deleteDNSRR")
 	query.Add("account", account)
@@ -104,68 +107,70 @@ func (c *Client) DeleteRecord(domainName, account, dnsRecord, recordID string) (
 	query.Add("rrdata", dnsRecord)
 	query.Add("key", recordID)
 
-	result := &DeleteRecord{}
-
-	err := c.do(query, result)
-	if err != nil {
-		return nil, fmt.Errorf("do: %w", err)
-	}
-
-	return result, nil
+	return c.do(ctx, query, nil)
 }
 
 // StatusDomain returns the status of a domain and account associated with it.
 // https://portal.brandit.com/apidocv3#statusDomain
-func (c *Client) StatusDomain(domain string) (*StatusDomain, error) {
-	// Create a new query
+func (c *Client) StatusDomain(ctx context.Context, domain string) (*StatusResponse, error) {
 	query := url.Values{}
 
 	query.Add("command", "statusDomain")
 	query.Add("domain", domain)
 
-	result := &StatusDomain{}
+	result := &Response[*StatusResponse]{}
 
-	err := c.do(query, result)
+	err := c.do(ctx, query, result)
 	if err != nil {
-		return nil, fmt.Errorf("do: %w", err)
+		return nil, err
 	}
 
-	return result, nil
+	return result.Response, nil
 }
 
-func (c *Client) do(query url.Values, result any) error {
-	// Add signature
-	v, err := sign(c.apiUsername, c.apiKey, query)
-	if err != nil {
-		return fmt.Errorf("signature: %w", err)
-	}
-
-	resp, err := c.HTTPClient.PostForm(c.BaseURL, v)
+func (c *Client) do(ctx context.Context, query url.Values, result any) error {
+	values, err := sign(c.apiUsername, c.apiKey, query)
 	if err != nil {
 		return err
 	}
 
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(values.Encode()))
+	if err != nil {
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
 	defer func() { _ = resp.Body.Close() }()
 
 	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return fmt.Errorf("read response body: %w", err)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	//  Unmarshal the error response, because the API returns a 200 OK even if there is an error.
 	var apiError APIError
 	err = json.Unmarshal(raw, &apiError)
 	if err != nil {
-		return fmt.Errorf("unmarshal error response: %w %s", err, string(raw))
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	if apiError.Code > 299 || apiError.Status != "success" {
 		return apiError
 	}
 
+	if result == nil {
+		return nil
+	}
+
 	err = json.Unmarshal(raw, result)
 	if err != nil {
-		return fmt.Errorf("unmarshal response body: %w %s", err, string(raw))
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
diff --git a/providers/dns/brandit/internal/client_test.go b/providers/dns/brandit/internal/client_test.go
index 6e75294d..a37e51a2 100644
--- a/providers/dns/brandit/internal/client_test.go
+++ b/providers/dns/brandit/internal/client_test.go
@@ -1,30 +1,32 @@
 package internal
 
 import (
+	"context"
 	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func setupTest(t *testing.T, file string) *Client {
+func setupTest(t *testing.T, filename string) *Client {
 	t.Helper()
 
 	server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		open, err := os.Open(file)
+		file, err := os.Open(filepath.Join("fixtures", filename))
 		if err != nil {
 			http.Error(rw, err.Error(), http.StatusInternalServerError)
 			return
 		}
 
-		defer func() { _ = open.Close() }()
+		defer func() { _ = file.Close() }()
 
 		rw.WriteHeader(http.StatusOK)
-		_, err = io.Copy(rw, open)
+		_, err = io.Copy(rw, file)
 		if err != nil {
 			http.Error(rw, err.Error(), http.StatusInternalServerError)
 			return
@@ -36,78 +38,82 @@ func setupTest(t *testing.T, file string) *Client {
 	require.NoError(t, err)
 
 	client.HTTPClient = server.Client()
-	client.BaseURL = server.URL
+	client.baseURL = server.URL
 
 	return client
 }
 
 func TestClient_StatusDomain(t *testing.T) {
-	client := setupTest(t, "./fixtures/status-domain.json")
+	client := setupTest(t, "status-domain.json")
 
-	domain, err := client.StatusDomain("example.com")
+	domain, err := client.StatusDomain(context.Background(), "example.com")
 	require.NoError(t, err)
 
-	expected := &StatusDomain{
-		Response: StatusResponse{
-			RenewalMode:                []string{"DEFAULT"},
-			Status:                     []string{"clientTransferProhibited"},
-			TransferLock:               []int{1},
-			Registrar:                  []string{"brandit"},
-			PaidUntilDate:              []string{"2021-12-15 05:00:00.0"},
-			Nameserver:                 []string{"NS1.RRPPROXY.NET", "NS2.RRPPROXY.NET"},
-			RegistrationExpirationDate: []string{"2021-12-15 05:00:00.0"},
-			Domain:                     []string{"example.com"},
-			RenewalDate:                []string{"2024-01-19 05:00:00.0"},
-			UpdatedDate:                []string{"2022-12-16 08:01:27.0"},
-			BillingContact:             []string{"example"},
-			XDomainRoID:                []string{"example"},
-			AdminContact:               []string{"example"},
-			TechContact:                []string{"example"},
-			DomainIDN:                  []string{"example.com"},
-			CreatedDate:                []string{"2016-12-16 05:00:00.0"},
-			RegistrarTransferDate:      []string{"2021-12-09 05:17:42.0"},
-			Zone:                       []string{"com"},
-			Auth:                       []string{"example"},
-			UpdatedBy:                  []string{"example"},
-			RoID:                       []string{"example"},
-			OwnerContact:               []string{"example"},
-			CreatedBy:                  []string{"example"},
-			TransferMode:               []string{"auto"},
-		},
-		Code:   200,
-		Status: "success",
-		Error:  "",
+	expected := &StatusResponse{
+		RenewalMode:                []string{"DEFAULT"},
+		Status:                     []string{"clientTransferProhibited"},
+		TransferLock:               []int{1},
+		Registrar:                  []string{"brandit"},
+		PaidUntilDate:              []string{"2021-12-15 05:00:00.0"},
+		Nameserver:                 []string{"NS1.RRPPROXY.NET", "NS2.RRPPROXY.NET"},
+		RegistrationExpirationDate: []string{"2021-12-15 05:00:00.0"},
+		Domain:                     []string{"example.com"},
+		RenewalDate:                []string{"2024-01-19 05:00:00.0"},
+		UpdatedDate:                []string{"2022-12-16 08:01:27.0"},
+		BillingContact:             []string{"example"},
+		XDomainRoID:                []string{"example"},
+		AdminContact:               []string{"example"},
+		TechContact:                []string{"example"},
+		DomainIDN:                  []string{"example.com"},
+		CreatedDate:                []string{"2016-12-16 05:00:00.0"},
+		RegistrarTransferDate:      []string{"2021-12-09 05:17:42.0"},
+		Zone:                       []string{"com"},
+		Auth:                       []string{"example"},
+		UpdatedBy:                  []string{"example"},
+		RoID:                       []string{"example"},
+		OwnerContact:               []string{"example"},
+		CreatedBy:                  []string{"example"},
+		TransferMode:               []string{"auto"},
 	}
 
 	assert.Equal(t, expected, domain)
 }
 
-func TestClient_ListRecords(t *testing.T) {
-	client := setupTest(t, "./fixtures/list-records.json")
+func TestClient_StatusDomain_error(t *testing.T) {
+	client := setupTest(t, "error.json")
 
-	resp, err := client.ListRecords("example", "example.com")
+	_, err := client.StatusDomain(context.Background(), "example.com")
+	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
+}
+
+func TestClient_ListRecords(t *testing.T) {
+	client := setupTest(t, "list-records.json")
+
+	resp, err := client.ListRecords(context.Background(), "example", "example.com")
 	require.NoError(t, err)
 
-	expected := &ListRecords{
-		Response: ListRecordsResponse{
-			Limit:  []int{100},
-			Column: []string{"rr"},
-			Count:  []int{1},
-			First:  []int{0},
-			Total:  []int{1},
-			RR:     []string{"example.com. 600 IN TXT txttxttxt"},
-			Last:   []int{0},
-		},
-		Code:   200,
-		Status: "success",
-		Error:  "",
+	expected := &ListRecordsResponse{
+		Limit:  []int{100},
+		Column: []string{"rr"},
+		Count:  []int{1},
+		First:  []int{0},
+		Total:  []int{1},
+		RR:     []string{"example.com. 600 IN TXT txttxttxt"},
+		Last:   []int{0},
 	}
 
 	assert.Equal(t, expected, resp)
 }
 
+func TestClient_ListRecords_error(t *testing.T) {
+	client := setupTest(t, "error.json")
+
+	_, err := client.ListRecords(context.Background(), "example", "example.com")
+	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
+}
+
 func TestClient_AddRecord(t *testing.T) {
-	client := setupTest(t, "./fixtures/add-record.json")
+	client := setupTest(t, "add-record.json")
 
 	testRecord := Record{
 		ID:      2565,
@@ -116,7 +122,7 @@ func TestClient_AddRecord(t *testing.T) {
 		Content: "txttxttxt",
 		TTL:     600,
 	}
-	resp, err := client.AddRecord("example.com", "test", "2565", testRecord)
+	resp, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord)
 	require.NoError(t, err)
 
 	expected := &AddRecord{
@@ -133,17 +139,31 @@ func TestClient_AddRecord(t *testing.T) {
 	assert.Equal(t, expected, resp)
 }
 
-func TestClient_DeleteRecord(t *testing.T) {
-	client := setupTest(t, "./fixtures/delete-record.json")
+func TestClient_AddRecord_error(t *testing.T) {
+	client := setupTest(t, "error.json")
 
-	resp, err := client.DeleteRecord("example.com", "test", "example.com 600 IN TXT txttxttxt", "2374")
-	require.NoError(t, err)
-
-	expected := &DeleteRecord{
-		Code:   200,
-		Status: "success",
-		Error:  "",
+	testRecord := Record{
+		ID:      2565,
+		Type:    "TXT",
+		Name:    "example.com",
+		Content: "txttxttxt",
+		TTL:     600,
 	}
 
-	assert.Equal(t, expected, resp)
+	_, err := client.AddRecord(context.Background(), "example.com", "test", "2565", testRecord)
+	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+	client := setupTest(t, "delete-record.json")
+
+	err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374")
+	require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+	client := setupTest(t, "error.json")
+
+	err := client.DeleteRecord(context.Background(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374")
+	require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."})
 }
diff --git a/providers/dns/brandit/internal/fixtures/error.json b/providers/dns/brandit/internal/fixtures/error.json
new file mode 100644
index 00000000..63bc2abd
--- /dev/null
+++ b/providers/dns/brandit/internal/fixtures/error.json
@@ -0,0 +1,5 @@
+{
+  "code": 402,
+  "status": "error",
+  "error": "Invalid user."
+}
diff --git a/providers/dns/brandit/internal/types.go b/providers/dns/brandit/internal/types.go
index 099c5fe1..a0a5e50b 100644
--- a/providers/dns/brandit/internal/types.go
+++ b/providers/dns/brandit/internal/types.go
@@ -2,11 +2,11 @@ package internal
 
 import "fmt"
 
-type StatusDomain struct {
-	Response StatusResponse `json:"response,omitempty"`
-	Code     int            `json:"code"`
-	Status   string         `json:"status"`
-	Error    string         `json:"error"`
+type Response[T any] struct {
+	Response T      `json:"response,omitempty"`
+	Code     int    `json:"code"`
+	Status   string `json:"status"`
+	Error    string `json:"error"`
 }
 
 type StatusResponse struct {
@@ -36,13 +36,6 @@ type StatusResponse struct {
 	TransferMode               []string `json:"transfermode"`
 }
 
-type ListRecords struct {
-	Response ListRecordsResponse `json:"response,omitempty"`
-	Code     int                 `json:"code"`
-	Status   string              `json:"status"`
-	Error    string              `json:"error"`
-}
-
 type ListRecordsResponse struct {
 	Limit  []int    `json:"limit,omitempty"`
 	Column []string `json:"column,omitempty"`
@@ -83,9 +76,3 @@ type Record struct {
 	Content string `json:"content,omitempty"`
 	TTL     int    `json:"ttl,omitempty"` // default 600
 }
-
-type DeleteRecord struct {
-	Code   int    `json:"code"`
-	Status string `json:"status"`
-	Error  string `json:"error"`
-}
diff --git a/providers/dns/checkdomain/checkdomain.go b/providers/dns/checkdomain/checkdomain.go
index 2a9787ba..7228fe29 100644
--- a/providers/dns/checkdomain/checkdomain.go
+++ b/providers/dns/checkdomain/checkdomain.go
@@ -2,15 +2,16 @@
 package checkdomain
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
-	"sync"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/checkdomain/internal"
 )
 
 // Environment variables names.
@@ -26,11 +27,6 @@ const (
 	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
 )
 
-const (
-	defaultEndpoint = "https://api.checkdomain.de"
-	defaultTTL      = 300
-)
-
 // Config is used to configure the creation of the DNSProvider.
 type Config struct {
 	Endpoint           *url.URL
@@ -44,7 +40,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 300),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 7*time.Second),
 		HTTPClient: &http.Client{
@@ -56,9 +52,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
-
-	domainIDMu      sync.Mutex
-	domainIDMapping map[string]int
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for CheckDomain.
@@ -71,7 +65,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 	config := NewDefaultConfig()
 	config.Token = values[EnvToken]
 
-	endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, defaultEndpoint))
+	endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, internal.DefaultEndpoint))
 	if err != nil {
 		return nil, fmt.Errorf("checkdomain: invalid %s: %w", EnvEndpoint, err)
 	}
@@ -89,32 +83,33 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("checkdomain: missing token")
 	}
 
-	if config.HTTPClient == nil {
-		config.HTTPClient = http.DefaultClient
+	client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token))
+
+	if config.Endpoint != nil {
+		client.BaseURL = config.Endpoint
 	}
 
-	return &DNSProvider{
-		config:          config,
-		domainIDMapping: make(map[string]int),
-	}, nil
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record to fulfill the dns-01 challenge.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	ctx := context.Background()
+
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	domainID, err := d.getDomainIDByName(domain)
+	domainID, err := d.client.GetDomainIDByName(ctx, domain)
 	if err != nil {
 		return fmt.Errorf("checkdomain: %w", err)
 	}
 
-	err = d.checkNameservers(domainID)
+	err = d.client.CheckNameservers(ctx, domainID)
 	if err != nil {
 		return fmt.Errorf("checkdomain: %w", err)
 	}
 
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err = d.createRecord(domainID, &Record{
+	err = d.client.CreateRecord(ctx, domainID, &internal.Record{
 		Name:  info.EffectiveFQDN,
 		TTL:   d.config.TTL,
 		Type:  "TXT",
@@ -130,28 +125,28 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 // CleanUp removes the TXT record previously created.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	ctx := context.Background()
+
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	domainID, err := d.getDomainIDByName(domain)
+	domainID, err := d.client.GetDomainIDByName(ctx, domain)
 	if err != nil {
 		return fmt.Errorf("checkdomain: %w", err)
 	}
 
-	err = d.checkNameservers(domainID)
+	err = d.client.CheckNameservers(ctx, domainID)
 	if err != nil {
 		return fmt.Errorf("checkdomain: %w", err)
 	}
 
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err = d.deleteTXTRecord(domainID, info.EffectiveFQDN, info.Value)
+	defer d.client.CleanCache(info.EffectiveFQDN)
+
+	err = d.client.DeleteTXTRecord(ctx, domainID, info.EffectiveFQDN, info.Value)
 	if err != nil {
 		return fmt.Errorf("checkdomain: %w", err)
 	}
 
-	d.domainIDMu.Lock()
-	delete(d.domainIDMapping, info.EffectiveFQDN)
-	d.domainIDMu.Unlock()
-
 	return nil
 }
 
diff --git a/providers/dns/checkdomain/checkdomain_test.go b/providers/dns/checkdomain/checkdomain_test.go
index eb9f05d3..b94f9397 100644
--- a/providers/dns/checkdomain/checkdomain_test.go
+++ b/providers/dns/checkdomain/checkdomain_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/go-acme/lego/v4/providers/dns/checkdomain/internal"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -83,7 +84,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
 			config := NewDefaultConfig()
-			config.Endpoint, _ = url.Parse(defaultEndpoint)
+			config.Endpoint, _ = url.Parse(internal.DefaultEndpoint)
 
 			if test.token != "" {
 				config.Token = test.token
diff --git a/providers/dns/checkdomain/client.go b/providers/dns/checkdomain/client.go
deleted file mode 100644
index 8b401a7e..00000000
--- a/providers/dns/checkdomain/client.go
+++ /dev/null
@@ -1,416 +0,0 @@
-package checkdomain
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"strconv"
-	"strings"
-)
-
-const (
-	ns1 = "ns.checkdomain.de"
-	ns2 = "ns2.checkdomain.de"
-)
-
-const domainNotFound = -1
-
-// max page limit that the checkdomain api allows.
-const maxLimit = 100
-
-// max integer value.
-const maxInt = int((^uint(0)) >> 1)
-
-type (
-	// Some fields have been omitted from the structs
-	// because they are not required for this application.
-
-	DomainListingResponse struct {
-		Page     int                `json:"page"`
-		Limit    int                `json:"limit"`
-		Pages    int                `json:"pages"`
-		Total    int                `json:"total"`
-		Embedded EmbeddedDomainList `json:"_embedded"`
-	}
-
-	EmbeddedDomainList struct {
-		Domains []*Domain `json:"domains"`
-	}
-
-	Domain struct {
-		ID   int    `json:"id"`
-		Name string `json:"name"`
-	}
-
-	DomainResponse struct {
-		ID      int    `json:"id"`
-		Name    string `json:"name"`
-		Created string `json:"created"`
-		PaidUp  string `json:"payed_up"`
-		Active  bool   `json:"active"`
-	}
-
-	NameserverResponse struct {
-		General     NameserverGeneral `json:"general"`
-		Nameservers []*Nameserver     `json:"nameservers"`
-		SOA         NameserverSOA     `json:"soa"`
-	}
-
-	NameserverGeneral struct {
-		IPv4       string `json:"ip_v4"`
-		IPv6       string `json:"ip_v6"`
-		IncludeWWW bool   `json:"include_www"`
-	}
-
-	NameserverSOA struct {
-		Mail    string `json:"mail"`
-		Refresh int    `json:"refresh"`
-		Retry   int    `json:"retry"`
-		Expiry  int    `json:"expiry"`
-		TTL     int    `json:"ttl"`
-	}
-
-	Nameserver struct {
-		Name string `json:"name"`
-	}
-
-	RecordListingResponse struct {
-		Page     int                `json:"page"`
-		Limit    int                `json:"limit"`
-		Pages    int                `json:"pages"`
-		Total    int                `json:"total"`
-		Embedded EmbeddedRecordList `json:"_embedded"`
-	}
-
-	EmbeddedRecordList struct {
-		Records []*Record `json:"records"`
-	}
-
-	Record struct {
-		Name     string `json:"name"`
-		Value    string `json:"value"`
-		TTL      int    `json:"ttl"`
-		Priority int    `json:"priority"`
-		Type     string `json:"type"`
-	}
-)
-
-func (d *DNSProvider) getDomainIDByName(name string) (int, error) {
-	// Load from cache if exists
-	d.domainIDMu.Lock()
-	id, ok := d.domainIDMapping[name]
-	d.domainIDMu.Unlock()
-	if ok {
-		return id, nil
-	}
-
-	// Find out by querying API
-	domains, err := d.listDomains()
-	if err != nil {
-		return domainNotFound, err
-	}
-
-	// Linear search over all registered domains
-	for _, domain := range domains {
-		if domain.Name == name || strings.HasSuffix(name, "."+domain.Name) {
-			d.domainIDMu.Lock()
-			d.domainIDMapping[name] = domain.ID
-			d.domainIDMu.Unlock()
-
-			return domain.ID, nil
-		}
-	}
-
-	return domainNotFound, errors.New("domain not found")
-}
-
-func (d *DNSProvider) listDomains() ([]*Domain, error) {
-	req, err := d.makeRequest(http.MethodGet, "/v1/domains", http.NoBody)
-	if err != nil {
-		return nil, fmt.Errorf("failed to make request: %w", err)
-	}
-
-	// Checkdomain also provides a query param 'query' which allows filtering domains for a string.
-	// But that functionality is kinda broken,
-	// so we scan through the whole list of registered domains to later find the one that is of interest to us.
-	q := req.URL.Query()
-	q.Set("limit", strconv.Itoa(maxLimit))
-
-	currentPage := 1
-	totalPages := maxInt
-
-	var domainList []*Domain
-	for currentPage <= totalPages {
-		q.Set("page", strconv.Itoa(currentPage))
-		req.URL.RawQuery = q.Encode()
-
-		var res DomainListingResponse
-		if err := d.sendRequest(req, &res); err != nil {
-			return nil, fmt.Errorf("failed to send domain listing request: %w", err)
-		}
-
-		// This is the first response,
-		// so we update totalPages and allocate the slice memory.
-		if totalPages == maxInt {
-			totalPages = res.Pages
-			domainList = make([]*Domain, 0, res.Total)
-		}
-
-		domainList = append(domainList, res.Embedded.Domains...)
-		currentPage++
-	}
-
-	return domainList, nil
-}
-
-func (d *DNSProvider) getNameserverInfo(domainID int) (*NameserverResponse, error) {
-	req, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/v1/domains/%d/nameservers", domainID), http.NoBody)
-	if err != nil {
-		return nil, err
-	}
-
-	res := &NameserverResponse{}
-	if err := d.sendRequest(req, res); err != nil {
-		return nil, err
-	}
-
-	return res, nil
-}
-
-func (d *DNSProvider) checkNameservers(domainID int) error {
-	info, err := d.getNameserverInfo(domainID)
-	if err != nil {
-		return err
-	}
-
-	var found1, found2 bool
-	for _, item := range info.Nameservers {
-		switch item.Name {
-		case ns1:
-			found1 = true
-		case ns2:
-			found2 = true
-		}
-	}
-
-	if !found1 || !found2 {
-		return errors.New("not using checkdomain nameservers, can not update records")
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) createRecord(domainID int, record *Record) error {
-	bs, err := json.Marshal(record)
-	if err != nil {
-		return fmt.Errorf("encoding record failed: %w", err)
-	}
-
-	req, err := d.makeRequest(http.MethodPost, fmt.Sprintf("/v1/domains/%d/nameservers/records", domainID), bytes.NewReader(bs))
-	if err != nil {
-		return err
-	}
-
-	return d.sendRequest(req, nil)
-}
-
-// Checkdomain doesn't seem provide a way to delete records but one can replace all records at once.
-// The current solution is to fetch all records and then use that list minus the record deleted as the new record list.
-// TODO: Simplify this function once Checkdomain do provide the functionality.
-func (d *DNSProvider) deleteTXTRecord(domainID int, recordName, recordValue string) error {
-	domainInfo, err := d.getDomainInfo(domainID)
-	if err != nil {
-		return err
-	}
-
-	nsInfo, err := d.getNameserverInfo(domainID)
-	if err != nil {
-		return err
-	}
-
-	allRecords, err := d.listRecords(domainID, "")
-	if err != nil {
-		return err
-	}
-
-	recordName = strings.TrimSuffix(recordName, "."+domainInfo.Name+".")
-
-	var recordsToKeep []*Record
-
-	// Find and delete matching records
-	for _, record := range allRecords {
-		if skipRecord(recordName, recordValue, record, nsInfo) {
-			continue
-		}
-
-		// Checkdomain API can return records without any TTL set (indicated by the value of 0).
-		// The API Call to replace the records would fail if we wouldn't specify a value.
-		// Thus, we use the default TTL queried beforehand
-		if record.TTL == 0 {
-			record.TTL = nsInfo.SOA.TTL
-		}
-
-		recordsToKeep = append(recordsToKeep, record)
-	}
-
-	return d.replaceRecords(domainID, recordsToKeep)
-}
-
-func (d *DNSProvider) getDomainInfo(domainID int) (*DomainResponse, error) {
-	req, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/v1/domains/%d", domainID), http.NoBody)
-	if err != nil {
-		return nil, err
-	}
-
-	var res DomainResponse
-	err = d.sendRequest(req, &res)
-	if err != nil {
-		return nil, err
-	}
-
-	return &res, nil
-}
-
-func (d *DNSProvider) listRecords(domainID int, recordType string) ([]*Record, error) {
-	req, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/v1/domains/%d/nameservers/records", domainID), http.NoBody)
-	if err != nil {
-		return nil, fmt.Errorf("failed to make request: %w", err)
-	}
-
-	q := req.URL.Query()
-	q.Set("limit", strconv.Itoa(maxLimit))
-	if recordType != "" {
-		q.Set("type", recordType)
-	}
-
-	currentPage := 1
-	totalPages := maxInt
-
-	var recordList []*Record
-	for currentPage <= totalPages {
-		q.Set("page", strconv.Itoa(currentPage))
-		req.URL.RawQuery = q.Encode()
-
-		var res RecordListingResponse
-		if err := d.sendRequest(req, &res); err != nil {
-			return nil, fmt.Errorf("failed to send record listing request: %w", err)
-		}
-
-		// This is the first response, so we update totalPages and allocate the slice memory.
-		if totalPages == maxInt {
-			totalPages = res.Pages
-			recordList = make([]*Record, 0, res.Total)
-		}
-
-		recordList = append(recordList, res.Embedded.Records...)
-		currentPage++
-	}
-
-	return recordList, nil
-}
-
-func (d *DNSProvider) replaceRecords(domainID int, records []*Record) error {
-	bs, err := json.Marshal(records)
-	if err != nil {
-		return fmt.Errorf("encoding record failed: %w", err)
-	}
-
-	req, err := d.makeRequest(http.MethodPut, fmt.Sprintf("/v1/domains/%d/nameservers/records", domainID), bytes.NewReader(bs))
-	if err != nil {
-		return err
-	}
-
-	return d.sendRequest(req, nil)
-}
-
-func skipRecord(recordName, recordValue string, record *Record, nsInfo *NameserverResponse) bool {
-	// Skip empty records
-	if record.Value == "" {
-		return true
-	}
-
-	// Skip some special records, otherwise we would get a "Nameserver update failed"
-	if record.Type == "SOA" || record.Type == "NS" || record.Name == "@" || (nsInfo.General.IncludeWWW && record.Name == "www") {
-		return true
-	}
-
-	nameMatch := recordName == "" || record.Name == recordName
-	valueMatch := recordValue == "" || record.Value == recordValue
-
-	// Skip our matching record
-	if record.Type == "TXT" && nameMatch && valueMatch {
-		return true
-	}
-
-	return false
-}
-
-func (d *DNSProvider) makeRequest(method, resource string, body io.Reader) (*http.Request, error) {
-	uri, err := d.config.Endpoint.Parse(resource)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(method, uri.String(), body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Authorization", "Bearer "+d.config.Token)
-	if method != http.MethodGet {
-		req.Header.Set("Content-Type", "application/json")
-	}
-
-	return req, nil
-}
-
-func (d *DNSProvider) sendRequest(req *http.Request, result interface{}) error {
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-
-	if err = checkResponse(resp); err != nil {
-		return err
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	if result == nil {
-		return nil
-	}
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-
-	err = json.Unmarshal(raw, result)
-	if err != nil {
-		return fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", result, resp.StatusCode, err, string(raw))
-	}
-	return nil
-}
-
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode < http.StatusBadRequest {
-		return nil
-	}
-
-	if resp.Body == nil {
-		return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode)
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err)
-	}
-
-	return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw))
-}
diff --git a/providers/dns/checkdomain/internal/client.go b/providers/dns/checkdomain/internal/client.go
new file mode 100644
index 00000000..74189dee
--- /dev/null
+++ b/providers/dns/checkdomain/internal/client.go
@@ -0,0 +1,383 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/oauth2"
+)
+
+const (
+	ns1 = "ns.checkdomain.de"
+	ns2 = "ns2.checkdomain.de"
+)
+
+// DefaultEndpoint the default API endpoint.
+const DefaultEndpoint = "https://api.checkdomain.de"
+
+const domainNotFound = -1
+
+// max page limit that the checkdomain api allows.
+const maxLimit = 100
+
+// max integer value.
+const maxInt = int((^uint(0)) >> 1)
+
+// Client the Autodns API client.
+type Client struct {
+	domainIDMapping map[string]int
+	domainIDMu      sync.Mutex
+
+	BaseURL    *url.URL
+	httpClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(hc *http.Client) *Client {
+	baseURL, _ := url.Parse(DefaultEndpoint)
+
+	if hc == nil {
+		hc = &http.Client{Timeout: 10 * time.Second}
+	}
+
+	return &Client{
+		BaseURL:         baseURL,
+		httpClient:      hc,
+		domainIDMapping: make(map[string]int),
+	}
+}
+
+func (c *Client) GetDomainIDByName(ctx context.Context, name string) (int, error) {
+	// Load from cache if exists
+	c.domainIDMu.Lock()
+	id, ok := c.domainIDMapping[name]
+	c.domainIDMu.Unlock()
+	if ok {
+		return id, nil
+	}
+
+	// Find out by querying API
+	domains, err := c.listDomains(ctx)
+	if err != nil {
+		return domainNotFound, err
+	}
+
+	// Linear search over all registered domains
+	for _, domain := range domains {
+		if domain.Name == name || strings.HasSuffix(name, "."+domain.Name) {
+			c.domainIDMu.Lock()
+			c.domainIDMapping[name] = domain.ID
+			c.domainIDMu.Unlock()
+
+			return domain.ID, nil
+		}
+	}
+
+	return domainNotFound, errors.New("domain not found")
+}
+
+func (c *Client) listDomains(ctx context.Context) ([]*Domain, error) {
+	endpoint := c.BaseURL.JoinPath("v1", "domains")
+
+	// Checkdomain also provides a query param 'query' which allows filtering domains for a string.
+	// But that functionality is kinda broken,
+	// so we scan through the whole list of registered domains to later find the one that is of interest to us.
+	q := endpoint.Query()
+	q.Set("limit", strconv.Itoa(maxLimit))
+
+	currentPage := 1
+	totalPages := maxInt
+
+	var domainList []*Domain
+	for currentPage <= totalPages {
+		q.Set("page", strconv.Itoa(currentPage))
+		endpoint.RawQuery = q.Encode()
+
+		req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+		if err != nil {
+			return nil, fmt.Errorf("failed to make request: %w", err)
+		}
+
+		var res DomainListingResponse
+		if err := c.do(req, &res); err != nil {
+			return nil, fmt.Errorf("failed to send domain listing request: %w", err)
+		}
+
+		// This is the first response,
+		// so we update totalPages and allocate the slice memory.
+		if totalPages == maxInt {
+			totalPages = res.Pages
+			domainList = make([]*Domain, 0, res.Total)
+		}
+
+		domainList = append(domainList, res.Embedded.Domains...)
+		currentPage++
+	}
+
+	return domainList, nil
+}
+
+func (c *Client) getNameserverInfo(ctx context.Context, domainID int) (*NameserverResponse, error) {
+	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	res := &NameserverResponse{}
+	if err := c.do(req, res); err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+func (c *Client) CheckNameservers(ctx context.Context, domainID int) error {
+	info, err := c.getNameserverInfo(ctx, domainID)
+	if err != nil {
+		return err
+	}
+
+	var found1, found2 bool
+	for _, item := range info.Nameservers {
+		switch item.Name {
+		case ns1:
+			found1 = true
+		case ns2:
+			found2 = true
+		}
+	}
+
+	if !found1 || !found2 {
+		return errors.New("not using checkdomain nameservers, can not update records")
+	}
+
+	return nil
+}
+
+func (c *Client) CreateRecord(ctx context.Context, domainID int, record *Record) error {
+	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+// DeleteTXTRecord Checkdomain doesn't seem provide a way to delete records but one can replace all records at once.
+// The current solution is to fetch all records and then use that list minus the record deleted as the new record list.
+// TODO: Simplify this function once Checkdomain do provide the functionality.
+func (c *Client) DeleteTXTRecord(ctx context.Context, domainID int, recordName, recordValue string) error {
+	domainInfo, err := c.getDomainInfo(ctx, domainID)
+	if err != nil {
+		return err
+	}
+
+	nsInfo, err := c.getNameserverInfo(ctx, domainID)
+	if err != nil {
+		return err
+	}
+
+	allRecords, err := c.listRecords(ctx, domainID, "")
+	if err != nil {
+		return err
+	}
+
+	recordName = strings.TrimSuffix(recordName, "."+domainInfo.Name+".")
+
+	var recordsToKeep []*Record
+
+	// Find and delete matching records
+	for _, record := range allRecords {
+		if skipRecord(recordName, recordValue, record, nsInfo) {
+			continue
+		}
+
+		// Checkdomain API can return records without any TTL set (indicated by the value of 0).
+		// The API Call to replace the records would fail if we wouldn't specify a value.
+		// Thus, we use the default TTL queried beforehand
+		if record.TTL == 0 {
+			record.TTL = nsInfo.SOA.TTL
+		}
+
+		recordsToKeep = append(recordsToKeep, record)
+	}
+
+	return c.replaceRecords(ctx, domainID, recordsToKeep)
+}
+
+func (c *Client) getDomainInfo(ctx context.Context, domainID int) (*DomainResponse, error) {
+	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID))
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var res DomainResponse
+	err = c.do(req, &res)
+	if err != nil {
+		return nil, err
+	}
+
+	return &res, nil
+}
+
+func (c *Client) listRecords(ctx context.Context, domainID int, recordType string) ([]*Record, error) {
+	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records")
+
+	q := endpoint.Query()
+	q.Set("limit", strconv.Itoa(maxLimit))
+	if recordType != "" {
+		q.Set("type", recordType)
+	}
+
+	currentPage := 1
+	totalPages := maxInt
+
+	var recordList []*Record
+	for currentPage <= totalPages {
+		q.Set("page", strconv.Itoa(currentPage))
+		endpoint.RawQuery = q.Encode()
+
+		req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request: %w", err)
+		}
+
+		var res RecordListingResponse
+		if err := c.do(req, &res); err != nil {
+			return nil, fmt.Errorf("failed to send record listing request: %w", err)
+		}
+
+		// This is the first response, so we update totalPages and allocate the slice memory.
+		if totalPages == maxInt {
+			totalPages = res.Pages
+			recordList = make([]*Record, 0, res.Total)
+		}
+
+		recordList = append(recordList, res.Embedded.Records...)
+		currentPage++
+	}
+
+	return recordList, nil
+}
+
+func (c *Client) replaceRecords(ctx context.Context, domainID int, records []*Record) error {
+	endpoint := c.BaseURL.JoinPath("v1", "domains", strconv.Itoa(domainID), "nameservers", "records")
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func (c *Client) CleanCache(fqdn string) {
+	c.domainIDMu.Lock()
+	delete(c.domainIDMapping, fqdn)
+	c.domainIDMu.Unlock()
+}
+
+func skipRecord(recordName, recordValue string, record *Record, nsInfo *NameserverResponse) bool {
+	// Skip empty records
+	if record.Value == "" {
+		return true
+	}
+
+	// Skip some special records, otherwise we would get a "Nameserver update failed"
+	if record.Type == "SOA" || record.Type == "NS" || record.Name == "@" || (nsInfo.General.IncludeWWW && record.Name == "www") {
+		return true
+	}
+
+	nameMatch := recordName == "" || record.Name == recordName
+	valueMatch := recordValue == "" || record.Value == recordValue
+
+	// Skip our matching record
+	if record.Type == "TXT" && nameMatch && valueMatch {
+		return true
+	}
+
+	return false
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
+}
diff --git a/providers/dns/checkdomain/client_test.go b/providers/dns/checkdomain/internal/client_test.go
similarity index 60%
rename from providers/dns/checkdomain/client_test.go
rename to providers/dns/checkdomain/internal/client_test.go
index f7c488be..3f6a7e7a 100644
--- a/providers/dns/checkdomain/client_test.go
+++ b/providers/dns/checkdomain/internal/client_test.go
@@ -1,6 +1,8 @@
-package checkdomain
+package internal
 
 import (
+	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -15,32 +17,42 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setupTestProvider(t *testing.T) (*DNSProvider, *http.ServeMux) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	config := NewDefaultConfig()
-	config.Endpoint, _ = url.Parse(server.URL)
-	config.Token = "secret"
+	client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
+	client.BaseURL, _ = url.Parse(server.URL)
 
-	p, err := NewDNSProviderConfig(config)
-	require.NoError(t, err)
-
-	return p, mux
+	return client, mux
 }
 
-func Test_getDomainIDByName(t *testing.T) {
-	prd, handler := setupTestProvider(t)
+func checkAuthorizationHeader(req *http.Request) error {
+	val := req.Header.Get("Authorization")
+	if val != "Bearer secret" {
+		return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret")
+	}
+	return nil
+}
 
-	handler.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) {
+func TestClient_GetDomainIDByName(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
 			return
 		}
 
+		err := checkAuthorizationHeader(req)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusUnauthorized)
+			return
+		}
+
 		domainList := DomainListingResponse{
 			Embedded: EmbeddedDomainList{Domains: []*Domain{
 				{ID: 1, Name: "test.com"},
@@ -48,28 +60,34 @@ func Test_getDomainIDByName(t *testing.T) {
 			}},
 		}
 
-		err := json.NewEncoder(rw).Encode(domainList)
+		err = json.NewEncoder(rw).Encode(domainList)
 		if err != nil {
 			http.Error(rw, err.Error(), http.StatusInternalServerError)
 			return
 		}
 	})
 
-	id, err := prd.getDomainIDByName("test.com")
+	id, err := client.GetDomainIDByName(context.Background(), "test.com")
 	require.NoError(t, err)
 
 	assert.Equal(t, 1, id)
 }
 
-func Test_checkNameservers(t *testing.T) {
-	prd, handler := setupTestProvider(t)
+func TestClient_CheckNameservers(t *testing.T) {
+	client, mux := setupTest(t)
 
-	handler.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) {
+	mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
 			return
 		}
 
+		err := checkAuthorizationHeader(req)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusUnauthorized)
+			return
+		}
+
 		nsResp := NameserverResponse{
 			Nameservers: []*Nameserver{
 				{Name: ns1},
@@ -78,33 +96,39 @@ func Test_checkNameservers(t *testing.T) {
 			},
 		}
 
-		err := json.NewEncoder(rw).Encode(nsResp)
+		err = json.NewEncoder(rw).Encode(nsResp)
 		if err != nil {
 			http.Error(rw, err.Error(), http.StatusInternalServerError)
 			return
 		}
 	})
 
-	err := prd.checkNameservers(1)
+	err := client.CheckNameservers(context.Background(), 1)
 	require.NoError(t, err)
 }
 
-func Test_createRecord(t *testing.T) {
-	prd, handler := setupTestProvider(t)
+func TestClient_CreateRecord(t *testing.T) {
+	client, mux := setupTest(t)
 
-	handler.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) {
+	mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
 			return
 		}
 
+		err := checkAuthorizationHeader(req)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusUnauthorized)
+			return
+		}
+
 		content, err := io.ReadAll(req.Body)
 		if err != nil {
 			http.Error(rw, err.Error(), http.StatusBadRequest)
 			return
 		}
 
-		if string(content) != `{"name":"test.com","value":"value","ttl":300,"priority":0,"type":"TXT"}` {
+		if string(bytes.TrimSpace(content)) != `{"name":"test.com","value":"value","ttl":300,"priority":0,"type":"TXT"}` {
 			http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest)
 			return
 		}
@@ -117,12 +141,12 @@ func Test_createRecord(t *testing.T) {
 		Value: "value",
 	}
 
-	err := prd.createRecord(1, record)
+	err := client.CreateRecord(context.Background(), 1, record)
 	require.NoError(t, err)
 }
 
-func Test_deleteTXTRecord(t *testing.T) {
-	prd, handler := setupTestProvider(t)
+func TestClient_DeleteTXTRecord(t *testing.T) {
+	client, mux := setupTest(t)
 
 	domainName := "lego.test"
 	recordValue := "test"
@@ -158,20 +182,26 @@ func Test_deleteTXTRecord(t *testing.T) {
 		},
 	}
 
-	handler.HandleFunc("/v1/domains/1", func(rw http.ResponseWriter, req *http.Request) {
+	mux.HandleFunc("/v1/domains/1", func(rw http.ResponseWriter, req *http.Request) {
+		err := checkAuthorizationHeader(req)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusUnauthorized)
+			return
+		}
+
 		resp := DomainResponse{
 			ID:   1,
 			Name: domainName,
 		}
 
-		err := json.NewEncoder(rw).Encode(resp)
+		err = json.NewEncoder(rw).Encode(resp)
 		if err != nil {
 			http.Error(rw, err.Error(), http.StatusInternalServerError)
 			return
 		}
 	})
 
-	handler.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) {
+	mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
 			return
@@ -188,7 +218,7 @@ func Test_deleteTXTRecord(t *testing.T) {
 		}
 	})
 
-	handler.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) {
+	mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) {
 		switch req.Method {
 		case http.MethodGet:
 			resp := RecordListingResponse{
@@ -226,6 +256,6 @@ func Test_deleteTXTRecord(t *testing.T) {
 	})
 
 	info := dns01.GetChallengeInfo(domainName, "abc")
-	err := prd.deleteTXTRecord(1, info.EffectiveFQDN, recordValue)
+	err := client.DeleteTXTRecord(context.Background(), 1, info.EffectiveFQDN, recordValue)
 	require.NoError(t, err)
 }
diff --git a/providers/dns/checkdomain/internal/types.go b/providers/dns/checkdomain/internal/types.go
new file mode 100644
index 00000000..06e0b018
--- /dev/null
+++ b/providers/dns/checkdomain/internal/types.go
@@ -0,0 +1,73 @@
+package internal
+
+// Some fields have been omitted from the structs
+// because they are not required for this application.
+
+type DomainListingResponse struct {
+	Page     int                `json:"page"`
+	Limit    int                `json:"limit"`
+	Pages    int                `json:"pages"`
+	Total    int                `json:"total"`
+	Embedded EmbeddedDomainList `json:"_embedded"`
+}
+
+type EmbeddedDomainList struct {
+	Domains []*Domain `json:"domains"`
+}
+
+type Domain struct {
+	ID   int    `json:"id"`
+	Name string `json:"name"`
+}
+
+type DomainResponse struct {
+	ID      int    `json:"id"`
+	Name    string `json:"name"`
+	Created string `json:"created"`
+	PaidUp  string `json:"payed_up"`
+	Active  bool   `json:"active"`
+}
+
+type NameserverResponse struct {
+	General     NameserverGeneral `json:"general"`
+	Nameservers []*Nameserver     `json:"nameservers"`
+	SOA         NameserverSOA     `json:"soa"`
+}
+
+type NameserverGeneral struct {
+	IPv4       string `json:"ip_v4"`
+	IPv6       string `json:"ip_v6"`
+	IncludeWWW bool   `json:"include_www"`
+}
+
+type NameserverSOA struct {
+	Mail    string `json:"mail"`
+	Refresh int    `json:"refresh"`
+	Retry   int    `json:"retry"`
+	Expiry  int    `json:"expiry"`
+	TTL     int    `json:"ttl"`
+}
+
+type Nameserver struct {
+	Name string `json:"name"`
+}
+
+type RecordListingResponse struct {
+	Page     int                `json:"page"`
+	Limit    int                `json:"limit"`
+	Pages    int                `json:"pages"`
+	Total    int                `json:"total"`
+	Embedded EmbeddedRecordList `json:"_embedded"`
+}
+
+type EmbeddedRecordList struct {
+	Records []*Record `json:"records"`
+}
+
+type Record struct {
+	Name     string `json:"name"`
+	Value    string `json:"value"`
+	TTL      int    `json:"ttl"`
+	Priority int    `json:"priority"`
+	Type     string `json:"type"`
+}
diff --git a/providers/dns/civo/civo.go b/providers/dns/civo/civo.go
index 6190ca2f..3d639eb6 100644
--- a/providers/dns/civo/civo.go
+++ b/providers/dns/civo/civo.go
@@ -93,11 +93,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("civo: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("civo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
+	zone := dns01.UnFqdn(authZone)
+
 	dnsDomain, err := d.client.GetDNSDomain(zone)
 	if err != nil {
 		return fmt.Errorf("civo: %w", err)
@@ -125,11 +127,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("civo: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("civo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
+	zone := dns01.UnFqdn(authZone)
+
 	dnsDomain, err := d.client.GetDNSDomain(zone)
 	if err != nil {
 		return fmt.Errorf("civo: %w", err)
@@ -166,12 +170,3 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 	return d.config.PropagationTimeout, d.config.PollingInterval
 }
-
-func getZone(fqdn string) (string, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return "", err
-	}
-
-	return dns01.UnFqdn(authZone), nil
-}
diff --git a/providers/dns/clouddns/clouddns.go b/providers/dns/clouddns/clouddns.go
index e12054ec..7b0644f7 100644
--- a/providers/dns/clouddns/clouddns.go
+++ b/providers/dns/clouddns/clouddns.go
@@ -2,6 +2,7 @@
 package clouddns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -89,10 +90,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		client.HTTPClient = config.HTTPClient
 	}
 
-	return &DNSProvider{
-		client: client,
-		config: config,
-	}, nil
+	return &DNSProvider{client: client, config: config}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -107,12 +105,17 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("clouddns: %w", err)
+		return fmt.Errorf("clouddns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	err = d.client.AddRecord(authZone, info.EffectiveFQDN, info.Value)
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
-		return fmt.Errorf("clouddns: %w", err)
+		return err
+	}
+
+	err = d.client.AddRecord(ctx, authZone, info.EffectiveFQDN, info.Value)
+	if err != nil {
+		return fmt.Errorf("clouddns: add record: %w", err)
 	}
 
 	return nil
@@ -124,12 +127,17 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("clouddns: %w", err)
+		return fmt.Errorf("clouddns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	err = d.client.DeleteRecord(authZone, info.EffectiveFQDN)
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
-		return fmt.Errorf("clouddns: %w", err)
+		return err
+	}
+
+	err = d.client.DeleteRecord(ctx, authZone, info.EffectiveFQDN)
+	if err != nil {
+		return fmt.Errorf("clouddns: delete record: %w", err)
 	}
 
 	return nil
diff --git a/providers/dns/clouddns/internal/client.go b/providers/dns/clouddns/internal/client.go
index 7ea6234c..cd3da50c 100644
--- a/providers/dns/clouddns/internal/client.go
+++ b/providers/dns/clouddns/internal/client.go
@@ -2,117 +2,127 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
-const (
-	apiBaseURL = "https://admin.vshosting.cloud/clouddns"
-	loginURL   = "https://admin.vshosting.cloud/api/public/auth/login"
-)
+const apiBaseURL = "https://admin.vshosting.cloud/clouddns"
+
+const authorizationHeader = "Authorization"
 
 // Client handles all communication with CloudDNS API.
 type Client struct {
-	AccessToken string
-	ClientID    string
-	Email       string
-	Password    string
-	TTL         int
-	HTTPClient  *http.Client
+	clientID string
+	email    string
+	password string
+	ttl      int
 
-	apiBaseURL string
-	loginURL   string
+	apiBaseURL *url.URL
+
+	loginURL *url.URL
+
+	HTTPClient *http.Client
 }
 
 // NewClient returns a Client instance configured to handle CloudDNS API communication.
 func NewClient(clientID, email, password string, ttl int) *Client {
+	baseURL, _ := url.Parse(apiBaseURL)
+	loginBaseURL, _ := url.Parse(loginURL)
+
 	return &Client{
-		ClientID:   clientID,
-		Email:      email,
-		Password:   password,
-		TTL:        ttl,
-		HTTPClient: &http.Client{},
-		apiBaseURL: apiBaseURL,
-		loginURL:   loginURL,
+		clientID:   clientID,
+		email:      email,
+		password:   password,
+		ttl:        ttl,
+		apiBaseURL: baseURL,
+		loginURL:   loginBaseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // AddRecord is a high level method to add a new record into CloudDNS zone.
-func (c *Client) AddRecord(zone, recordName, recordValue string) error {
-	domain, err := c.getDomain(zone)
+func (c *Client) AddRecord(ctx context.Context, zone, recordName, recordValue string) error {
+	domain, err := c.getDomain(ctx, zone)
 	if err != nil {
 		return err
 	}
 
 	record := Record{DomainID: domain.ID, Name: recordName, Value: recordValue, Type: "TXT"}
 
-	err = c.addTxtRecord(record)
+	err = c.addTxtRecord(ctx, record)
 	if err != nil {
 		return err
 	}
 
-	return c.publishRecords(domain.ID)
+	return c.publishRecords(ctx, domain.ID)
 }
 
 // DeleteRecord is a high level method to remove a record from zone.
-func (c *Client) DeleteRecord(zone, recordName string) error {
-	domain, err := c.getDomain(zone)
+func (c *Client) DeleteRecord(ctx context.Context, zone, recordName string) error {
+	domain, err := c.getDomain(ctx, zone)
 	if err != nil {
 		return err
 	}
 
-	record, err := c.getRecord(domain.ID, recordName)
+	record, err := c.getRecord(ctx, domain.ID, recordName)
 	if err != nil {
 		return err
 	}
 
-	err = c.deleteRecord(record)
+	err = c.deleteRecord(ctx, record)
 	if err != nil {
 		return err
 	}
 
-	return c.publishRecords(domain.ID)
+	return c.publishRecords(ctx, domain.ID)
 }
 
-func (c *Client) addTxtRecord(record Record) error {
-	body, err := json.Marshal(record)
+func (c *Client) addTxtRecord(ctx context.Context, record Record) error {
+	endpoint := c.apiBaseURL.JoinPath("record-txt")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return err
 	}
 
-	_, err = c.doAPIRequest(http.MethodPost, "record-txt", bytes.NewReader(body))
-	return err
+	return c.do(req, nil)
 }
 
-func (c *Client) deleteRecord(record Record) error {
-	endpoint := fmt.Sprintf("record/%s", record.ID)
-	_, err := c.doAPIRequest(http.MethodDelete, endpoint, nil)
-	return err
+func (c *Client) deleteRecord(ctx context.Context, record Record) error {
+	endpoint := c.apiBaseURL.JoinPath("record", record.ID)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
 }
 
-func (c *Client) getDomain(zone string) (Domain, error) {
+func (c *Client) getDomain(ctx context.Context, zone string) (Domain, error) {
 	searchQuery := SearchQuery{
 		Search: []Search{
-			{Name: "clientId", Operator: "eq", Value: c.ClientID},
+			{Name: "clientId", Operator: "eq", Value: c.clientID},
 			{Name: "domainName", Operator: "eq", Value: zone},
 		},
 	}
 
-	body, err := json.Marshal(searchQuery)
-	if err != nil {
-		return Domain{}, err
-	}
+	endpoint := c.apiBaseURL.JoinPath("domain", "search")
 
-	resp, err := c.doAPIRequest(http.MethodPost, "domain/search", bytes.NewReader(body))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, searchQuery)
 	if err != nil {
 		return Domain{}, err
 	}
 
 	var result SearchResponse
-	err = json.Unmarshal(resp, &result)
+	err = c.do(req, &result)
 	if err != nil {
 		return Domain{}, err
 	}
@@ -124,15 +134,16 @@ func (c *Client) getDomain(zone string) (Domain, error) {
 	return result.Items[0], nil
 }
 
-func (c *Client) getRecord(domainID, recordName string) (Record, error) {
-	endpoint := fmt.Sprintf("domain/%s", domainID)
-	resp, err := c.doAPIRequest(http.MethodGet, endpoint, nil)
+func (c *Client) getRecord(ctx context.Context, domainID, recordName string) (Record, error) {
+	endpoint := c.apiBaseURL.JoinPath("domain", domainID)
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return Record{}, err
 	}
 
 	var result DomainInfo
-	err = json.Unmarshal(resp, &result)
+	err = c.do(req, &result)
 	if err != nil {
 		return Record{}, err
 	}
@@ -146,116 +157,85 @@ func (c *Client) getRecord(domainID, recordName string) (Record, error) {
 	return Record{}, fmt.Errorf("record not found: domainID %s, name %s", domainID, recordName)
 }
 
-func (c *Client) publishRecords(domainID string) error {
-	body, err := json.Marshal(DomainInfo{SoaTTL: c.TTL})
+func (c *Client) publishRecords(ctx context.Context, domainID string) error {
+	endpoint := c.apiBaseURL.JoinPath("domain", domainID, "publish")
+
+	payload := DomainInfo{SoaTTL: c.ttl}
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, payload)
 	if err != nil {
 		return err
 	}
 
-	endpoint := fmt.Sprintf("domain/%s/publish", domainID)
-	_, err = c.doAPIRequest(http.MethodPut, endpoint, bytes.NewReader(body))
-	return err
+	return c.do(req, nil)
 }
 
-func (c *Client) login() error {
-	authorization := Authorization{Email: c.Email, Password: c.Password}
-
-	body, err := json.Marshal(authorization)
-	if err != nil {
-		return err
+func (c *Client) do(req *http.Request, result any) error {
+	at := getAccessToken(req.Context())
+	if at != "" {
+		req.Header.Set(authorizationHeader, "Bearer "+at)
 	}
 
-	req, err := http.NewRequest(http.MethodPost, c.loginURL, bytes.NewReader(body))
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	req.Header.Set("Content-Type", "application/json")
+	defer func() { _ = resp.Body.Close() }()
 
-	content, err := c.doRequest(req)
-	if err != nil {
-		return err
+	if resp.StatusCode/100 != 2 {
+		return parseError(req, resp)
 	}
 
-	var result AuthResponse
-	err = json.Unmarshal(content, &result)
-	if err != nil {
-		return err
+	if result == nil {
+		return nil
 	}
 
-	c.AccessToken = result.Auth.AccessToken
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
 
 	return nil
 }
 
-func (c *Client) doAPIRequest(method, endpoint string, body io.Reader) ([]byte, error) {
-	if c.AccessToken == "" {
-		err := c.login()
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 		}
 	}
 
-	url := fmt.Sprintf("%s/%s", c.apiBaseURL, endpoint)
-
-	req, err := c.newRequest(method, url, body)
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
-	content, err := c.doRequest(req)
-	if err != nil {
-		return nil, err
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
 	}
 
-	return content, nil
-}
-
-func (c *Client) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) {
-	req, err := http.NewRequest(method, reqURL, body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.AccessToken))
-
 	return req, nil
 }
 
-func (c *Client) doRequest(req *http.Request) ([]byte, error) {
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
 
-	if resp.StatusCode >= http.StatusBadRequest {
-		return nil, readError(req, resp)
+	var response APIError
+	err := json.Unmarshal(raw, &response)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 	}
 
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-	return content, nil
-}
-
-func readError(req *http.Request, resp *http.Response) error {
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return errors.New(toUnreadableBodyMessage(req, content))
-	}
-
-	var errInfo APIError
-	err = json.Unmarshal(content, &errInfo)
-	if err != nil {
-		return fmt.Errorf("APIError unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content))
-	}
-
-	return fmt.Errorf("HTTP %d: code %v: %s", resp.StatusCode, errInfo.Error.Code, errInfo.Error.Message)
-}
-
-func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
-	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody))
+	return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Error)
 }
diff --git a/providers/dns/clouddns/internal/client_test.go b/providers/dns/clouddns/internal/client_test.go
index 68f500d8..2a4891cc 100644
--- a/providers/dns/clouddns/internal/client_test.go
+++ b/providers/dns/clouddns/internal/client_test.go
@@ -1,16 +1,33 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_AddRecord(t *testing.T) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	client := NewClient("clientID", "email@example.com", "secret", 300)
+	client.HTTPClient = server.Client()
+	client.apiBaseURL, _ = url.Parse(server.URL + "/api")
+	client.loginURL, _ = url.Parse(server.URL + "/login")
+
+	return client, mux
+}
+
+func TestClient_AddRecord(t *testing.T) {
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) {
 		response := SearchResponse{
@@ -45,19 +62,12 @@ func TestClient_AddRecord(t *testing.T) {
 		}
 	})
 
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
-	client := NewClient("clientID", "email@example.com", "secret", 300)
-	client.apiBaseURL = server.URL + "/api"
-	client.loginURL = server.URL + "/login"
-
-	err := client.AddRecord("example.com", "_acme-challenge.example.com", "txt")
+	err := client.AddRecord(context.Background(), "example.com", "_acme-challenge.example.com", "txt")
 	require.NoError(t, err)
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	mux := http.NewServeMux()
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) {
 		response := SearchResponse{
@@ -114,13 +124,9 @@ func TestClient_DeleteRecord(t *testing.T) {
 		}
 	})
 
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	ctx, err := client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
 
-	client := NewClient("clientID", "email@example.com", "secret", 300)
-	client.apiBaseURL = server.URL + "/api"
-	client.loginURL = server.URL + "/login"
-
-	err := client.DeleteRecord("example.com", "_acme-challenge.example.com")
+	err = client.DeleteRecord(ctx, "example.com", "_acme-challenge.example.com")
 	require.NoError(t, err)
 }
diff --git a/providers/dns/clouddns/internal/identity.go b/providers/dns/clouddns/internal/identity.go
new file mode 100644
index 00000000..4ea5c504
--- /dev/null
+++ b/providers/dns/clouddns/internal/identity.go
@@ -0,0 +1,47 @@
+package internal
+
+import (
+	"context"
+	"net/http"
+)
+
+const loginURL = "https://admin.vshosting.cloud/api/public/auth/login"
+
+type token string
+
+const accessTokenKey token = "accessToken"
+
+func (c *Client) login(ctx context.Context) (*AuthResponse, error) {
+	authorization := Authorization{Email: c.email, Password: c.password}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, c.loginURL, authorization)
+	if err != nil {
+		return nil, err
+	}
+
+	var result AuthResponse
+	err = c.do(req, &result)
+	if err != nil {
+		return nil, err
+	}
+
+	return &result, nil
+}
+
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+	tok, err := c.login(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return context.WithValue(ctx, accessTokenKey, tok.Auth.AccessToken), nil
+}
+
+func getAccessToken(ctx context.Context) string {
+	tok, ok := ctx.Value(accessTokenKey).(string)
+	if !ok {
+		return ""
+	}
+
+	return tok
+}
diff --git a/providers/dns/clouddns/internal/identity_test.go b/providers/dns/clouddns/internal/identity_test.go
new file mode 100644
index 00000000..3c727448
--- /dev/null
+++ b/providers/dns/clouddns/internal/identity_test.go
@@ -0,0 +1,46 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestClient_CreateAuthenticatedContext(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) {
+		response := AuthResponse{
+			Auth: Auth{
+				AccessToken:  "at",
+				RefreshToken: "",
+			},
+		}
+
+		err := json.NewEncoder(rw).Encode(response)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+	mux.HandleFunc("/api/record/xxx", func(rw http.ResponseWriter, req *http.Request) {
+		authorization := req.Header.Get(authorizationHeader)
+		if authorization != "Bearer at" {
+			http.Error(rw, "invalid credential: "+authorization, http.StatusUnauthorized)
+			return
+		}
+	})
+
+	ctx, err := client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
+
+	at := getAccessToken(ctx)
+	assert.Equal(t, "at", at)
+
+	err = client.deleteRecord(ctx, Record{ID: "xxx"})
+	require.NoError(t, err)
+}
diff --git a/providers/dns/clouddns/internal/models.go b/providers/dns/clouddns/internal/types.go
similarity index 95%
rename from providers/dns/clouddns/internal/models.go
rename to providers/dns/clouddns/internal/types.go
index a46bfdf0..a53c958a 100644
--- a/providers/dns/clouddns/internal/models.go
+++ b/providers/dns/clouddns/internal/types.go
@@ -1,5 +1,7 @@
 package internal
 
+import "fmt"
+
 type APIError struct {
 	Error ErrorContent `json:"error"`
 }
@@ -9,6 +11,10 @@ type ErrorContent struct {
 	Message string `json:"message,omitempty"`
 }
 
+func (e ErrorContent) Error() string {
+	return fmt.Sprintf("%d: %s", e.Code, e.Message)
+}
+
 type Authorization struct {
 	Email    string `json:"email,omitempty"`
 	Password string `json:"password,omitempty"`
diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go
index 16bfe390..b32e91ff 100644
--- a/providers/dns/cloudflare/cloudflare.go
+++ b/providers/dns/cloudflare/cloudflare.go
@@ -126,7 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("cloudflare: %w", err)
+		return fmt.Errorf("cloudflare: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	zoneID, err := d.client.ZoneIDByName(authZone)
@@ -165,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("cloudflare: %w", err)
+		return fmt.Errorf("cloudflare: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	zoneID, err := d.client.ZoneIDByName(authZone)
diff --git a/providers/dns/cloudflare/client.go b/providers/dns/cloudflare/wrapper.go
similarity index 100%
rename from providers/dns/cloudflare/client.go
rename to providers/dns/cloudflare/wrapper.go
diff --git a/providers/dns/cloudns/cloudns.go b/providers/dns/cloudns/cloudns.go
index f75b9096..554e5416 100644
--- a/providers/dns/cloudns/cloudns.go
+++ b/providers/dns/cloudns/cloudns.go
@@ -2,6 +2,7 @@
 package cloudns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -104,29 +105,33 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.client.GetZone(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zone, err := d.client.GetZone(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("ClouDNS: %w", err)
 	}
 
-	err = d.client.AddTxtRecord(zone.Name, info.EffectiveFQDN, info.Value, d.config.TTL)
+	err = d.client.AddTxtRecord(ctx, zone.Name, info.EffectiveFQDN, info.Value, d.config.TTL)
 	if err != nil {
 		return fmt.Errorf("ClouDNS: %w", err)
 	}
 
-	return d.waitNameservers(domain, zone)
+	return d.waitNameservers(ctx, domain, zone)
 }
 
 // CleanUp removes the TXT records matching the specified parameters.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.client.GetZone(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zone, err := d.client.GetZone(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("ClouDNS: %w", err)
 	}
 
-	records, err := d.client.ListTxtRecords(zone.Name, info.EffectiveFQDN)
+	records, err := d.client.ListTxtRecords(ctx, zone.Name, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("ClouDNS: %w", err)
 	}
@@ -136,7 +141,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	}
 
 	for _, record := range records {
-		err = d.client.RemoveTxtRecord(record.ID, zone.Name)
+		err = d.client.RemoveTxtRecord(ctx, record.ID, zone.Name)
 		if err != nil {
 			return fmt.Errorf("ClouDNS: %w", err)
 		}
@@ -153,9 +158,9 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 
 // waitNameservers At the time of writing 4 servers are found as authoritative, but 8 are reported during the sync.
 // If this is not done, the secondary verification done by Let's Encrypt server will fail quire a bit.
-func (d *DNSProvider) waitNameservers(domain string, zone *internal.Zone) error {
+func (d *DNSProvider) waitNameservers(ctx context.Context, domain string, zone *internal.Zone) error {
 	return wait.For("Nameserver sync on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) {
-		syncProgress, err := d.client.GetUpdateStatus(zone.Name)
+		syncProgress, err := d.client.GetUpdateStatus(ctx, zone.Name)
 		if err != nil {
 			return false, err
 		}
diff --git a/providers/dns/cloudns/internal/client.go b/providers/dns/cloudns/internal/client.go
index 65270f80..c4f350f6 100644
--- a/providers/dns/cloudns/internal/client.go
+++ b/providers/dns/cloudns/internal/client.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -8,8 +9,10 @@ import (
 	"net/http"
 	"net/url"
 	"strconv"
+	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://api.cloudns.net/dns/"
@@ -19,8 +22,9 @@ type Client struct {
 	authID       string
 	subAuthID    string
 	authPassword string
-	HTTPClient   *http.Client
-	BaseURL      *url.URL
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient creates a ClouDNS client.
@@ -42,16 +46,16 @@ func NewClient(authID, subAuthID, authPassword string) (*Client, error) {
 		authID:       authID,
 		subAuthID:    subAuthID,
 		authPassword: authPassword,
-		HTTPClient:   &http.Client{},
 		BaseURL:      baseURL,
+		HTTPClient:   &http.Client{Timeout: 10 * time.Second},
 	}, nil
 }
 
 // GetZone Get domain name information for a FQDN.
-func (c *Client) GetZone(authFQDN string) (*Zone, error) {
+func (c *Client) GetZone(ctx context.Context, authFQDN string) (*Zone, error) {
 	authZone, err := dns01.FindZoneByFqdn(authFQDN)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("could not find zone for FQDN %q: %w", authFQDN, err)
 	}
 
 	authZoneName := dns01.UnFqdn(authZone)
@@ -62,16 +66,21 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) {
 	q.Set("domain-name", authZoneName)
 	endpoint.RawQuery = q.Encode()
 
-	result, err := c.doRequest(http.MethodGet, endpoint)
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	rawMessage, err := c.do(req)
 	if err != nil {
 		return nil, err
 	}
 
 	var zone Zone
 
-	if len(result) > 0 {
-		if err = json.Unmarshal(result, &zone); err != nil {
-			return nil, fmt.Errorf("failed to unmarshal zone: %w", err)
+	if len(rawMessage) > 0 {
+		if err = json.Unmarshal(rawMessage, &zone); err != nil {
+			return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 		}
 	}
 
@@ -83,7 +92,7 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) {
 }
 
 // FindTxtRecord returns the TXT record a zone ID and a FQDN.
-func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
+func (c *Client) FindTxtRecord(ctx context.Context, zoneName, fqdn string) (*TXTRecord, error) {
 	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
 	if err != nil {
 		return nil, err
@@ -97,19 +106,24 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
 	q.Set("type", "TXT")
 	endpoint.RawQuery = q.Encode()
 
-	result, err := c.doRequest(http.MethodGet, endpoint)
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	rawMessage, err := c.do(req)
 	if err != nil {
 		return nil, err
 	}
 
 	// the API returns [] when there is no records.
-	if string(result) == "[]" {
+	if string(rawMessage) == "[]" {
 		return nil, nil
 	}
 
 	var records map[string]TXTRecord
-	if err = json.Unmarshal(result, &records); err != nil {
-		return nil, fmt.Errorf("failed to unmarshall TXT records: %w: %s", err, string(result))
+	if err = json.Unmarshal(rawMessage, &records); err != nil {
+		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 	}
 
 	for _, record := range records {
@@ -122,7 +136,7 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
 }
 
 // ListTxtRecords returns the TXT records a zone ID and a FQDN.
-func (c *Client) ListTxtRecords(zoneName, fqdn string) ([]TXTRecord, error) {
+func (c *Client) ListTxtRecords(ctx context.Context, zoneName, fqdn string) ([]TXTRecord, error) {
 	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
 	if err != nil {
 		return nil, err
@@ -136,19 +150,24 @@ func (c *Client) ListTxtRecords(zoneName, fqdn string) ([]TXTRecord, error) {
 	q.Set("type", "TXT")
 	endpoint.RawQuery = q.Encode()
 
-	result, err := c.doRequest(http.MethodGet, endpoint)
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	rawMessage, err := c.do(req)
 	if err != nil {
 		return nil, err
 	}
 
 	// the API returns [] when there is no records.
-	if string(result) == "[]" {
+	if string(rawMessage) == "[]" {
 		return nil, nil
 	}
 
 	var raw map[string]TXTRecord
-	if err = json.Unmarshal(result, &raw); err != nil {
-		return nil, fmt.Errorf("failed to unmarshall TXT records: %w: %s", err, string(result))
+	if err = json.Unmarshal(rawMessage, &raw); err != nil {
+		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 	}
 
 	var records []TXTRecord
@@ -162,7 +181,7 @@ func (c *Client) ListTxtRecords(zoneName, fqdn string) ([]TXTRecord, error) {
 }
 
 // AddTxtRecord adds a TXT record.
-func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error {
+func (c *Client) AddTxtRecord(ctx context.Context, zoneName, fqdn, value string, ttl int) error {
 	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
 	if err != nil {
 		return err
@@ -178,14 +197,19 @@ func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error {
 	q.Set("record-type", "TXT")
 	endpoint.RawQuery = q.Encode()
 
-	raw, err := c.doRequest(http.MethodPost, endpoint)
+	req, err := c.newRequest(ctx, http.MethodPost, endpoint)
+	if err != nil {
+		return err
+	}
+
+	rawMessage, err := c.do(req)
 	if err != nil {
 		return err
 	}
 
 	resp := apiResponse{}
-	if err = json.Unmarshal(raw, &resp); err != nil {
-		return fmt.Errorf("failed to unmarshal API response: %w: %s", err, string(raw))
+	if err = json.Unmarshal(rawMessage, &resp); err != nil {
+		return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 	}
 
 	if resp.Status != "Success" {
@@ -196,7 +220,7 @@ func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error {
 }
 
 // RemoveTxtRecord removes a TXT record.
-func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error {
+func (c *Client) RemoveTxtRecord(ctx context.Context, recordID int, zoneName string) error {
 	endpoint := c.BaseURL.JoinPath("delete-record.json")
 
 	q := endpoint.Query()
@@ -204,14 +228,19 @@ func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error {
 	q.Set("record-id", strconv.Itoa(recordID))
 	endpoint.RawQuery = q.Encode()
 
-	raw, err := c.doRequest(http.MethodPost, endpoint)
+	req, err := c.newRequest(ctx, http.MethodPost, endpoint)
+	if err != nil {
+		return err
+	}
+
+	rawMessage, err := c.do(req)
 	if err != nil {
 		return err
 	}
 
 	resp := apiResponse{}
-	if err = json.Unmarshal(raw, &resp); err != nil {
-		return fmt.Errorf("failed to unmarshal API response: %w: %s", err, string(raw))
+	if err = json.Unmarshal(rawMessage, &resp); err != nil {
+		return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 	}
 
 	if resp.Status != "Success" {
@@ -222,26 +251,31 @@ func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error {
 }
 
 // GetUpdateStatus gets sync progress of all CloudDNS NS servers.
-func (c *Client) GetUpdateStatus(zoneName string) (*SyncProgress, error) {
+func (c *Client) GetUpdateStatus(ctx context.Context, zoneName string) (*SyncProgress, error) {
 	endpoint := c.BaseURL.JoinPath("update-status.json")
 
 	q := endpoint.Query()
 	q.Set("domain-name", zoneName)
 	endpoint.RawQuery = q.Encode()
 
-	result, err := c.doRequest(http.MethodGet, endpoint)
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	rawMessage, err := c.do(req)
 	if err != nil {
 		return nil, err
 	}
 
 	// the API returns [] when there is no records.
-	if string(result) == "[]" {
+	if string(rawMessage) == "[]" {
 		return nil, errors.New("no nameservers records returned")
 	}
 
 	var records []UpdateRecord
-	if err = json.Unmarshal(result, &records); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal UpdateRecord: %w: %s", err, string(result))
+	if err = json.Unmarshal(rawMessage, &records); err != nil {
+		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 	}
 
 	updatedCount := 0
@@ -254,33 +288,8 @@ func (c *Client) GetUpdateStatus(zoneName string) (*SyncProgress, error) {
 	return &SyncProgress{Complete: updatedCount == len(records), Updated: updatedCount, Total: len(records)}, nil
 }
 
-func (c *Client) doRequest(method string, uri *url.URL) (json.RawMessage, error) {
-	req, err := c.buildRequest(method, uri)
-	if err != nil {
-		return nil, err
-	}
-
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-
-	defer resp.Body.Close()
-
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.New(toUnreadableBodyMessage(req, content))
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("invalid code (%d), error: %s", resp.StatusCode, content)
-	}
-
-	return content, nil
-}
-
-func (c *Client) buildRequest(method string, uri *url.URL) (*http.Request, error) {
-	q := uri.Query()
+func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL) (*http.Request, error) {
+	q := endpoint.Query()
 
 	if c.subAuthID != "" {
 		q.Set("sub-auth-id", c.subAuthID)
@@ -290,18 +299,34 @@ func (c *Client) buildRequest(method string, uri *url.URL) (*http.Request, error
 
 	q.Set("auth-password", c.authPassword)
 
-	uri.RawQuery = q.Encode()
+	endpoint.RawQuery = q.Encode()
 
-	req, err := http.NewRequest(method, uri.String(), nil)
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil)
 	if err != nil {
-		return nil, fmt.Errorf("invalid request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	return req, nil
 }
 
-func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
-	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody))
+func (c *Client) do(req *http.Request) (json.RawMessage, error) {
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	return raw, nil
 }
 
 // Rounds the given TTL in seconds to the next accepted value.
diff --git a/providers/dns/cloudns/internal/client_test.go b/providers/dns/cloudns/internal/client_test.go
index 277063a8..554bf008 100644
--- a/providers/dns/cloudns/internal/client_test.go
+++ b/providers/dns/cloudns/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -11,6 +12,21 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func setupTest(t *testing.T, subAuthID string, handler http.HandlerFunc) *Client {
+	t.Helper()
+
+	server := httptest.NewServer(handler)
+	t.Cleanup(server.Close)
+
+	client, err := NewClient("myAuthID", subAuthID, "myAuthPassword")
+	require.NoError(t, err)
+
+	client.BaseURL, _ = url.Parse(server.URL)
+	client.HTTPClient = server.Client()
+
+	return client
+}
+
 func handlerMock(method string, jsonData []byte) http.HandlerFunc {
 	return func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != method {
@@ -109,22 +125,16 @@ func TestClient_GetZone(t *testing.T) {
 			authFQDN:    "_acme-challenge.foo.com.",
 			apiResponse: `[{}]`,
 			expected: expected{
-				errorMsg: "failed to unmarshal zone: json: cannot unmarshal array into Go value of type internal.Zone",
+				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.Zone",
 			},
 		},
 	}
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse)))
-			t.Cleanup(server.Close)
+			client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse)))
 
-			client, err := NewClient("myAuthID", "", "myAuthPassword")
-			require.NoError(t, err)
-
-			client.BaseURL, _ = url.Parse(server.URL)
-
-			zone, err := client.GetZone(test.authFQDN)
+			zone, err := client.GetZone(context.Background(), test.authFQDN)
 
 			if test.expected.errorMsg != "" {
 				require.EqualError(t, err, test.expected.errorMsg)
@@ -222,22 +232,16 @@ func TestClient_FindTxtRecord(t *testing.T) {
 			zoneName:    "example.com",
 			apiResponse: `[{}]`,
 			expected: expected{
-				errorMsg: "failed to unmarshall TXT records: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord: [{}]",
+				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord",
 			},
 		},
 	}
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse)))
-			t.Cleanup(server.Close)
+			client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse)))
 
-			client, err := NewClient("myAuthID", "", "myAuthPassword")
-			require.NoError(t, err)
-
-			client.BaseURL, _ = url.Parse(server.URL)
-
-			txtRecord, err := client.FindTxtRecord(test.zoneName, test.authFQDN)
+			txtRecord, err := client.FindTxtRecord(context.Background(), test.zoneName, test.authFQDN)
 
 			if test.expected.errorMsg != "" {
 				require.EqualError(t, err, test.expected.errorMsg)
@@ -337,22 +341,16 @@ func TestClient_ListTxtRecord(t *testing.T) {
 			zoneName:    "example.com",
 			apiResponse: `[{}]`,
 			expected: expected{
-				errorMsg: "failed to unmarshall TXT records: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord: [{}]",
+				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord",
 			},
 		},
 	}
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse)))
-			t.Cleanup(server.Close)
+			client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse)))
 
-			client, err := NewClient("myAuthID", "", "myAuthPassword")
-			require.NoError(t, err)
-
-			client.BaseURL, _ = url.Parse(server.URL)
-
-			txtRecords, err := client.ListTxtRecords(test.zoneName, test.authFQDN)
+			txtRecords, err := client.ListTxtRecords(context.Background(), test.zoneName, test.authFQDN)
 
 			if test.expected.errorMsg != "" {
 				require.EqualError(t, err, test.expected.errorMsg)
@@ -440,14 +438,14 @@ func TestClient_AddTxtRecord(t *testing.T) {
 			apiResponse: `[{}]`,
 			expected: expected{
 				query:    `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`,
-				errorMsg: "failed to unmarshal API response: json: cannot unmarshal array into Go value of type internal.apiResponse: [{}]",
+				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse",
 			},
 		},
 	}
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+			client := setupTest(t, test.subAuthID, func(rw http.ResponseWriter, req *http.Request) {
 				if test.expected.query != req.URL.RawQuery {
 					msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery)
 					http.Error(rw, msg, http.StatusBadRequest)
@@ -455,15 +453,9 @@ func TestClient_AddTxtRecord(t *testing.T) {
 				}
 
 				handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req)
-			}))
-			t.Cleanup(server.Close)
+			})
 
-			client, err := NewClient(test.authID, test.subAuthID, "myAuthPassword")
-			require.NoError(t, err)
-
-			client.BaseURL, _ = url.Parse(server.URL)
-
-			err = client.AddTxtRecord(test.zoneName, test.authFQDN, test.value, test.ttl)
+			err := client.AddTxtRecord(context.Background(), test.zoneName, test.authFQDN, test.value, test.ttl)
 
 			if test.expected.errorMsg != "" {
 				require.EqualError(t, err, test.expected.errorMsg)
@@ -513,7 +505,7 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
 			apiResponse: `[{}]`,
 			expected: expected{
 				query:    `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo-plus.com&record-id=44`,
-				errorMsg: "failed to unmarshal API response: json: cannot unmarshal array into Go value of type internal.apiResponse: [{}]",
+				errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse",
 			},
 		},
 	}
@@ -536,7 +528,7 @@ func TestClient_RemoveTxtRecord(t *testing.T) {
 
 			client.BaseURL, _ = url.Parse(server.URL)
 
-			err = client.RemoveTxtRecord(test.id, test.zoneName)
+			err = client.RemoveTxtRecord(context.Background(), test.id, test.zoneName)
 
 			if test.expected.errorMsg != "" {
 				require.EqualError(t, err, test.expected.errorMsg)
@@ -592,7 +584,7 @@ func TestClient_GetUpdateStatus(t *testing.T) {
 			authFQDN:    "_acme-challenge.foo.com.",
 			zoneName:    "test-zone",
 			apiResponse: `[x]`,
-			expected:    expected{errorMsg: "failed to unmarshal UpdateRecord: invalid character 'x' looking for beginning of value: [x]"},
+			expected:    expected{errorMsg: "unable to unmarshal response: [status code: 200] body: [x] error: invalid character 'x' looking for beginning of value"},
 		},
 	}
 
@@ -606,7 +598,7 @@ func TestClient_GetUpdateStatus(t *testing.T) {
 
 			client.BaseURL, _ = url.Parse(server.URL)
 
-			syncProgress, err := client.GetUpdateStatus(test.zoneName)
+			syncProgress, err := client.GetUpdateStatus(context.Background(), test.zoneName)
 
 			if test.expected.errorMsg != "" {
 				require.EqualError(t, err, test.expected.errorMsg)
diff --git a/providers/dns/cloudxns/cloudxns.go b/providers/dns/cloudxns/cloudxns.go
index d3bd9b6b..6269b8da 100644
--- a/providers/dns/cloudxns/cloudxns.go
+++ b/providers/dns/cloudxns/cloudxns.go
@@ -2,6 +2,7 @@
 package cloudxns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -59,7 +60,7 @@ type DNSProvider struct {
 func NewDNSProvider() (*DNSProvider, error) {
 	values, err := env.Get(EnvAPIKey, EnvSecretKey)
 	if err != nil {
-		return nil, fmt.Errorf("CloudXNS: %w", err)
+		return nil, fmt.Errorf("cloudxns: %w", err)
 	}
 
 	config := NewDefaultConfig()
@@ -72,15 +73,17 @@ func NewDNSProvider() (*DNSProvider, error) {
 // NewDNSProviderConfig return a DNSProvider instance configured for CloudXNS.
 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	if config == nil {
-		return nil, errors.New("CloudXNS: the configuration of the DNS provider is nil")
+		return nil, errors.New("cloudxns: the configuration of the DNS provider is nil")
 	}
 
 	client, err := internal.NewClient(config.APIKey, config.SecretKey)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("cloudxns: %w", err)
 	}
 
-	client.HTTPClient = config.HTTPClient
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
 
 	return &DNSProvider{client: client, config: config}, nil
 }
@@ -89,29 +92,43 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	challengeInfo := dns01.GetChallengeInfo(domain, keyAuth)
 
-	info, err := d.client.GetDomainInformation(challengeInfo.EffectiveFQDN)
+	ctx := context.Background()
+
+	info, err := d.client.GetDomainInformation(ctx, challengeInfo.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("cloudxns: %w", err)
 	}
 
-	return d.client.AddTxtRecord(info, challengeInfo.EffectiveFQDN, challengeInfo.Value, d.config.TTL)
+	err = d.client.AddTxtRecord(ctx, info, challengeInfo.EffectiveFQDN, challengeInfo.Value, d.config.TTL)
+	if err != nil {
+		return fmt.Errorf("cloudxns: %w", err)
+	}
+
+	return nil
 }
 
 // CleanUp removes the TXT record matching the specified parameters.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	challengeInfo := dns01.GetChallengeInfo(domain, keyAuth)
 
-	info, err := d.client.GetDomainInformation(challengeInfo.EffectiveFQDN)
+	ctx := context.Background()
+
+	info, err := d.client.GetDomainInformation(ctx, challengeInfo.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("cloudxns: %w", err)
 	}
 
-	record, err := d.client.FindTxtRecord(info.ID, challengeInfo.EffectiveFQDN)
+	record, err := d.client.FindTxtRecord(ctx, info.ID, challengeInfo.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("cloudxns: %w", err)
 	}
 
-	return d.client.RemoveTxtRecord(record.RecordID, info.ID)
+	err = d.client.RemoveTxtRecord(ctx, record.RecordID, info.ID)
+	if err != nil {
+		return fmt.Errorf("cloudxns: %w", err)
+	}
+
+	return nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
diff --git a/providers/dns/cloudxns/cloudxns_test.go b/providers/dns/cloudxns/cloudxns_test.go
index 43dd8a99..0b327176 100644
--- a/providers/dns/cloudxns/cloudxns_test.go
+++ b/providers/dns/cloudxns/cloudxns_test.go
@@ -34,7 +34,7 @@ func TestNewDNSProvider(t *testing.T) {
 				EnvAPIKey:    "",
 				EnvSecretKey: "",
 			},
-			expected: "CloudXNS: some credentials information are missing: CLOUDXNS_API_KEY,CLOUDXNS_SECRET_KEY",
+			expected: "cloudxns: some credentials information are missing: CLOUDXNS_API_KEY,CLOUDXNS_SECRET_KEY",
 		},
 		{
 			desc: "missing API key",
@@ -42,7 +42,7 @@ func TestNewDNSProvider(t *testing.T) {
 				EnvAPIKey:    "",
 				EnvSecretKey: "456",
 			},
-			expected: "CloudXNS: some credentials information are missing: CLOUDXNS_API_KEY",
+			expected: "cloudxns: some credentials information are missing: CLOUDXNS_API_KEY",
 		},
 		{
 			desc: "missing secret key",
@@ -50,7 +50,7 @@ func TestNewDNSProvider(t *testing.T) {
 				EnvAPIKey:    "123",
 				EnvSecretKey: "",
 			},
-			expected: "CloudXNS: some credentials information are missing: CLOUDXNS_SECRET_KEY",
+			expected: "cloudxns: some credentials information are missing: CLOUDXNS_SECRET_KEY",
 		},
 	}
 
@@ -89,17 +89,17 @@ func TestNewDNSProviderConfig(t *testing.T) {
 		},
 		{
 			desc:     "missing credentials",
-			expected: "CloudXNS: credentials missing: apiKey",
+			expected: "cloudxns: credentials missing: apiKey",
 		},
 		{
 			desc:      "missing api key",
 			secretKey: "456",
-			expected:  "CloudXNS: credentials missing: apiKey",
+			expected:  "cloudxns: credentials missing: apiKey",
 		},
 		{
 			desc:     "missing secret key",
 			apiKey:   "123",
-			expected: "CloudXNS: credentials missing: secretKey",
+			expected: "cloudxns: credentials missing: secretKey",
 		},
 	}
 
diff --git a/providers/dns/cloudxns/internal/client.go b/providers/dns/cloudxns/internal/client.go
index dd151184..2fc6aab2 100644
--- a/providers/dns/cloudxns/internal/client.go
+++ b/providers/dns/cloudxns/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"crypto/md5"
 	"encoding/hex"
 	"encoding/json"
@@ -9,83 +10,63 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 	"strconv"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://www.cloudxns.net/api2/"
 
-type apiResponse struct {
-	Code    int             `json:"code"`
-	Message string          `json:"message"`
-	Data    json.RawMessage `json:"data,omitempty"`
-}
+// Client CloudXNS client.
+type Client struct {
+	apiKey    string
+	secretKey string
 
-// Data Domain information.
-type Data struct {
-	ID     string `json:"id"`
-	Domain string `json:"domain"`
-	TTL    int    `json:"ttl,omitempty"`
-}
-
-// TXTRecord a TXT record.
-type TXTRecord struct {
-	ID       int    `json:"domain_id,omitempty"`
-	RecordID string `json:"record_id,omitempty"`
-
-	Host   string `json:"host"`
-	Value  string `json:"value"`
-	Type   string `json:"type"`
-	LineID int    `json:"line_id,string"`
-	TTL    int    `json:"ttl,string"`
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient creates a CloudXNS client.
 func NewClient(apiKey, secretKey string) (*Client, error) {
 	if apiKey == "" {
-		return nil, errors.New("CloudXNS: credentials missing: apiKey")
+		return nil, errors.New("credentials missing: apiKey")
 	}
 
 	if secretKey == "" {
-		return nil, errors.New("CloudXNS: credentials missing: secretKey")
+		return nil, errors.New("credentials missing: secretKey")
 	}
 
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
 		apiKey:     apiKey,
 		secretKey:  secretKey,
-		HTTPClient: &http.Client{},
-		BaseURL:    defaultBaseURL,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}, nil
 }
 
-// Client CloudXNS client.
-type Client struct {
-	apiKey     string
-	secretKey  string
-	HTTPClient *http.Client
-	BaseURL    string
-}
-
 // GetDomainInformation Get domain name information for a FQDN.
-func (c *Client) GetDomainInformation(fqdn string) (*Data, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
+func (c *Client) GetDomainInformation(ctx context.Context, fqdn string) (*Data, error) {
+	endpoint := c.baseURL.JoinPath("domain")
+
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	result, err := c.doRequest(http.MethodGet, "domain", nil)
+	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("cloudflare: could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	var domains []Data
-	if len(result) > 0 {
-		err = json.Unmarshal(result, &domains)
-		if err != nil {
-			return nil, fmt.Errorf("CloudXNS: domains unmarshaling error: %w", err)
-		}
+	err = c.do(req, &domains)
+	if err != nil {
+		return nil, err
 	}
 
 	for _, data := range domains {
@@ -94,20 +75,28 @@ func (c *Client) GetDomainInformation(fqdn string) (*Data, error) {
 		}
 	}
 
-	return nil, fmt.Errorf("CloudXNS: zone %s not found for domain %s", authZone, fqdn)
+	return nil, fmt.Errorf("zone %s not found for domain %s", authZone, fqdn)
 }
 
 // FindTxtRecord return the TXT record a zone ID and a FQDN.
-func (c *Client) FindTxtRecord(zoneID, fqdn string) (*TXTRecord, error) {
-	result, err := c.doRequest(http.MethodGet, fmt.Sprintf("record/%s?host_id=0&offset=0&row_num=2000", zoneID), nil)
+func (c *Client) FindTxtRecord(ctx context.Context, zoneID, fqdn string) (*TXTRecord, error) {
+	endpoint := c.baseURL.JoinPath("record", zoneID)
+
+	query := endpoint.Query()
+	query.Set("host_id", "0")
+	query.Set("offset", "0")
+	query.Set("row_num", "2000")
+	endpoint.RawQuery = query.Encode()
+
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
 	var records []TXTRecord
-	err = json.Unmarshal(result, &records)
+	err = c.do(req, &records)
 	if err != nil {
-		return nil, fmt.Errorf("CloudXNS: TXT record unmarshaling error: %w", err)
+		return nil, err
 	}
 
 	for _, record := range records {
@@ -116,22 +105,24 @@ func (c *Client) FindTxtRecord(zoneID, fqdn string) (*TXTRecord, error) {
 		}
 	}
 
-	return nil, fmt.Errorf("CloudXNS: no existing record found for %q", fqdn)
+	return nil, fmt.Errorf("no existing record found for %q", fqdn)
 }
 
 // AddTxtRecord add a TXT record.
-func (c *Client) AddTxtRecord(info *Data, fqdn, value string, ttl int) error {
+func (c *Client) AddTxtRecord(ctx context.Context, info *Data, fqdn, value string, ttl int) error {
 	id, err := strconv.Atoi(info.ID)
 	if err != nil {
-		return fmt.Errorf("CloudXNS: invalid zone ID: %w", err)
+		return fmt.Errorf("invalid zone ID: %w", err)
 	}
 
+	endpoint := c.baseURL.JoinPath("record")
+
 	subDomain, err := dns01.ExtractSubDomain(fqdn, info.Domain)
 	if err != nil {
-		return fmt.Errorf("CloudXNS: %w", err)
+		return err
 	}
 
-	payload := TXTRecord{
+	record := TXTRecord{
 		ID:     id,
 		Host:   subDomain,
 		Value:  value,
@@ -140,74 +131,91 @@ func (c *Client) AddTxtRecord(info *Data, fqdn, value string, ttl int) error {
 		TTL:    ttl,
 	}
 
-	body, err := json.Marshal(payload)
+	req, err := c.newRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
-		return fmt.Errorf("CloudXNS: record unmarshaling error: %w", err)
+		return err
 	}
 
-	_, err = c.doRequest(http.MethodPost, "record", body)
-	return err
+	return c.do(req, nil)
 }
 
 // RemoveTxtRecord remove a TXT record.
-func (c *Client) RemoveTxtRecord(recordID, zoneID string) error {
-	_, err := c.doRequest(http.MethodDelete, fmt.Sprintf("record/%s/%s", recordID, zoneID), nil)
-	return err
-}
+func (c *Client) RemoveTxtRecord(ctx context.Context, recordID, zoneID string) error {
+	endpoint := c.baseURL.JoinPath("record", recordID, zoneID)
 
-func (c *Client) doRequest(method, uri string, body []byte) (json.RawMessage, error) {
-	req, err := c.buildRequest(method, uri, body)
+	req, err := c.newRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("CloudXNS: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	defer resp.Body.Close()
+	defer func() { _ = resp.Body.Close() }()
 
-	content, err := io.ReadAll(resp.Body)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, fmt.Errorf("CloudXNS: %s", toUnreadableBodyMessage(req, content))
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	var r apiResponse
-	err = json.Unmarshal(content, &r)
+	var response apiResponse
+	err = json.Unmarshal(raw, &response)
 	if err != nil {
-		return nil, fmt.Errorf("CloudXNS: response unmashaling error: %w: %s", err, toUnreadableBodyMessage(req, content))
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
-	if r.Code != 1 {
-		return nil, fmt.Errorf("CloudXNS: invalid code (%v), error: %s", r.Code, r.Message)
+	if response.Code != 1 {
+		return fmt.Errorf("[status code %d] invalid code (%v) error: %s", resp.StatusCode, response.Code, response.Message)
 	}
-	return r.Data, nil
+
+	if result == nil {
+		return nil
+	}
+
+	if len(response.Data) == 0 {
+		return nil
+	}
+
+	err = json.Unmarshal(response.Data, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
 }
 
-func (c *Client) buildRequest(method, uri string, body []byte) (*http.Request, error) {
-	url := c.BaseURL + uri
+func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
 
-	req, err := http.NewRequest(method, url, bytes.NewReader(body))
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 	if err != nil {
-		return nil, fmt.Errorf("CloudXNS: invalid request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	requestDate := time.Now().Format(time.RFC1123Z)
 
 	req.Header.Set("API-KEY", c.apiKey)
 	req.Header.Set("API-REQUEST-DATE", requestDate)
-	req.Header.Set("API-HMAC", c.hmac(url, requestDate, string(body)))
+	req.Header.Set("API-HMAC", c.hmac(endpoint.String(), requestDate, buf.String()))
 	req.Header.Set("API-FORMAT", "json")
 
 	return req, nil
 }
 
-func (c *Client) hmac(url, date, body string) string {
-	sum := md5.Sum([]byte(c.apiKey + url + body + date + c.secretKey))
+func (c *Client) hmac(endpoint, date, body string) string {
+	sum := md5.Sum([]byte(c.apiKey + endpoint + body + date + c.secretKey))
 	return hex.EncodeToString(sum[:])
 }
-
-func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
-	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody))
-}
diff --git a/providers/dns/cloudxns/internal/client_test.go b/providers/dns/cloudxns/internal/client_test.go
index 618ac027..e4972174 100644
--- a/providers/dns/cloudxns/internal/client_test.go
+++ b/providers/dns/cloudxns/internal/client_test.go
@@ -1,19 +1,35 @@
 package internal
 
 import (
+	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func handlerMock(method string, response *apiResponse, data interface{}) http.Handler {
-	return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+func setupTest(t *testing.T, handler http.HandlerFunc) *Client {
+	t.Helper()
+
+	server := httptest.NewServer(handler)
+	t.Cleanup(server.Close)
+
+	client, _ := NewClient("myKey", "mySecret")
+	client.baseURL, _ = url.Parse(server.URL + "/")
+	client.HTTPClient = server.Client()
+
+	return client
+}
+
+func handlerMock(method string, response *apiResponse, data interface{}) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != method {
 			content, err := json.Marshal(apiResponse{
 				Code:    999, // random code only for the test
@@ -47,10 +63,10 @@ func handlerMock(method string, response *apiResponse, data interface{}) http.Ha
 			http.Error(rw, err.Error(), http.StatusInternalServerError)
 			return
 		}
-	})
+	}
 }
 
-func TestClientGetDomainInformation(t *testing.T) {
+func TestClient_GetDomainInformation(t *testing.T) {
 	type result struct {
 		domain *Data
 		error  bool
@@ -106,13 +122,9 @@ func TestClientGetDomainInformation(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.data))
-			t.Cleanup(server.Close)
+			client := setupTest(t, handlerMock(http.MethodGet, test.response, test.data))
 
-			client, _ := NewClient("myKey", "mySecret")
-			client.BaseURL = server.URL + "/"
-
-			domain, err := client.GetDomainInformation(test.fqdn)
+			domain, err := client.GetDomainInformation(context.Background(), test.fqdn)
 
 			if test.expected.error {
 				require.Error(t, err)
@@ -124,7 +136,7 @@ func TestClientGetDomainInformation(t *testing.T) {
 	}
 }
 
-func TestClientFindTxtRecord(t *testing.T) {
+func TestClient_FindTxtRecord(t *testing.T) {
 	type result struct {
 		txtRecord *TXTRecord
 		error     bool
@@ -210,13 +222,9 @@ func TestClientFindTxtRecord(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.txtRecords))
-			t.Cleanup(server.Close)
+			client := setupTest(t, handlerMock(http.MethodGet, test.response, test.txtRecords))
 
-			client, _ := NewClient("myKey", "mySecret")
-			client.BaseURL = server.URL + "/"
-
-			txtRecord, err := client.FindTxtRecord(test.zoneID, test.fqdn)
+			txtRecord, err := client.FindTxtRecord(context.Background(), test.zoneID, test.fqdn)
 
 			if test.expected.error {
 				require.Error(t, err)
@@ -228,7 +236,7 @@ func TestClientFindTxtRecord(t *testing.T) {
 	}
 }
 
-func TestClientAddTxtRecord(t *testing.T) {
+func TestClient_AddTxtRecord(t *testing.T) {
 	testCases := []struct {
 		desc     string
 		domain   *Data
@@ -267,21 +275,17 @@ func TestClientAddTxtRecord(t *testing.T) {
 				Code: 1,
 			}
 
-			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+			client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
 				assert.NotNil(t, req.Body)
 				content, err := io.ReadAll(req.Body)
 				require.NoError(t, err)
 
-				assert.Equal(t, test.expected, string(content))
+				assert.Equal(t, test.expected, string(bytes.TrimSpace(content)))
 
 				handlerMock(http.MethodPost, response, nil).ServeHTTP(rw, req)
-			}))
-			t.Cleanup(server.Close)
+			})
 
-			client, _ := NewClient("myKey", "mySecret")
-			client.BaseURL = server.URL + "/"
-
-			err := client.AddTxtRecord(test.domain, test.fqdn, test.value, test.ttl)
+			err := client.AddTxtRecord(context.Background(), test.domain, test.fqdn, test.value, test.ttl)
 			require.NoError(t, err)
 		})
 	}
diff --git a/providers/dns/cloudxns/internal/types.go b/providers/dns/cloudxns/internal/types.go
new file mode 100644
index 00000000..c1b24e30
--- /dev/null
+++ b/providers/dns/cloudxns/internal/types.go
@@ -0,0 +1,28 @@
+package internal
+
+import "encoding/json"
+
+type apiResponse struct {
+	Code    int             `json:"code"`
+	Message string          `json:"message"`
+	Data    json.RawMessage `json:"data,omitempty"`
+}
+
+// Data Domain information.
+type Data struct {
+	ID     string `json:"id"`
+	Domain string `json:"domain"`
+	TTL    int    `json:"ttl,omitempty"`
+}
+
+// TXTRecord a TXT record.
+type TXTRecord struct {
+	ID       int    `json:"domain_id,omitempty"`
+	RecordID string `json:"record_id,omitempty"`
+
+	Host   string `json:"host"`
+	Value  string `json:"value"`
+	Type   string `json:"type"`
+	LineID int    `json:"line_id,string"`
+	TTL    int    `json:"ttl,string"`
+}
diff --git a/providers/dns/conoha/conoha.go b/providers/dns/conoha/conoha.go
index b107ed37..1e4b0e18 100644
--- a/providers/dns/conoha/conoha.go
+++ b/providers/dns/conoha/conoha.go
@@ -2,6 +2,7 @@
 package conoha
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -85,6 +86,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("conoha: some credentials information are missing")
 	}
 
+	identifier, err := internal.NewIdentifier(config.Region)
+	if err != nil {
+		return nil, fmt.Errorf("conoha: failed to create identity client: %w", err)
+	}
+
+	if config.HTTPClient != nil {
+		identifier.HTTPClient = config.HTTPClient
+	}
+
 	auth := internal.Auth{
 		TenantID: config.TenantID,
 		PasswordCredentials: internal.PasswordCredentials{
@@ -93,11 +103,20 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		},
 	}
 
-	client, err := internal.NewClient(config.Region, auth, config.HTTPClient)
+	tokens, err := identifier.GetToken(context.TODO(), auth)
+	if err != nil {
+		return nil, fmt.Errorf("conoha: failed to login: %w", err)
+	}
+
+	client, err := internal.NewClient(config.Region, tokens.Access.Token.ID)
 	if err != nil {
 		return nil, fmt.Errorf("conoha: failed to create client: %w", err)
 	}
 
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
 	return &DNSProvider{config: config, client: client}, nil
 }
 
@@ -107,10 +126,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("conoha: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	id, err := d.client.GetDomainID(authZone)
+	ctx := context.Background()
+
+	id, err := d.client.GetDomainID(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("conoha: failed to get domain ID: %w", err)
 	}
@@ -122,7 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:  d.config.TTL,
 	}
 
-	err = d.client.CreateRecord(id, record)
+	err = d.client.CreateRecord(ctx, id, record)
 	if err != nil {
 		return fmt.Errorf("conoha: failed to create record: %w", err)
 	}
@@ -136,20 +157,22 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("conoha: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	domID, err := d.client.GetDomainID(authZone)
+	ctx := context.Background()
+
+	domID, err := d.client.GetDomainID(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("conoha: failed to get domain ID: %w", err)
 	}
 
-	recID, err := d.client.GetRecordID(domID, info.EffectiveFQDN, "TXT", info.Value)
+	recID, err := d.client.GetRecordID(ctx, domID, info.EffectiveFQDN, "TXT", info.Value)
 	if err != nil {
 		return fmt.Errorf("conoha: failed to get record ID: %w", err)
 	}
 
-	err = d.client.DeleteRecord(domID, recID)
+	err = d.client.DeleteRecord(ctx, domID, recID)
 	if err != nil {
 		return fmt.Errorf("conoha: failed to delete record: %w", err)
 	}
diff --git a/providers/dns/conoha/conoha_test.go b/providers/dns/conoha/conoha_test.go
index 75e38b7f..8d8197f3 100644
--- a/providers/dns/conoha/conoha_test.go
+++ b/providers/dns/conoha/conoha_test.go
@@ -29,7 +29,7 @@ func TestNewDNSProvider(t *testing.T) {
 				EnvAPIUsername: "api_username",
 				EnvAPIPassword: "api_password",
 			},
-			expected: `conoha: failed to create client: failed to login: HTTP request failed with status code 401: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`,
+			expected: `conoha: failed to login: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`,
 		},
 		{
 			desc: "missing credentials",
@@ -99,7 +99,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
 	}{
 		{
 			desc:     "complete credentials, but login failed",
-			expected: `conoha: failed to create client: failed to login: HTTP request failed with status code 401: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`,
+			expected: `conoha: failed to login: unexpected status code: [status code: 401] body: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`,
 			tenant:   "tenant_id",
 			username: "api_username",
 			password: "api_password",
diff --git a/providers/dns/conoha/internal/client.go b/providers/dns/conoha/internal/client.go
index f8a5e192..87fbe5a0 100644
--- a/providers/dns/conoha/internal/client.go
+++ b/providers/dns/conoha/internal/client.go
@@ -2,121 +2,45 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
-const (
-	identityBaseURL   = "https://identity.%s.conoha.io"
-	dnsServiceBaseURL = "https://dns-service.%s.conoha.io"
-)
-
-// IdentityRequest is an authentication request body.
-type IdentityRequest struct {
-	Auth Auth `json:"auth"`
-}
-
-// Auth is an authentication information.
-type Auth struct {
-	TenantID            string              `json:"tenantId"`
-	PasswordCredentials PasswordCredentials `json:"passwordCredentials"`
-}
-
-// PasswordCredentials is API-user's credentials.
-type PasswordCredentials struct {
-	Username string `json:"username"`
-	Password string `json:"password"`
-}
-
-// IdentityResponse is an authentication response body.
-type IdentityResponse struct {
-	Access Access `json:"access"`
-}
-
-// Access is an identity information.
-type Access struct {
-	Token Token `json:"token"`
-}
-
-// Token is an api access token.
-type Token struct {
-	ID string `json:"id"`
-}
-
-// DomainListResponse is a response of a domain listing request.
-type DomainListResponse struct {
-	Domains []Domain `json:"domains"`
-}
-
-// Domain is a hosted domain entry.
-type Domain struct {
-	ID   string `json:"id"`
-	Name string `json:"name"`
-}
-
-// RecordListResponse is a response of record listing request.
-type RecordListResponse struct {
-	Records []Record `json:"records"`
-}
-
-// Record is a record entry.
-type Record struct {
-	ID   string `json:"id,omitempty"`
-	Name string `json:"name"`
-	Type string `json:"type"`
-	Data string `json:"data"`
-	TTL  int    `json:"ttl"`
-}
+const dnsServiceBaseURL = "https://dns-service.%s.conoha.io"
 
 // Client is a ConoHa API client.
 type Client struct {
-	token      string
-	endpoint   string
-	httpClient *http.Client
+	token string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient returns a client instance logged into the ConoHa service.
-func NewClient(region string, auth Auth, httpClient *http.Client) (*Client, error) {
-	if httpClient == nil {
-		httpClient = &http.Client{}
-	}
-
-	c := &Client{httpClient: httpClient}
-
-	c.endpoint = fmt.Sprintf(identityBaseURL, region)
-
-	identity, err := c.getIdentity(auth)
-	if err != nil {
-		return nil, fmt.Errorf("failed to login: %w", err)
-	}
-
-	c.token = identity.Access.Token.ID
-	c.endpoint = fmt.Sprintf(dnsServiceBaseURL, region)
-
-	return c, nil
-}
-
-func (c *Client) getIdentity(auth Auth) (*IdentityResponse, error) {
-	req := &IdentityRequest{Auth: auth}
-
-	identity := &IdentityResponse{}
-
-	err := c.do(http.MethodPost, "/v2.0/tokens", req, identity)
+func NewClient(region string, token string) (*Client, error) {
+	baseURL, err := url.Parse(fmt.Sprintf(dnsServiceBaseURL, region))
 	if err != nil {
 		return nil, err
 	}
 
-	return identity, nil
+	return &Client{
+		token:      token,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}, nil
 }
 
 // GetDomainID returns an ID of specified domain.
-func (c *Client) GetDomainID(domainName string) (string, error) {
-	domainList := &DomainListResponse{}
-
-	err := c.do(http.MethodGet, "/v1/domains", nil, domainList)
+func (c *Client) GetDomainID(ctx context.Context, domainName string) (string, error) {
+	domainList, err := c.getDomains(ctx)
 	if err != nil {
 		return "", err
 	}
@@ -126,14 +50,32 @@ func (c *Client) GetDomainID(domainName string) (string, error) {
 			return domain.ID, nil
 		}
 	}
+
 	return "", fmt.Errorf("no such domain: %s", domainName)
 }
 
-// GetRecordID returns an ID of specified record.
-func (c *Client) GetRecordID(domainID, recordName, recordType, data string) (string, error) {
-	recordList := &RecordListResponse{}
+// https://www.conoha.jp/docs/paas-dns-list-domains.php
+func (c *Client) getDomains(ctx context.Context) (*DomainListResponse, error) {
+	endpoint := c.baseURL.JoinPath("v1", "domains")
 
-	err := c.do(http.MethodGet, fmt.Sprintf("/v1/domains/%s/records", domainID), nil, recordList)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	domainList := &DomainListResponse{}
+
+	err = c.do(req, domainList)
+	if err != nil {
+		return nil, err
+	}
+
+	return domainList, nil
+}
+
+// GetRecordID returns an ID of specified record.
+func (c *Client) GetRecordID(ctx context.Context, domainID, recordName, recordType, data string) (string, error) {
+	recordList, err := c.getRecords(ctx, domainID)
 	if err != nil {
 		return "", err
 	}
@@ -143,63 +85,119 @@ func (c *Client) GetRecordID(domainID, recordName, recordType, data string) (str
 			return record.ID, nil
 		}
 	}
+
 	return "", errors.New("no such record")
 }
 
+// https://www.conoha.jp/docs/paas-dns-list-records-in-a-domain.php
+func (c *Client) getRecords(ctx context.Context, domainID string) (*RecordListResponse, error) {
+	endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	recordList := &RecordListResponse{}
+
+	err = c.do(req, recordList)
+	if err != nil {
+		return nil, err
+	}
+
+	return recordList, nil
+}
+
 // CreateRecord adds new record.
-func (c *Client) CreateRecord(domainID string, record Record) error {
-	return c.do(http.MethodPost, fmt.Sprintf("/v1/domains/%s/records", domainID), record, nil)
+func (c *Client) CreateRecord(ctx context.Context, domainID string, record Record) error {
+	_, err := c.createRecord(ctx, domainID, record)
+	return err
+}
+
+// https://www.conoha.jp/docs/paas-dns-create-record.php
+func (c *Client) createRecord(ctx context.Context, domainID string, record Record) (*Record, error) {
+	endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return nil, err
+	}
+
+	newRecord := &Record{}
+	err = c.do(req, newRecord)
+	if err != nil {
+		return nil, err
+	}
+
+	return newRecord, nil
 }
 
 // DeleteRecord removes specified record.
-func (c *Client) DeleteRecord(domainID, recordID string) error {
-	return c.do(http.MethodDelete, fmt.Sprintf("/v1/domains/%s/records/%s", domainID, recordID), nil, nil)
+// https://www.conoha.jp/docs/paas-dns-delete-a-record.php
+func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID string) error {
+	endpoint := c.baseURL.JoinPath("v1", "domains", domainID, "records", recordID)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
 }
 
-func (c *Client) do(method, path string, payload, result interface{}) error {
-	body := bytes.NewReader(nil)
-
-	if payload != nil {
-		bodyBytes, err := json.Marshal(payload)
-		if err != nil {
-			return err
-		}
-		body = bytes.NewReader(bodyBytes)
+func (c *Client) do(req *http.Request, result any) error {
+	if c.token != "" {
+		req.Header.Set("X-Auth-Token", c.token)
 	}
 
-	req, err := http.NewRequest(method, c.endpoint+path, body)
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("X-Auth-Token", c.token)
-
-	resp, err := c.httpClient.Do(req)
-	if err != nil {
-		return err
-	}
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		respBody, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return err
-		}
-		defer resp.Body.Close()
-
-		return fmt.Errorf("HTTP request failed with status code %d: %s", resp.StatusCode, string(respBody))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	if result != nil {
-		respBody, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return err
-		}
-		defer resp.Body.Close()
+	if result == nil {
+		return nil
+	}
 
-		return json.Unmarshal(respBody, result)
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/conoha/internal/client_test.go b/providers/dns/conoha/internal/client_test.go
index 44f16b1c..bc27ec21 100644
--- a/providers/dns/conoha/internal/client_test.go
+++ b/providers/dns/conoha/internal/client_test.go
@@ -1,30 +1,71 @@
 package internal
 
 import (
+	"bytes"
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func setupTest(t *testing.T) (*http.ServeMux, *Client) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	client := &Client{
-		token:      "secret",
-		endpoint:   server.URL,
-		httpClient: server.Client(),
-	}
+	client, err := NewClient("tyo1", "secret")
+	require.NoError(t, err)
 
-	return mux, client
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client, mux
+}
+
+func writeFixtureHandler(method, filename string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		writeFixture(rw, filename)
+	}
+}
+
+func writeBodyHandler(method, content string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+		_, err := fmt.Fprint(rw, content)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+}
+
+func writeFixture(rw http.ResponseWriter, filename string) {
+	file, err := os.Open(filepath.Join("fixtures", filename))
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer func() { _ = file.Close() }()
+
+	_, _ = io.Copy(rw, file)
 }
 
 func TestClient_GetDomainID(t *testing.T) {
@@ -42,91 +83,30 @@ func TestClient_GetDomainID(t *testing.T) {
 		{
 			desc:       "success",
 			domainName: "domain1.com.",
-			handler: func(rw http.ResponseWriter, req *http.Request) {
-				if req.Method != http.MethodGet {
-					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
-					return
-				}
-
-				content := `
-{
-    "domains":[
-      {
-        "id": "09494b72-b65b-4297-9efb-187f65a0553e",
-        "name": "domain1.com.",
-        "ttl": 3600,
-        "serial": 1351800668,
-        "email": "nsadmin@example.org",
-        "gslb": 0,
-        "created_at": "2012-11-01T20:11:08.000000",
-        "updated_at": null,
-        "description": "memo"
-      },
-      {
-        "id": "cf661142-e577-40b5-b3eb-75795cdc0cd7",
-        "name": "domain2.com.",
-        "ttl": 7200,
-        "serial": 1351800670,
-        "email": "nsadmin2@example.org",
-        "gslb": 1,
-        "created_at": "2012-11-01T20:11:08.000000",
-        "updated_at": "2012-12-01T20:11:08.000000",
-        "description": "memomemo"
-      }
-    ]
-}
-`
-				_, err := fmt.Fprint(rw, content)
-				if err != nil {
-					http.Error(rw, err.Error(), http.StatusInternalServerError)
-					return
-				}
-			},
-			expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"},
+			handler:    writeFixtureHandler(http.MethodGet, "domains_GET.json"),
+			expected:   expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"},
 		},
 		{
 			desc:       "non existing domain",
 			domainName: "domain1.com.",
-			handler: func(rw http.ResponseWriter, req *http.Request) {
-				if req.Method != http.MethodGet {
-					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
-					return
-				}
-
-				_, err := fmt.Fprint(rw, "{}")
-				if err != nil {
-					http.Error(rw, err.Error(), http.StatusInternalServerError)
-					return
-				}
-			},
-			expected: expected{error: true},
+			handler:    writeBodyHandler(http.MethodGet, "{}"),
+			expected:   expected{error: true},
 		},
 		{
 			desc:       "marshaling error",
 			domainName: "domain1.com.",
-			handler: func(rw http.ResponseWriter, req *http.Request) {
-				if req.Method != http.MethodGet {
-					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
-					return
-				}
-
-				_, err := fmt.Fprint(rw, "[]")
-				if err != nil {
-					http.Error(rw, err.Error(), http.StatusInternalServerError)
-					return
-				}
-			},
-			expected: expected{error: true},
+			handler:    writeBodyHandler(http.MethodGet, "[]"),
+			expected:   expected{error: true},
 		},
 	}
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			mux, client := setupTest(t)
+			client, mux := setupTest(t)
 
 			mux.Handle("/v1/domains", test.handler)
 
-			domainID, err := client.GetDomainID(test.domainName)
+			domainID, err := client.GetDomainID(context.Background(), test.domainName)
 
 			if test.expected.error {
 				require.Error(t, err)
@@ -140,15 +120,15 @@ func TestClient_GetDomainID(t *testing.T) {
 
 func TestClient_CreateRecord(t *testing.T) {
 	testCases := []struct {
-		desc        string
-		handler     http.HandlerFunc
-		expectError bool
+		desc    string
+		handler http.HandlerFunc
+		assert  require.ErrorAssertionFunc
 	}{
 		{
 			desc: "success",
 			handler: func(rw http.ResponseWriter, req *http.Request) {
 				if req.Method != http.MethodPost {
-					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
+					http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
 					return
 				}
 
@@ -157,31 +137,34 @@ func TestClient_CreateRecord(t *testing.T) {
 					http.Error(rw, err.Error(), http.StatusBadRequest)
 					return
 				}
-				defer req.Body.Close()
+				defer func() { _ = req.Body.Close() }()
 
-				if string(raw) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` {
+				if string(bytes.TrimSpace(raw)) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` {
 					http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
 					return
 				}
+
+				writeFixture(rw, "domains-records_POST.json")
 			},
+			assert: require.NoError,
 		},
 		{
 			desc: "bad request",
 			handler: func(rw http.ResponseWriter, req *http.Request) {
 				if req.Method != http.MethodPost {
-					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
+					http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
 					return
 				}
 
 				http.Error(rw, "OOPS", http.StatusBadRequest)
 			},
-			expectError: true,
+			assert: require.Error,
 		},
 	}
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			mux, client := setupTest(t)
+			client, mux := setupTest(t)
 
 			mux.Handle("/v1/domains/lego/records", test.handler)
 
@@ -194,13 +177,36 @@ func TestClient_CreateRecord(t *testing.T) {
 				TTL:  300,
 			}
 
-			err := client.CreateRecord(domainID, record)
-
-			if test.expectError {
-				require.Error(t, err)
-			} else {
-				require.NoError(t, err)
-			}
+			err := client.CreateRecord(context.Background(), domainID, record)
+			test.assert(t, err)
 		})
 	}
 }
+
+func TestClient_GetRecordID(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records",
+		writeFixtureHandler(http.MethodGet, "domains-records_GET.json"))
+
+	recordID, err := client.GetRecordID(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153")
+	require.NoError(t, err)
+
+	assert.Equal(t, "2e32e609-3a4f-45ba-bdef-e50eacd345ad", recordID)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodDelete {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		rw.WriteHeader(http.StatusOK)
+	})
+
+	err := client.DeleteRecord(context.Background(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad")
+	require.NoError(t, err)
+}
diff --git a/providers/dns/conoha/internal/fixtures/domains-records_GET.json b/providers/dns/conoha/internal/fixtures/domains-records_GET.json
new file mode 100644
index 00000000..6b8ca263
--- /dev/null
+++ b/providers/dns/conoha/internal/fixtures/domains-records_GET.json
@@ -0,0 +1,43 @@
+{
+  "records": [
+    {
+      "id": "2e32e609-3a4f-45ba-bdef-e50eacd345ad",
+      "name": "www.example.com.",
+      "type": "A",
+      "ttl": 3600,
+      "created_at": "2012-11-02T19:56:26.000000",
+      "updated_at": "2012-11-04T13:22:36.000000",
+      "data": "15.185.172.153",
+      "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+      "version": 1,
+      "gslb_region": "JP",
+      "gslb_weight": 250,
+      "gslb_check": 12300
+    },
+    {
+      "id": "8e9ecf3e-fb92-4a3a-a8ae-7596f167bea3",
+      "name": "host1.example.com.",
+      "type": "A",
+      "ttl": 3600,
+      "created_at": "2012-11-04T13:57:50.000000",
+      "updated_at": null,
+      "data": "15.185.172.154",
+      "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+      "version": 1,
+      "gslb_region": "US",
+      "gslb_weight": 220,
+      "gslb_check": 12200
+    },
+    {
+      "id": "4ad19089-3e62-40f8-9482-17cc8ccb92cb",
+      "name": "web.example.com.",
+      "type": "CNAME",
+      "ttl": 3600,
+      "created_at": "2012-11-04T13:58:16.393735",
+      "updated_at": null,
+      "data": "www.example.com.",
+      "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+      "version": 1
+    }
+  ]
+}
diff --git a/providers/dns/conoha/internal/fixtures/domains-records_POST.json b/providers/dns/conoha/internal/fixtures/domains-records_POST.json
new file mode 100644
index 00000000..832d7b22
--- /dev/null
+++ b/providers/dns/conoha/internal/fixtures/domains-records_POST.json
@@ -0,0 +1,13 @@
+{
+  "id": "2e32e609-3a4f-45ba-bdef-e50eacd345ad",
+  "name": "www.example.com.",
+  "type": "A",
+  "created_at": "2012-11-02T19:56:26.366792",
+  "updated_at": null,
+  "domain_id": "89acac79-38e7-497d-807c-a011e1310438",
+  "ttl": null,
+  "data": "192.0.2.3",
+  "gslb_check": 1,
+  "gslb_region": "JP",
+  "gslb_weight": 250
+}
diff --git a/providers/dns/conoha/internal/fixtures/domains_GET.json b/providers/dns/conoha/internal/fixtures/domains_GET.json
new file mode 100644
index 00000000..bafc4585
--- /dev/null
+++ b/providers/dns/conoha/internal/fixtures/domains_GET.json
@@ -0,0 +1,26 @@
+{
+  "domains":[
+    {
+      "id": "09494b72-b65b-4297-9efb-187f65a0553e",
+      "name": "domain1.com.",
+      "ttl": 3600,
+      "serial": 1351800668,
+      "email": "nsadmin@example.org",
+      "gslb": 0,
+      "created_at": "2012-11-01T20:11:08.000000",
+      "updated_at": null,
+      "description": "memo"
+    },
+    {
+      "id": "cf661142-e577-40b5-b3eb-75795cdc0cd7",
+      "name": "domain2.com.",
+      "ttl": 7200,
+      "serial": 1351800670,
+      "email": "nsadmin2@example.org",
+      "gslb": 1,
+      "created_at": "2012-11-01T20:11:08.000000",
+      "updated_at": "2012-12-01T20:11:08.000000",
+      "description": "memomemo"
+    }
+  ]
+}
diff --git a/providers/dns/conoha/internal/fixtures/tokens_POST.json b/providers/dns/conoha/internal/fixtures/tokens_POST.json
new file mode 100644
index 00000000..ac917186
--- /dev/null
+++ b/providers/dns/conoha/internal/fixtures/tokens_POST.json
@@ -0,0 +1,17 @@
+{
+  "access": {
+    "token": {
+      "issued_at": "2015-05-19T07:08:21.927295",
+      "expires": "2015-05-20T07:08:21Z",
+      "id": "sample00d88246078f2bexample788f7",
+      "tenant": {
+        "name": "example00000000",
+        "enabled": true,
+        "tyo1_image_size": "550GB"
+      },
+      "endpoints_links": [],
+      "type": "mailhosting",
+      "name": "Mail Hosting Service"
+    }
+  }
+}
diff --git a/providers/dns/conoha/internal/identity.go b/providers/dns/conoha/internal/identity.go
new file mode 100644
index 00000000..995d55bb
--- /dev/null
+++ b/providers/dns/conoha/internal/identity.go
@@ -0,0 +1,82 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const identityBaseURL = "https://identity.%s.conoha.io"
+
+type Identifier struct {
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewIdentifier creates a new Identifier.
+func NewIdentifier(region string) (*Identifier, error) {
+	baseURL, err := url.Parse(fmt.Sprintf(identityBaseURL, region))
+	if err != nil {
+		return nil, err
+	}
+
+	return &Identifier{
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}, nil
+}
+
+// GetToken gets valid token information.
+// https://www.conoha.jp/docs/identity-post_tokens.php
+func (c *Identifier) GetToken(ctx context.Context, auth Auth) (*IdentityResponse, error) {
+	endpoint := c.baseURL.JoinPath("v2.0", "tokens")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, &IdentityRequest{Auth: auth})
+	if err != nil {
+		return nil, err
+	}
+
+	identity := &IdentityResponse{}
+
+	err = c.do(req, identity)
+	if err != nil {
+		return nil, err
+	}
+
+	return identity, nil
+}
+
+func (c *Identifier) do(req *http.Request, result any) error {
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
diff --git a/providers/dns/conoha/internal/identity_test.go b/providers/dns/conoha/internal/identity_test.go
new file mode 100644
index 00000000..027c7f2c
--- /dev/null
+++ b/providers/dns/conoha/internal/identity_test.go
@@ -0,0 +1,41 @@
+package internal
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewClient(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	identifier, err := NewIdentifier("tyo1")
+	require.NoError(t, err)
+
+	identifier.HTTPClient = server.Client()
+	identifier.baseURL, _ = url.Parse(server.URL)
+
+	mux.HandleFunc("/v2.0/tokens", writeFixtureHandler(http.MethodPost, "tokens_POST.json"))
+
+	auth := Auth{
+		TenantID: "487727e3921d44e3bfe7ebb337bf085e",
+		PasswordCredentials: PasswordCredentials{
+			Username: "ConoHa",
+			Password: "paSSword123456#$%",
+		},
+	}
+
+	token, err := identifier.GetToken(context.Background(), auth)
+	require.NoError(t, err)
+
+	expected := &IdentityResponse{Access: Access{Token: Token{ID: "sample00d88246078f2bexample788f7"}}}
+
+	assert.Equal(t, expected, token)
+}
diff --git a/providers/dns/conoha/internal/types.go b/providers/dns/conoha/internal/types.go
new file mode 100644
index 00000000..7749aded
--- /dev/null
+++ b/providers/dns/conoha/internal/types.go
@@ -0,0 +1,58 @@
+package internal
+
+// IdentityRequest is an authentication request body.
+type IdentityRequest struct {
+	Auth Auth `json:"auth"`
+}
+
+// Auth is an authentication information.
+type Auth struct {
+	TenantID            string              `json:"tenantId"`
+	PasswordCredentials PasswordCredentials `json:"passwordCredentials"`
+}
+
+// PasswordCredentials is API-user's credentials.
+type PasswordCredentials struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+// IdentityResponse is an authentication response body.
+type IdentityResponse struct {
+	Access Access `json:"access"`
+}
+
+// Access is an identity information.
+type Access struct {
+	Token Token `json:"token"`
+}
+
+// Token is an api access token.
+type Token struct {
+	ID string `json:"id"`
+}
+
+// DomainListResponse is a response of a domain listing request.
+type DomainListResponse struct {
+	Domains []Domain `json:"domains"`
+}
+
+// Domain is a hosted domain entry.
+type Domain struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// RecordListResponse is a response of record listing request.
+type RecordListResponse struct {
+	Records []Record `json:"records"`
+}
+
+// Record is a record entry.
+type Record struct {
+	ID   string `json:"id,omitempty"`
+	Name string `json:"name"`
+	Type string `json:"type"`
+	Data string `json:"data"`
+	TTL  int    `json:"ttl"`
+}
diff --git a/providers/dns/constellix/constellix.go b/providers/dns/constellix/constellix.go
index 6f43b531..17ca1ab6 100644
--- a/providers/dns/constellix/constellix.go
+++ b/providers/dns/constellix/constellix.go
@@ -2,6 +2,7 @@
 package constellix
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -101,10 +102,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("constellix: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone))
+	ctx := context.Background()
+
+	dom, err := d.client.Domains.GetByName(ctx, dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err)
 	}
@@ -114,7 +117,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("constellix: %w", err)
 	}
 
-	records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName)
+	records, err := d.client.TxtRecords.Search(ctx, dom.ID, internal.Exact, recordName)
 	if err != nil {
 		return fmt.Errorf("constellix: failed to search TXT records: %w", err)
 	}
@@ -125,10 +128,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	// TXT record entry already existing
 	if len(records) == 1 {
-		return d.appendRecordValue(dom, records[0].ID, info.Value)
+		return d.appendRecordValue(ctx, dom, records[0].ID, info.Value)
 	}
 
-	err = d.createRecord(dom, info.EffectiveFQDN, recordName, info.Value)
+	err = d.createRecord(ctx, dom, info.EffectiveFQDN, recordName, info.Value)
 	if err != nil {
 		return fmt.Errorf("constellix: %w", err)
 	}
@@ -142,10 +145,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("constellix: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone))
+	ctx := context.Background()
+
+	dom, err := d.client.Domains.GetByName(ctx, dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("constellix: failed to get domain (%s): %w", authZone, err)
 	}
@@ -155,7 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("constellix: %w", err)
 	}
 
-	records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName)
+	records, err := d.client.TxtRecords.Search(ctx, dom.ID, internal.Exact, recordName)
 	if err != nil {
 		return fmt.Errorf("constellix: failed to search TXT records: %w", err)
 	}
@@ -168,7 +173,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return nil
 	}
 
-	record, err := d.client.TxtRecords.Get(dom.ID, records[0].ID)
+	record, err := d.client.TxtRecords.Get(ctx, dom.ID, records[0].ID)
 	if err != nil {
 		return fmt.Errorf("constellix: failed to get TXT records: %w", err)
 	}
@@ -179,14 +184,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	// only 1 record value, the whole record must be deleted.
 	if len(record.Value) == 1 {
-		_, err = d.client.TxtRecords.Delete(dom.ID, record.ID)
+		_, err = d.client.TxtRecords.Delete(ctx, dom.ID, record.ID)
 		if err != nil {
 			return fmt.Errorf("constellix: failed to delete TXT records: %w", err)
 		}
 		return nil
 	}
 
-	err = d.removeRecordValue(dom, record, info.Value)
+	err = d.removeRecordValue(ctx, dom, record, info.Value)
 	if err != nil {
 		return fmt.Errorf("constellix: %w", err)
 	}
@@ -194,7 +199,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	return nil
 }
 
-func (d *DNSProvider) createRecord(dom internal.Domain, fqdn, recordName, value string) error {
+func (d *DNSProvider) createRecord(ctx context.Context, dom internal.Domain, fqdn, recordName, value string) error {
 	request := internal.RecordRequest{
 		Name: recordName,
 		TTL:  d.config.TTL,
@@ -203,7 +208,7 @@ func (d *DNSProvider) createRecord(dom internal.Domain, fqdn, recordName, value
 		},
 	}
 
-	_, err := d.client.TxtRecords.Create(dom.ID, request)
+	_, err := d.client.TxtRecords.Create(ctx, dom.ID, request)
 	if err != nil {
 		return fmt.Errorf("failed to create TXT record %s: %w", fqdn, err)
 	}
@@ -211,8 +216,8 @@ func (d *DNSProvider) createRecord(dom internal.Domain, fqdn, recordName, value
 	return nil
 }
 
-func (d *DNSProvider) appendRecordValue(dom internal.Domain, recordID int64, value string) error {
-	record, err := d.client.TxtRecords.Get(dom.ID, recordID)
+func (d *DNSProvider) appendRecordValue(ctx context.Context, dom internal.Domain, recordID int64, value string) error {
+	record, err := d.client.TxtRecords.Get(ctx, dom.ID, recordID)
 	if err != nil {
 		return fmt.Errorf("failed to get TXT records: %w", err)
 	}
@@ -227,7 +232,7 @@ func (d *DNSProvider) appendRecordValue(dom internal.Domain, recordID int64, val
 		RoundRobin: append(record.RoundRobin, internal.RecordValue{Value: fmt.Sprintf(`%q`, value)}),
 	}
 
-	_, err = d.client.TxtRecords.Update(dom.ID, record.ID, request)
+	_, err = d.client.TxtRecords.Update(ctx, dom.ID, record.ID, request)
 	if err != nil {
 		return fmt.Errorf("failed to update TXT records: %w", err)
 	}
@@ -235,7 +240,7 @@ func (d *DNSProvider) appendRecordValue(dom internal.Domain, recordID int64, val
 	return nil
 }
 
-func (d *DNSProvider) removeRecordValue(dom internal.Domain, record *internal.Record, value string) error {
+func (d *DNSProvider) removeRecordValue(ctx context.Context, dom internal.Domain, record *internal.Record, value string) error {
 	request := internal.RecordRequest{
 		Name: record.Name,
 		TTL:  record.TTL,
@@ -247,7 +252,7 @@ func (d *DNSProvider) removeRecordValue(dom internal.Domain, record *internal.Re
 		}
 	}
 
-	_, err := d.client.TxtRecords.Update(dom.ID, record.ID, request)
+	_, err := d.client.TxtRecords.Update(ctx, dom.ID, record.ID, request)
 	if err != nil {
 		return fmt.Errorf("failed to update TXT records: %w", err)
 	}
diff --git a/providers/dns/constellix/internal/client.go b/providers/dns/constellix/internal/client.go
index af399fa3..fee0c5a3 100644
--- a/providers/dns/constellix/internal/client.go
+++ b/providers/dns/constellix/internal/client.go
@@ -6,6 +6,9 @@ import (
 	"io"
 	"net/http"
 	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const (
@@ -28,7 +31,7 @@ type Client struct {
 // NewClient Creates a Constellix client.
 func NewClient(httpClient *http.Client) *Client {
 	if httpClient == nil {
-		httpClient = http.DefaultClient
+		httpClient = &http.Client{Timeout: 5 * time.Second}
 	}
 
 	client := &Client{
@@ -48,13 +51,15 @@ type service struct {
 }
 
 // do sends an API request and returns the API response.
-func (c *Client) do(req *http.Request, v interface{}) error {
+func (c *Client) do(req *http.Request, result any) error {
+	req.Header.Set("Accept", "application/json")
 	req.Header.Set("Content-Type", "application/json")
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
+
 	defer func() { _ = resp.Body.Close() }()
 
 	err = checkResponse(resp)
@@ -64,11 +69,11 @@ func (c *Client) do(req *http.Request, v interface{}) error {
 
 	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return fmt.Errorf("failed to read body: %w", err)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	if err = json.Unmarshal(raw, v); err != nil {
-		return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
+	if err = json.Unmarshal(raw, result); err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
@@ -83,21 +88,21 @@ func checkResponse(resp *http.Response) error {
 		return nil
 	}
 
-	data, err := io.ReadAll(resp.Body)
-	if err == nil && data != nil {
-		msg := &APIError{StatusCode: resp.StatusCode}
+	raw, err := io.ReadAll(resp.Body)
+	if err == nil && raw != nil {
+		errAPI := &APIError{StatusCode: resp.StatusCode}
 
-		if json.Unmarshal(data, msg) != nil {
-			return fmt.Errorf("API error: status code: %d: %v", resp.StatusCode, string(data))
+		if json.Unmarshal(raw, errAPI) != nil {
+			return fmt.Errorf("API error: status code: %d: %v", resp.StatusCode, string(raw))
 		}
 
 		switch resp.StatusCode {
 		case http.StatusNotFound:
-			return &NotFound{APIError: msg}
+			return &NotFound{APIError: errAPI}
 		case http.StatusBadRequest:
-			return &BadRequest{APIError: msg}
+			return &BadRequest{APIError: errAPI}
 		default:
-			return msg
+			return errAPI
 		}
 	}
 
diff --git a/providers/dns/constellix/internal/domains.go b/providers/dns/constellix/internal/domains.go
index c6e2480d..485f0d53 100644
--- a/providers/dns/constellix/internal/domains.go
+++ b/providers/dns/constellix/internal/domains.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -13,15 +14,15 @@ type DomainService service
 
 // GetAll domains.
 // https://api-docs.constellix.com/?version=latest#484c3f21-d724-4ee4-a6fa-ab22c8eb9e9b
-func (s *DomainService) GetAll(params *PaginationParameters) ([]Domain, error) {
+func (s *DomainService) GetAll(ctx context.Context, params *PaginationParameters) ([]Domain, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains")
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	if params != nil {
@@ -42,8 +43,8 @@ func (s *DomainService) GetAll(params *PaginationParameters) ([]Domain, error) {
 }
 
 // GetByName Gets domain by name.
-func (s *DomainService) GetByName(domainName string) (Domain, error) {
-	domains, err := s.Search(Exact, domainName)
+func (s *DomainService) GetByName(ctx context.Context, domainName string) (Domain, error) {
+	domains, err := s.Search(ctx, Exact, domainName)
 	if err != nil {
 		return Domain{}, err
 	}
@@ -61,15 +62,15 @@ func (s *DomainService) GetByName(domainName string) (Domain, error) {
 
 // Search searches for a domain by name.
 // https://api-docs.constellix.com/?version=latest#3d7b2679-2209-49f3-b011-b7d24e512008
-func (s *DomainService) Search(filter searchFilter, value string) ([]Domain, error) {
+func (s *DomainService) Search(ctx context.Context, filter searchFilter, value string) ([]Domain, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", "search")
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	query := req.URL.Query()
diff --git a/providers/dns/constellix/internal/domains_test.go b/providers/dns/constellix/internal/domains_test.go
index 5df3f423..1b0779b3 100644
--- a/providers/dns/constellix/internal/domains_test.go
+++ b/providers/dns/constellix/internal/domains_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"io"
 	"net/http"
 	"net/http/httptest"
@@ -47,7 +48,7 @@ func TestDomainService_GetAll(t *testing.T) {
 		}
 	})
 
-	data, err := client.Domains.GetAll(nil)
+	data, err := client.Domains.GetAll(context.Background(), nil)
 	require.NoError(t, err)
 
 	expected := []Domain{
@@ -83,7 +84,7 @@ func TestDomainService_Search(t *testing.T) {
 		}
 	})
 
-	data, err := client.Domains.Search(Exact, "lego.wtf")
+	data, err := client.Domains.Search(context.Background(), Exact, "lego.wtf")
 	require.NoError(t, err)
 
 	expected := []Domain{
diff --git a/providers/dns/constellix/internal/txtrecords.go b/providers/dns/constellix/internal/txtrecords.go
index e9df28e6..7880da4d 100644
--- a/providers/dns/constellix/internal/txtrecords.go
+++ b/providers/dns/constellix/internal/txtrecords.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -14,20 +15,20 @@ type TxtRecordService service
 
 // Create a TXT record.
 // https://api-docs.constellix.com/?version=latest#22e24d5b-9ec0-49a7-b2b0-5ff0a28e71be
-func (s *TxtRecordService) Create(domainID int64, record RecordRequest) ([]Record, error) {
-	body, err := json.Marshal(record)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshall request body: %w", err)
-	}
-
+func (s *TxtRecordService) Create(ctx context.Context, domainID int64, record RecordRequest) ([]Record, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt")
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
+	body, err := json.Marshal(record)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	var records []Record
@@ -41,15 +42,15 @@ func (s *TxtRecordService) Create(domainID int64, record RecordRequest) ([]Recor
 
 // GetAll TXT records.
 // https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2
-func (s *TxtRecordService) GetAll(domainID int64) ([]Record, error) {
+func (s *TxtRecordService) GetAll(ctx context.Context, domainID int64) ([]Record, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt")
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
+		return nil, fmt.Errorf("failed to create endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	var records []Record
@@ -63,15 +64,15 @@ func (s *TxtRecordService) GetAll(domainID int64) ([]Record, error) {
 
 // Get a TXT record.
 // https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2
-func (s *TxtRecordService) Get(domainID, recordID int64) (*Record, error) {
+func (s *TxtRecordService) Get(ctx context.Context, domainID, recordID int64) (*Record, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10))
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	var records Record
@@ -85,20 +86,20 @@ func (s *TxtRecordService) Get(domainID, recordID int64) (*Record, error) {
 
 // Update a TXT record.
 // https://api-docs.constellix.com/?version=latest#d4e9ab2e-fac0-45a6-b0e4-cf62a2d2e3da
-func (s *TxtRecordService) Update(domainID, recordID int64, record RecordRequest) (*SuccessMessage, error) {
-	body, err := json.Marshal(record)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshall request body: %w", err)
-	}
-
+func (s *TxtRecordService) Update(ctx context.Context, domainID, recordID int64, record RecordRequest) (*SuccessMessage, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10))
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
+	body, err := json.Marshal(record)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	var msg SuccessMessage
@@ -112,15 +113,15 @@ func (s *TxtRecordService) Update(domainID, recordID int64, record RecordRequest
 
 // Delete a TXT record.
 // https://api-docs.constellix.com/?version=latest#135947f7-d6c8-481a-83c7-4d387b0bdf9e
-func (s *TxtRecordService) Delete(domainID, recordID int64) (*SuccessMessage, error) {
+func (s *TxtRecordService) Delete(ctx context.Context, domainID, recordID int64) (*SuccessMessage, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10))
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	var msg *SuccessMessage
@@ -134,15 +135,15 @@ func (s *TxtRecordService) Delete(domainID, recordID int64) (*SuccessMessage, er
 
 // Search searches for a TXT record by name.
 // https://api-docs.constellix.com/?version=latest#81003e4f-bd3f-413f-a18d-6d9d18f10201
-func (s *TxtRecordService) Search(domainID int64, filter searchFilter, value string) ([]Record, error) {
+func (s *TxtRecordService) Search(ctx context.Context, domainID int64, filter searchFilter, value string) ([]Record, error) {
 	endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", "search")
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request endpoint: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	query := req.URL.Query()
diff --git a/providers/dns/constellix/internal/txtrecords_test.go b/providers/dns/constellix/internal/txtrecords_test.go
index e0c4de6d..7adc4af5 100644
--- a/providers/dns/constellix/internal/txtrecords_test.go
+++ b/providers/dns/constellix/internal/txtrecords_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"io"
 	"net/http"
@@ -34,7 +35,7 @@ func TestTxtRecordService_Create(t *testing.T) {
 		}
 	})
 
-	records, err := client.TxtRecords.Create(12345, RecordRequest{})
+	records, err := client.TxtRecords.Create(context.Background(), 12345, RecordRequest{})
 	require.NoError(t, err)
 
 	recordsJSON, err := json.Marshal(records)
@@ -69,7 +70,7 @@ func TestTxtRecordService_GetAll(t *testing.T) {
 		}
 	})
 
-	records, err := client.TxtRecords.GetAll(12345)
+	records, err := client.TxtRecords.GetAll(context.Background(), 12345)
 	require.NoError(t, err)
 
 	recordsJSON, err := json.Marshal(records)
@@ -104,7 +105,7 @@ func TestTxtRecordService_Get(t *testing.T) {
 		}
 	})
 
-	record, err := client.TxtRecords.Get(12345, 6789)
+	record, err := client.TxtRecords.Get(context.Background(), 12345, 6789)
 	require.NoError(t, err)
 
 	expected := &Record{
@@ -145,7 +146,7 @@ func TestTxtRecordService_Update(t *testing.T) {
 		}
 	})
 
-	msg, err := client.TxtRecords.Update(12345, 6789, RecordRequest{})
+	msg, err := client.TxtRecords.Update(context.Background(), 12345, 6789, RecordRequest{})
 	require.NoError(t, err)
 
 	expected := &SuccessMessage{Success: "Record  updated successfully"}
@@ -168,7 +169,7 @@ func TestTxtRecordService_Delete(t *testing.T) {
 		}
 	})
 
-	msg, err := client.TxtRecords.Delete(12345, 6789)
+	msg, err := client.TxtRecords.Delete(context.Background(), 12345, 6789)
 	require.NoError(t, err)
 
 	expected := &SuccessMessage{Success: "Record  deleted successfully"}
@@ -198,7 +199,7 @@ func TestTxtRecordService_Search(t *testing.T) {
 		}
 	})
 
-	records, err := client.TxtRecords.Search(12345, Exact, "test")
+	records, err := client.TxtRecords.Search(context.Background(), 12345, Exact, "test")
 	require.NoError(t, err)
 
 	recordsJSON, err := json.Marshal(records)
diff --git a/providers/dns/constellix/internal/model.go b/providers/dns/constellix/internal/types.go
similarity index 100%
rename from providers/dns/constellix/internal/model.go
rename to providers/dns/constellix/internal/types.go
diff --git a/providers/dns/desec/desec.go b/providers/dns/desec/desec.go
index e391fd38..1c88ad08 100644
--- a/providers/dns/desec/desec.go
+++ b/providers/dns/desec/desec.go
@@ -106,7 +106,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("desec: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("desec: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -156,7 +156,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("desec: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("desec: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
diff --git a/providers/dns/designate/designate.go b/providers/dns/designate/designate.go
index a8502819..da3b6f78 100644
--- a/providers/dns/designate/designate.go
+++ b/providers/dns/designate/designate.go
@@ -128,12 +128,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("designate: couldn't get zone ID in Present: %w", err)
+		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	zoneID, err := d.getZoneID(authZone)
 	if err != nil {
-		return fmt.Errorf("designate: %w", err)
+		return fmt.Errorf("designate: couldn't get zone ID in Present: %w", err)
 	}
 
 	// use mutex to prevent race condition between creating the record and verifying it
@@ -168,7 +168,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	zoneID, err := d.getZoneID(authZone)
diff --git a/providers/dns/designate/designate_test.go b/providers/dns/designate/designate_test.go
index f80fee1c..881faeef 100644
--- a/providers/dns/designate/designate_test.go
+++ b/providers/dns/designate/designate_test.go
@@ -286,6 +286,9 @@ func setupTestProvider(t *testing.T) string {
 	t.Helper()
 
 	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
 	mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
 		_, _ = w.Write([]byte(`{
 	"access": {
@@ -319,9 +322,6 @@ func setupTestProvider(t *testing.T) string {
 		w.WriteHeader(http.StatusOK)
 	})
 
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	return server.URL
 }
 
diff --git a/providers/dns/digitalocean/client.go b/providers/dns/digitalocean/client.go
deleted file mode 100644
index 82580e78..00000000
--- a/providers/dns/digitalocean/client.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package digitalocean
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-
-	"github.com/go-acme/lego/v4/challenge/dns01"
-)
-
-const defaultBaseURL = "https://api.digitalocean.com"
-
-// txtRecordResponse represents a response from DO's API after making a TXT record.
-type txtRecordResponse struct {
-	DomainRecord record `json:"domain_record"`
-}
-
-type record struct {
-	ID   int    `json:"id,omitempty"`
-	Type string `json:"type,omitempty"`
-	Name string `json:"name,omitempty"`
-	Data string `json:"data,omitempty"`
-	TTL  int    `json:"ttl,omitempty"`
-}
-
-type apiError struct {
-	ID      string `json:"id"`
-	Message string `json:"message"`
-}
-
-func (d *DNSProvider) removeTxtRecord(domain string, recordID int) error {
-	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
-	if err != nil {
-		return fmt.Errorf("could not determine zone for domain %q: %w", domain, err)
-	}
-
-	reqURL := fmt.Sprintf("%s/v2/domains/%s/records/%d", d.config.BaseURL, dns01.UnFqdn(authZone), recordID)
-	req, err := d.newRequest(http.MethodDelete, reqURL, nil)
-	if err != nil {
-		return err
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return readError(req, resp)
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) addTxtRecord(fqdn, value string) (*txtRecordResponse, error) {
-	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(fqdn))
-	if err != nil {
-		return nil, fmt.Errorf("could not determine zone for domain %q: %w", fqdn, err)
-	}
-
-	reqData := record{Type: "TXT", Name: fqdn, Data: value, TTL: d.config.TTL}
-	body, err := json.Marshal(reqData)
-	if err != nil {
-		return nil, err
-	}
-
-	reqURL := fmt.Sprintf("%s/v2/domains/%s/records", d.config.BaseURL, dns01.UnFqdn(authZone))
-	req, err := d.newRequest(http.MethodPost, reqURL, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return nil, readError(req, resp)
-	}
-
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.New(toUnreadableBodyMessage(req, content))
-	}
-
-	// Everything looks good; but we'll need the ID later to delete the record
-	respData := &txtRecordResponse{}
-	err = json.Unmarshal(content, respData)
-	if err != nil {
-		return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(req, content))
-	}
-
-	return respData, nil
-}
-
-func (d *DNSProvider) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) {
-	req, err := http.NewRequest(method, reqURL, body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.config.AuthToken))
-
-	return req, nil
-}
-
-func readError(req *http.Request, resp *http.Response) error {
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return errors.New(toUnreadableBodyMessage(req, content))
-	}
-
-	var errInfo apiError
-	err = json.Unmarshal(content, &errInfo)
-	if err != nil {
-		return fmt.Errorf("apiError unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content))
-	}
-
-	return fmt.Errorf("HTTP %d: %s: %s", resp.StatusCode, errInfo.ID, errInfo.Message)
-}
-
-func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
-	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody))
-}
diff --git a/providers/dns/digitalocean/digitalocean.go b/providers/dns/digitalocean/digitalocean.go
index df27244d..dd790faa 100644
--- a/providers/dns/digitalocean/digitalocean.go
+++ b/providers/dns/digitalocean/digitalocean.go
@@ -2,14 +2,17 @@
 package digitalocean
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"sync"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/digitalocean/internal"
 )
 
 // Environment variables names.
@@ -38,7 +41,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		BaseURL:            env.GetOrDefaultString(EnvAPIUrl, defaultBaseURL),
+		BaseURL:            env.GetOrDefaultString(EnvAPIUrl, internal.DefaultBaseURL),
 		TTL:                env.GetOrDefaultInt(EnvTTL, 30),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
@@ -50,7 +53,9 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config      *Config
+	config *Config
+	client *internal.Client
+
 	recordIDs   map[string]int
 	recordIDsMu sync.Mutex
 }
@@ -80,12 +85,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("digitalocean: credentials missing")
 	}
 
-	if config.BaseURL == "" {
-		config.BaseURL = defaultBaseURL
+	client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken))
+
+	if config.BaseURL != "" {
+		var err error
+		client.BaseURL, err = url.Parse(config.BaseURL)
+		if err != nil {
+			return nil, fmt.Errorf("digitalocean: %w", err)
+		}
 	}
 
 	return &DNSProvider{
 		config:    config,
+		client:    client,
 		recordIDs: make(map[string]int),
 	}, nil
 }
@@ -100,7 +112,14 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	respData, err := d.addTxtRecord(info.EffectiveFQDN, info.Value)
+	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN))
+	if err != nil {
+		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
+	}
+
+	record := internal.Record{Type: "TXT", Name: info.EffectiveFQDN, Data: info.Value, TTL: d.config.TTL}
+
+	respData, err := d.client.AddTxtRecord(context.Background(), authZone, record)
 	if err != nil {
 		return fmt.Errorf("digitalocean: %w", err)
 	}
@@ -118,7 +137,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("digitalocean: %w", err)
+		return fmt.Errorf("designate: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// get the record's unique ID from when we created it
@@ -129,7 +148,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("digitalocean: unknown record ID for '%s'", info.EffectiveFQDN)
 	}
 
-	err = d.removeTxtRecord(authZone, recordID)
+	err = d.client.RemoveTxtRecord(context.Background(), authZone, recordID)
 	if err != nil {
 		return fmt.Errorf("digitalocean: %w", err)
 	}
diff --git a/providers/dns/digitalocean/digitalocean_test.go b/providers/dns/digitalocean/digitalocean_test.go
index 7cdc9638..bfd2d68c 100644
--- a/providers/dns/digitalocean/digitalocean_test.go
+++ b/providers/dns/digitalocean/digitalocean_test.go
@@ -1,6 +1,7 @@
 package digitalocean
 
 import (
+	"bytes"
 	"fmt"
 	"io"
 	"net/http"
@@ -115,6 +116,7 @@ func TestDNSProvider_Present(t *testing.T) {
 	mux.HandleFunc("/v2/domains/example.com/records", func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, http.MethodPost, r.Method, "method")
 
+		assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept")
 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
 		assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization")
 
@@ -125,7 +127,7 @@ func TestDNSProvider_Present(t *testing.T) {
 		}
 
 		expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`
-		assert.Equal(t, expectedReqBody, string(reqBody))
+		assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody)))
 
 		w.WriteHeader(http.StatusCreated)
 		_, err = fmt.Fprintf(w, `{
@@ -157,7 +159,7 @@ func TestDNSProvider_CleanUp(t *testing.T) {
 
 		assert.Equal(t, "/v2/domains/example.com/records/1234567", r.URL.Path, "Path")
 
-		// NOTE: Even though the body is empty, DigitalOcean API docs still show setting this Content-Type...
+		assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept")
 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
 		assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization")
 
diff --git a/providers/dns/digitalocean/internal/client.go b/providers/dns/digitalocean/internal/client.go
new file mode 100644
index 00000000..e7dd181b
--- /dev/null
+++ b/providers/dns/digitalocean/internal/client.go
@@ -0,0 +1,142 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/oauth2"
+)
+
+// DefaultBaseURL default API endpoint.
+const DefaultBaseURL = "https://api.digitalocean.com"
+
+// Client the Digital Ocean API client.
+type Client struct {
+	BaseURL    *url.URL
+	httpClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(hc *http.Client) *Client {
+	baseURL, _ := url.Parse(DefaultBaseURL)
+
+	if hc == nil {
+		hc = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	return &Client{BaseURL: baseURL, httpClient: hc}
+}
+
+func (c *Client) AddTxtRecord(ctx context.Context, zone string, record Record) (*TxtRecordResponse, error) {
+	endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return nil, err
+	}
+
+	respData := &TxtRecordResponse{}
+	err = c.do(req, respData)
+	if err != nil {
+		return nil, err
+	}
+
+	return respData, nil
+}
+
+func (c *Client) RemoveTxtRecord(ctx context.Context, zone string, recordID int) error {
+	endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", strconv.Itoa(recordID))
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode >= http.StatusBadRequest {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	// NOTE: Even though the body is empty, DigitalOcean API docs still show setting this Content-Type...
+	req.Header.Set("Content-Type", "application/json")
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errInfo APIError
+	err := json.Unmarshal(raw, &errInfo)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("[status code %d] %w", resp.StatusCode, errInfo)
+}
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
+}
diff --git a/providers/dns/digitalocean/internal/client_test.go b/providers/dns/digitalocean/internal/client_test.go
new file mode 100644
index 00000000..081e1a10
--- /dev/null
+++ b/providers/dns/digitalocean/internal/client_test.go
@@ -0,0 +1,139 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
+	client.BaseURL, _ = url.Parse(server.URL)
+
+	mux.HandleFunc(pattern, handler)
+
+	return client
+}
+
+func checkHeader(req *http.Request, name, value string) error {
+	val := req.Header.Get(name)
+	if val != value {
+		return fmt.Errorf("invalid header value, got: %s want %s", val, value)
+	}
+	return nil
+}
+
+func writeFixture(rw http.ResponseWriter, filename string) {
+	file, err := os.Open(filepath.Join("fixtures", filename))
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer func() { _ = file.Close() }()
+
+	_, _ = io.Copy(rw, file)
+}
+
+func TestClient_AddTxtRecord(t *testing.T) {
+	client := setupTest(t, "/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
+			return
+		}
+
+		err := checkHeader(req, "Accept", "application/json")
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		err = checkHeader(req, "Content-Type", "application/json")
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		err = checkHeader(req, "Authorization", "Bearer secret")
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusUnauthorized)
+			return
+		}
+
+		reqBody, err := io.ReadAll(req.Body)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`
+		if expectedReqBody != string(bytes.TrimSpace(reqBody)) {
+			http.Error(rw, fmt.Sprintf("unexpected request body: %s", string(bytes.TrimSpace(reqBody))), http.StatusBadRequest)
+			return
+		}
+
+		rw.WriteHeader(http.StatusCreated)
+		writeFixture(rw, "domains-records_POST.json")
+	})
+
+	record := Record{
+		Type: "TXT",
+		Name: "_acme-challenge.example.com.",
+		Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI",
+		TTL:  30,
+	}
+
+	newRecord, err := client.AddTxtRecord(context.Background(), "example.com", record)
+	require.NoError(t, err)
+
+	expected := &TxtRecordResponse{DomainRecord: Record{
+		ID:   1234567,
+		Type: "TXT",
+		Name: "_acme-challenge",
+		Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI",
+		TTL:  0,
+	}}
+
+	assert.Equal(t, expected, newRecord)
+}
+
+func TestClient_RemoveTxtRecord(t *testing.T) {
+	client := setupTest(t, "/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodDelete {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
+			return
+		}
+
+		err := checkHeader(req, "Accept", "application/json")
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		err = checkHeader(req, "Authorization", "Bearer secret")
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusUnauthorized)
+			return
+		}
+
+		rw.WriteHeader(http.StatusNoContent)
+	})
+
+	err := client.RemoveTxtRecord(context.Background(), "example.com", 1234567)
+	require.NoError(t, err)
+}
diff --git a/providers/dns/digitalocean/internal/fixtures/domains-records_POST.json b/providers/dns/digitalocean/internal/fixtures/domains-records_POST.json
new file mode 100644
index 00000000..8f13835a
--- /dev/null
+++ b/providers/dns/digitalocean/internal/fixtures/domains-records_POST.json
@@ -0,0 +1,11 @@
+{
+  "domain_record": {
+    "id": 1234567,
+    "type": "TXT",
+    "name": "_acme-challenge",
+    "data": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI",
+    "priority": null,
+    "port": null,
+    "weight": null
+  }
+}
diff --git a/providers/dns/digitalocean/internal/types.go b/providers/dns/digitalocean/internal/types.go
new file mode 100644
index 00000000..c1246e6e
--- /dev/null
+++ b/providers/dns/digitalocean/internal/types.go
@@ -0,0 +1,25 @@
+package internal
+
+import "fmt"
+
+// TxtRecordResponse represents a response from DO's API after making a TXT record.
+type TxtRecordResponse struct {
+	DomainRecord Record `json:"domain_record"`
+}
+
+type Record struct {
+	ID   int    `json:"id,omitempty"`
+	Type string `json:"type,omitempty"`
+	Name string `json:"name,omitempty"`
+	Data string `json:"data,omitempty"`
+	TTL  int    `json:"ttl,omitempty"`
+}
+
+type APIError struct {
+	ID      string `json:"id"`
+	Message string `json:"message"`
+}
+
+func (a APIError) Error() string {
+	return fmt.Sprintf("%s: %s", a.ID, a.Message)
+}
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 685b6f80..07fbd330 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -126,7 +126,7 @@ import (
 // NewDNSChallengeProviderByName Factory for DNS providers.
 func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
 	switch name {
-	case "acme-dns":
+	case "acme-dns": // TODO(ldez): remove "-" in v5
 		return acmedns.NewDNSProvider()
 	case "alidns":
 		return alidns.NewDNSProvider()
diff --git a/providers/dns/dnshomede/dnshomede.go b/providers/dns/dnshomede/dnshomede.go
index f098d573..1b81be74 100644
--- a/providers/dns/dnshomede/dnshomede.go
+++ b/providers/dns/dnshomede/dnshomede.go
@@ -2,6 +2,7 @@
 package dnshomede
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -99,7 +100,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, _, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err := d.client.Add(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+	err := d.client.Add(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
 	if err != nil {
 		return fmt.Errorf("dnshomede: %w", err)
 	}
@@ -111,7 +112,7 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err := d.client.Remove(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+	err := d.client.Remove(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
 	if err != nil {
 		return fmt.Errorf("dnshomede: %w", err)
 	}
diff --git a/providers/dns/dnshomede/internal/client.go b/providers/dns/dnshomede/internal/client.go
index 175b12e8..591c32a4 100644
--- a/providers/dns/dnshomede/internal/client.go
+++ b/providers/dns/dnshomede/internal/client.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
@@ -9,6 +10,8 @@ import (
 	"strings"
 	"sync"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const (
@@ -22,8 +25,8 @@ const defaultBaseURL = "https://www.dnshome.de/dyndns.php"
 
 // Client the dnsHome.de client.
 type Client struct {
-	HTTPClient *http.Client
 	baseURL    string
+	HTTPClient *http.Client
 
 	credentials map[string]string
 	credMu      sync.Mutex
@@ -40,75 +43,48 @@ func NewClient(credentials map[string]string) *Client {
 
 // Add adds a TXT record.
 // only one TXT record for ACME is allowed, so it will update the "current" TXT record.
-func (c *Client) Add(hostname, value string) error {
+func (c *Client) Add(ctx context.Context, hostname, value string) error {
 	domain := strings.TrimPrefix(hostname, "_acme-challenge.")
 
-	c.credMu.Lock()
-	password, ok := c.credentials[domain]
-	c.credMu.Unlock()
-
-	if !ok {
-		return fmt.Errorf("domain %s not found in credentials, check your credentials map", domain)
-	}
-
-	return c.do(url.UserPassword(domain, password), addAction, value)
+	return c.doAction(ctx, domain, addAction, value)
 }
 
 // Remove removes a TXT record.
 // only one TXT record for ACME is allowed, so it will remove "all" the TXT records.
-func (c *Client) Remove(hostname, value string) error {
+func (c *Client) Remove(ctx context.Context, hostname, value string) error {
 	domain := strings.TrimPrefix(hostname, "_acme-challenge.")
 
-	c.credMu.Lock()
-	password, ok := c.credentials[domain]
-	c.credMu.Unlock()
-
-	if !ok {
-		return fmt.Errorf("domain %s not found in credentials, check your credentials map", domain)
-	}
-
-	return c.do(url.UserPassword(domain, password), removeAction, value)
+	return c.doAction(ctx, domain, removeAction, value)
 }
 
-func (c *Client) do(userInfo *url.Userinfo, action, value string) error {
-	if len(value) < 12 {
-		return fmt.Errorf("the TXT value must have more than 12 characters: %s", value)
-	}
-
-	apiEndpoint, err := url.Parse(c.baseURL)
+func (c *Client) doAction(ctx context.Context, domain, action, value string) error {
+	endpoint, err := c.createEndpoint(domain, action, value)
 	if err != nil {
 		return err
 	}
 
-	apiEndpoint.User = userInfo
-
-	query := apiEndpoint.Query()
-	query.Set("acme", action)
-	query.Set("txt", value)
-	apiEndpoint.RawQuery = query.Encode()
-
-	req, err := http.NewRequest(http.MethodPost, apiEndpoint.String(), http.NoBody)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody)
 	if err != nil {
-		return err
+		return fmt.Errorf("unable to create request: %w", err)
 	}
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
+
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		all, _ := io.ReadAll(resp.Body)
-		return fmt.Errorf("%d: %s", resp.StatusCode, string(all))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	all, err := io.ReadAll(resp.Body)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return err
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	output := string(all)
+	output := string(raw)
 
 	if !strings.HasPrefix(output, successCode) {
 		return errors.New(output)
@@ -116,3 +92,31 @@ func (c *Client) do(userInfo *url.Userinfo, action, value string) error {
 
 	return nil
 }
+
+func (c *Client) createEndpoint(domain, action, value string) (*url.URL, error) {
+	if len(value) < 12 {
+		return nil, fmt.Errorf("the TXT value must have more than 12 characters: %s", value)
+	}
+
+	endpoint, err := url.Parse(c.baseURL)
+	if err != nil {
+		return nil, err
+	}
+
+	c.credMu.Lock()
+	password, ok := c.credentials[domain]
+	c.credMu.Unlock()
+
+	if !ok {
+		return nil, fmt.Errorf("domain %s not found in credentials, check your credentials map", domain)
+	}
+
+	endpoint.User = url.UserPassword(domain, password)
+
+	query := endpoint.Query()
+	query.Set("acme", action)
+	query.Set("txt", value)
+	endpoint.RawQuery = query.Encode()
+
+	return endpoint, nil
+}
diff --git a/providers/dns/dnshomede/internal/client_test.go b/providers/dns/dnshomede/internal/client_test.go
index 305d83cb..e6f2c1b7 100644
--- a/providers/dns/dnshomede/internal/client_test.go
+++ b/providers/dns/dnshomede/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -9,79 +10,55 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_Add(t *testing.T) {
-	txtValue := "123456789012"
+func setupTest(t *testing.T, credentials map[string]string, handler http.HandlerFunc) *Client {
+	t.Helper()
 
 	mux := http.NewServeMux()
-	mux.HandleFunc("/", handlerMock(addAction, txtValue))
 	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
 
-	credentials := map[string]string{
-		"example.org": "secret",
-	}
+	mux.HandleFunc("/", handler)
 
 	client := NewClient(credentials)
 	client.HTTPClient = server.Client()
 	client.baseURL = server.URL
 
-	err := client.Add("example.org", txtValue)
+	return client
+}
+
+func TestClient_Add(t *testing.T) {
+	txtValue := "123456789012"
+
+	client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(addAction, txtValue))
+
+	err := client.Add(context.Background(), "example.org", txtValue)
 	require.NoError(t, err)
 }
 
 func TestClient_Add_error(t *testing.T) {
 	txtValue := "123456789012"
 
-	mux := http.NewServeMux()
-	mux.HandleFunc("/", handlerMock(addAction, txtValue))
-	server := httptest.NewServer(mux)
+	client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(addAction, txtValue))
 
-	credentials := map[string]string{
-		"example.com": "secret",
-	}
-
-	client := NewClient(credentials)
-	client.HTTPClient = server.Client()
-	client.baseURL = server.URL
-
-	err := client.Add("example.org", txtValue)
+	err := client.Add(context.Background(), "example.org", txtValue)
 	require.Error(t, err)
 }
 
 func TestClient_Remove(t *testing.T) {
 	txtValue := "ABCDEFGHIJKL"
 
-	mux := http.NewServeMux()
-	mux.HandleFunc("/", handlerMock(removeAction, txtValue))
-	server := httptest.NewServer(mux)
+	client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(removeAction, txtValue))
 
-	credentials := map[string]string{
-		"example.org": "secret",
-	}
-
-	client := NewClient(credentials)
-	client.HTTPClient = server.Client()
-	client.baseURL = server.URL
-
-	err := client.Remove("example.org", txtValue)
+	err := client.Remove(context.Background(), "example.org", txtValue)
 	require.NoError(t, err)
 }
 
 func TestClient_Remove_error(t *testing.T) {
 	txtValue := "ABCDEFGHIJKL"
 
-	mux := http.NewServeMux()
-	mux.HandleFunc("/", handlerMock(removeAction, txtValue))
-	server := httptest.NewServer(mux)
+	client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(removeAction, txtValue))
 
-	credentials := map[string]string{
-		"example.com": "secret",
-	}
-
-	client := NewClient(credentials)
-	client.HTTPClient = server.Client()
-	client.baseURL = server.URL
-
-	err := client.Remove("example.org", txtValue)
+	err := client.Remove(context.Background(), "example.org", txtValue)
 	require.Error(t, err)
 }
 
diff --git a/providers/dns/dnsimple/dnsimple.go b/providers/dns/dnsimple/dnsimple.go
index 67f3b3e9..4a5b8788 100644
--- a/providers/dns/dnsimple/dnsimple.go
+++ b/providers/dns/dnsimple/dnsimple.go
@@ -149,7 +149,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) getHostedZone(domain string) (string, error) {
 	authZone, err := dns01.FindZoneByFqdn(domain)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
 	}
 
 	accountID, err := d.getAccountID()
diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.go b/providers/dns/dnsmadeeasy/dnsmadeeasy.go
index b3ae9245..50512fe6 100644
--- a/providers/dns/dnsmadeeasy/dnsmadeeasy.go
+++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.go
@@ -2,10 +2,12 @@
 package dnsmadeeasy
 
 import (
+	"context"
 	"crypto/tls"
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"strings"
 	"time"
 
@@ -86,12 +88,12 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 
 	var baseURL string
 	if config.Sandbox {
-		baseURL = "https://api.sandbox.dnsmadeeasy.com/V2.0"
+		baseURL = internal.DefaultSandboxBaseURL
 	} else {
-		if len(config.BaseURL) > 0 {
-			baseURL = config.BaseURL
+		if config.BaseURL == "" {
+			baseURL = internal.DefaultProdBaseURL
 		} else {
-			baseURL = "https://api.dnsmadeeasy.com/V2.0"
+			baseURL = config.BaseURL
 		}
 	}
 
@@ -101,7 +103,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	}
 
 	client.HTTPClient = config.HTTPClient
-	client.BaseURL = baseURL
+	client.BaseURL, err = url.Parse(baseURL)
+	if err != nil {
+		return nil, err
+	}
 
 	return &DNSProvider{
 		client: client,
@@ -115,11 +120,13 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("dnsmadeeasy: unable to find zone for %s: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q (%s): %w", domainName, info.EffectiveFQDN, err)
 	}
 
+	ctx := context.Background()
+
 	// fetch the domain details
-	domain, err := d.client.GetDomain(authZone)
+	domain, err := d.client.GetDomain(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("dnsmadeeasy: unable to get domain for zone %s: %w", authZone, err)
 	}
@@ -128,7 +135,7 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error {
 	name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1)
 	record := &internal.Record{Type: "TXT", Name: name, Value: info.Value, TTL: d.config.TTL}
 
-	err = d.client.CreateRecord(domain, record)
+	err = d.client.CreateRecord(ctx, domain, record)
 	if err != nil {
 		return fmt.Errorf("dnsmadeeasy: unable to create record for %s: %w", name, err)
 	}
@@ -141,18 +148,20 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("dnsmadeeasy: unable to find zone for %s: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("dnsmadeeasy: could not find zone for domain %q (%s): %w", domainName, info.EffectiveFQDN, err)
 	}
 
+	ctx := context.Background()
+
 	// fetch the domain details
-	domain, err := d.client.GetDomain(authZone)
+	domain, err := d.client.GetDomain(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("dnsmadeeasy: unable to get domain for zone %s: %w", authZone, err)
 	}
 
 	// find matching records
 	name := strings.Replace(info.EffectiveFQDN, "."+authZone, "", 1)
-	records, err := d.client.GetRecords(domain, name, "TXT")
+	records, err := d.client.GetRecords(ctx, domain, name, "TXT")
 	if err != nil {
 		return fmt.Errorf("dnsmadeeasy: unable to get records for domain %s: %w", domain.Name, err)
 	}
@@ -160,7 +169,7 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error {
 	// delete records
 	var lastError error
 	for _, record := range *records {
-		err = d.client.DeleteRecord(record)
+		err = d.client.DeleteRecord(ctx, record)
 		if err != nil {
 			lastError = fmt.Errorf("dnsmadeeasy: unable to delete record [id=%d, name=%s]: %w", record.ID, record.Name, err)
 		}
diff --git a/providers/dns/dnsmadeeasy/internal/client.go b/providers/dns/dnsmadeeasy/internal/client.go
index 85d18a0f..9890de8b 100644
--- a/providers/dns/dnsmadeeasy/internal/client.go
+++ b/providers/dns/dnsmadeeasy/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"crypto/hmac"
 	"crypto/sha1"
 	"encoding/hex"
@@ -10,34 +11,25 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"strconv"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
-// Domain holds the DNSMadeEasy API representation of a Domain.
-type Domain struct {
-	ID   int    `json:"id"`
-	Name string `json:"name"`
-}
-
-// Record holds the DNSMadeEasy API representation of a Domain Record.
-type Record struct {
-	ID       int    `json:"id"`
-	Type     string `json:"type"`
-	Name     string `json:"name"`
-	Value    string `json:"value"`
-	TTL      int    `json:"ttl"`
-	SourceID int    `json:"sourceId"`
-}
-
-type recordsResponse struct {
-	Records *[]Record `json:"data"`
-}
+// Default API endpoints.
+const (
+	DefaultSandboxBaseURL = "https://api.sandbox.dnsmadeeasy.com/V2.0"
+	DefaultProdBaseURL    = "https://api.dnsmadeeasy.com/V2.0"
+)
 
 // Client DNSMadeEasy client.
 type Client struct {
-	apiKey     string
-	apiSecret  string
-	BaseURL    string
+	apiKey    string
+	apiSecret string
+
+	BaseURL    *url.URL
 	HTTPClient *http.Client
 }
 
@@ -51,26 +43,33 @@ func NewClient(apiKey, apiSecret string) (*Client, error) {
 		return nil, errors.New("credentials missing: API secret")
 	}
 
+	baseURL, _ := url.Parse(DefaultProdBaseURL)
+
 	return &Client{
 		apiKey:     apiKey,
 		apiSecret:  apiSecret,
-		HTTPClient: &http.Client{},
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}, nil
 }
 
 // GetDomain gets a domain.
-func (c *Client) GetDomain(authZone string) (*Domain, error) {
-	domainName := authZone[0 : len(authZone)-1]
-	resource := fmt.Sprintf("%s%s", "/dns/managed/name?domainname=", domainName)
+func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error) {
+	endpoint := c.BaseURL.JoinPath("dns", "managed", "name")
 
-	resp, err := c.sendRequest(http.MethodGet, resource, nil)
+	domainName := authZone[0 : len(authZone)-1]
+
+	query := endpoint.Query()
+	query.Set("domainname", domainName)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
 
 	domain := &Domain{}
-	err = json.NewDecoder(resp.Body).Decode(&domain)
+	err = c.do(req, domain)
 	if err != nil {
 		return nil, err
 	}
@@ -79,17 +78,20 @@ func (c *Client) GetDomain(authZone string) (*Domain, error) {
 }
 
 // GetRecords gets all TXT records.
-func (c *Client) GetRecords(domain *Domain, recordName, recordType string) (*[]Record, error) {
-	resource := fmt.Sprintf("%s/%d/%s%s%s%s", "/dns/managed", domain.ID, "records?recordName=", recordName, "&type=", recordType)
+func (c *Client) GetRecords(ctx context.Context, domain *Domain, recordName, recordType string) (*[]Record, error) {
+	endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(domain.ID), "records")
 
-	resp, err := c.sendRequest(http.MethodGet, resource, nil)
+	query := endpoint.Query()
+	query.Set("recordName", recordName)
+	query.Set("type", recordType)
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
 
 	records := &recordsResponse{}
-	err = json.NewDecoder(resp.Body).Decode(&records)
+	err = c.do(req, records)
 	if err != nil {
 		return nil, err
 	}
@@ -98,69 +100,73 @@ func (c *Client) GetRecords(domain *Domain, recordName, recordType string) (*[]R
 }
 
 // CreateRecord creates a TXT records.
-func (c *Client) CreateRecord(domain *Domain, record *Record) error {
-	url := fmt.Sprintf("%s/%d/%s", "/dns/managed", domain.ID, "records")
+func (c *Client) CreateRecord(ctx context.Context, domain *Domain, record *Record) error {
+	endpoint := c.BaseURL.JoinPath("dns", "managed", strconv.Itoa(domain.ID), "records")
 
-	resp, err := c.sendRequest(http.MethodPost, url, record)
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return err
 	}
-	defer resp.Body.Close()
 
-	return nil
+	return c.do(req, nil)
 }
 
 // DeleteRecord deletes a TXT records.
-func (c *Client) DeleteRecord(record Record) error {
-	resource := fmt.Sprintf("%s/%d/%s/%d", "/dns/managed", record.SourceID, "records", record.ID)
+func (c *Client) DeleteRecord(ctx context.Context, record Record) error {
+	endpoint := c.BaseURL.JoinPath("/dns/managed", strconv.Itoa(record.SourceID), "records", strconv.Itoa(record.ID))
 
-	resp, err := c.sendRequest(http.MethodDelete, resource, nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
-	defer resp.Body.Close()
+
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	err := c.sign(req, time.Now().UTC().Format(time.RFC1123))
+	if err != nil {
+		return err
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	if err = json.Unmarshal(raw, result); err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
 
 	return nil
 }
 
-func (c *Client) sendRequest(method, resource string, payload interface{}) (*http.Response, error) {
-	url := fmt.Sprintf("%s%s", c.BaseURL, resource)
-
-	body, err := json.Marshal(payload)
-	if err != nil {
-		return nil, err
-	}
-
-	timestamp := time.Now().UTC().Format(time.RFC1123)
+func (c *Client) sign(req *http.Request, timestamp string) error {
 	signature, err := computeHMAC(timestamp, c.apiSecret)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	req, err := http.NewRequest(method, url, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
 	req.Header.Set("x-dnsme-apiKey", c.apiKey)
 	req.Header.Set("x-dnsme-requestDate", timestamp)
 	req.Header.Set("x-dnsme-hmac", signature)
-	req.Header.Set("accept", "application/json")
-	req.Header.Set("content-type", "application/json")
 
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-
-	if resp.StatusCode > 299 {
-		body, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return nil, fmt.Errorf("request failed with HTTP status code %d", resp.StatusCode)
-		}
-		return nil, fmt.Errorf("request failed with HTTP status code %d: %s", resp.StatusCode, string(body))
-	}
-
-	return resp, nil
+	return nil
 }
 
 func computeHMAC(message, secret string) (string, error) {
@@ -172,3 +178,27 @@ func computeHMAC(message, secret string) (string, error) {
 	}
 	return hex.EncodeToString(h.Sum(nil)), nil
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/dnsmadeeasy/internal/client_test.go b/providers/dns/dnsmadeeasy/internal/client_test.go
new file mode 100644
index 00000000..72121469
--- /dev/null
+++ b/providers/dns/dnsmadeeasy/internal/client_test.go
@@ -0,0 +1,28 @@
+package internal
+
+import (
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_sign(t *testing.T) {
+	apiKey := "key"
+
+	client := Client{apiKey: apiKey, apiSecret: "secret"}
+
+	req, err := http.NewRequest(http.MethodGet, "", http.NoBody)
+	require.NoError(t, err)
+
+	timestamp := time.Date(2015, time.June, 2, 2, 36, 7, 0, time.UTC).Format(time.RFC1123)
+
+	err = client.sign(req, timestamp)
+	require.NoError(t, err)
+
+	assert.Equal(t, apiKey, req.Header.Get("x-dnsme-apiKey"))
+	assert.Equal(t, timestamp, req.Header.Get("x-dnsme-requestDate"))
+	assert.Equal(t, "6b6c8432119c31e1d3776eb4cd3abd92fae4a71c", req.Header.Get("x-dnsme-hmac"))
+}
diff --git a/providers/dns/dnsmadeeasy/internal/types.go b/providers/dns/dnsmadeeasy/internal/types.go
new file mode 100644
index 00000000..a10da88e
--- /dev/null
+++ b/providers/dns/dnsmadeeasy/internal/types.go
@@ -0,0 +1,21 @@
+package internal
+
+// Domain holds the DNSMadeEasy API representation of a Domain.
+type Domain struct {
+	ID   int    `json:"id"`
+	Name string `json:"name"`
+}
+
+// Record holds the DNSMadeEasy API representation of a Domain Record.
+type Record struct {
+	ID       int    `json:"id"`
+	Type     string `json:"type"`
+	Name     string `json:"name"`
+	Value    string `json:"value"`
+	TTL      int    `json:"ttl"`
+	SourceID int    `json:"sourceId"`
+}
+
+type recordsResponse struct {
+	Records *[]Record `json:"data"`
+}
diff --git a/providers/dns/dnspod/dnspod.go b/providers/dns/dnspod/dnspod.go
index 740c648e..c20caf3c 100644
--- a/providers/dns/dnspod/dnspod.go
+++ b/providers/dns/dnspod/dnspod.go
@@ -143,7 +143,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) {
 
 	authZone, err := dns01.FindZoneByFqdn(domain)
 	if err != nil {
-		return "", "", err
+		return "", "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
 	}
 
 	var hostedZone dnspod.Domain
diff --git a/providers/dns/dode/client.go b/providers/dns/dode/client.go
deleted file mode 100644
index d788c1a7..00000000
--- a/providers/dns/dode/client.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package dode
-
-import (
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/url"
-
-	"github.com/go-acme/lego/v4/challenge/dns01"
-)
-
-type apiResponse struct {
-	Domain  string
-	Success bool
-}
-
-// updateTxtRecord Update the domains TXT record
-// To update the TXT record we just need to make one simple get request.
-func (d *DNSProvider) updateTxtRecord(fqdn, token, txt string, clear bool) error {
-	u, _ := url.Parse("https://www.do.de/api/letsencrypt")
-
-	query := u.Query()
-	query.Set("token", token)
-	query.Set("domain", dns01.UnFqdn(fqdn))
-
-	// api call differs per set/delete
-	if clear {
-		query.Set("action", "delete")
-	} else {
-		query.Set("value", txt)
-	}
-
-	u.RawQuery = query.Encode()
-
-	response, err := d.config.HTTPClient.Get(u.String())
-	if err != nil {
-		return err
-	}
-	defer response.Body.Close()
-
-	bodyBytes, err := io.ReadAll(response.Body)
-	if err != nil {
-		return err
-	}
-
-	var r apiResponse
-	err = json.Unmarshal(bodyBytes, &r)
-	if err != nil {
-		return fmt.Errorf("request to change TXT record for do.de returned the following invalid json (%s); used url [%s]", string(bodyBytes), u)
-	}
-
-	body := string(bodyBytes)
-	if !r.Success {
-		return fmt.Errorf("request to change TXT record for do.de returned the following error result (%s); used url [%s]", body, u)
-	}
-	return nil
-}
diff --git a/providers/dns/dode/dode.go b/providers/dns/dode/dode.go
index 18c16b10..04393fb0 100644
--- a/providers/dns/dode/dode.go
+++ b/providers/dns/dode/dode.go
@@ -2,6 +2,7 @@
 package dode
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -9,6 +10,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/dode/internal"
 )
 
 // Environment variables names.
@@ -47,6 +49,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a new DNS provider using
@@ -73,19 +76,25 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("do.de: credentials missing")
 	}
 
-	return &DNSProvider{config: config}, nil
+	client := internal.NewClient(config.Token)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record to fulfill the dns-01 challenge.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
-	return d.updateTxtRecord(info.EffectiveFQDN, d.config.Token, info.Value, false)
+	return d.client.UpdateTxtRecord(context.Background(), info.EffectiveFQDN, info.Value, false)
 }
 
 // CleanUp clears TXT record.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
-	return d.updateTxtRecord(info.EffectiveFQDN, d.config.Token, "", true)
+	return d.client.UpdateTxtRecord(context.Background(), info.EffectiveFQDN, "", true)
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
diff --git a/providers/dns/dode/dode_test.go b/providers/dns/dode/dode_test.go
index 67a61433..3d8e9395 100644
--- a/providers/dns/dode/dode_test.go
+++ b/providers/dns/dode/dode_test.go
@@ -10,8 +10,7 @@ import (
 
 const envDomain = envNamespace + "DOMAIN"
 
-var envTest = tester.NewEnvTest(EnvToken).
-	WithDomain(envDomain)
+var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain)
 
 func TestNewDNSProvider(t *testing.T) {
 	testCases := []struct {
diff --git a/providers/dns/dode/internal/client.go b/providers/dns/dode/internal/client.go
new file mode 100644
index 00000000..4568cd9b
--- /dev/null
+++ b/providers/dns/dode/internal/client.go
@@ -0,0 +1,84 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://www.do.de/api"
+
+// Client the do.de API client.
+type Client struct {
+	token string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(token string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		token:      token,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// UpdateTxtRecord Update the domains TXT record
+// To update the TXT record we just need to make one simple get request.
+func (c Client) UpdateTxtRecord(ctx context.Context, fqdn, txt string, clear bool) error {
+	endpoint := c.baseURL.JoinPath("letsencrypt")
+
+	query := endpoint.Query()
+	query.Set("token", c.token)
+	query.Set("domain", dns01.UnFqdn(fqdn))
+
+	// api call differs per set/delete
+	if clear {
+		query.Set("action", "delete")
+	} else {
+		query.Set("value", txt)
+	}
+
+	endpoint.RawQuery = query.Encode()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
+	if err != nil {
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var response apiResponse
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	body := string(raw)
+	if !response.Success {
+		return fmt.Errorf("request to change TXT record for do.de returned the following error result (%s); used url [%s]", body, endpoint)
+	}
+
+	return nil
+}
diff --git a/providers/dns/dode/internal/client_test.go b/providers/dns/dode/internal/client_test.go
new file mode 100644
index 00000000..116ca8c4
--- /dev/null
+++ b/providers/dns/dode/internal/client_test.go
@@ -0,0 +1,93 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, method, pattern string, status int, file string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		query := req.URL.Query()
+		if query.Get("token") != "secret" {
+			http.Error(rw, fmt.Sprintf("invalid credentials: %q", query.Get("token")), http.StatusUnauthorized)
+			return
+		}
+
+		if query.Get("domain") != "example.com" {
+			http.Error(rw, fmt.Sprintf("invalid domain: %q", query.Get("domain")), http.StatusBadRequest)
+			return
+		}
+
+		if query.Has("action") {
+			if query.Get("action") != "delete" {
+				http.Error(rw, fmt.Sprintf("invalid action: %q", query.Get("action")), http.StatusBadRequest)
+				return
+			}
+		} else {
+			if query.Get("value") != "value" {
+				http.Error(rw, fmt.Sprintf("invalid value: %q", query.Get("value")), http.StatusBadRequest)
+				return
+			}
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	client := NewClient("secret")
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func TestClient_UpdateTxtRecord(t *testing.T) {
+	client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json")
+
+	err := client.UpdateTxtRecord(context.Background(), "example.com.", "value", false)
+	require.NoError(t, err)
+}
+
+func TestClient_UpdateTxtRecord_clear(t *testing.T) {
+	client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json")
+
+	err := client.UpdateTxtRecord(context.Background(), "example.com.", "value", true)
+	require.NoError(t, err)
+}
diff --git a/providers/dns/dode/internal/fixtures/success.json b/providers/dns/dode/internal/fixtures/success.json
new file mode 100644
index 00000000..d6622346
--- /dev/null
+++ b/providers/dns/dode/internal/fixtures/success.json
@@ -0,0 +1,4 @@
+{
+  "Domain" : "example.com",
+  "Success": true
+}
diff --git a/providers/dns/dode/internal/types.go b/providers/dns/dode/internal/types.go
new file mode 100644
index 00000000..cc95ba14
--- /dev/null
+++ b/providers/dns/dode/internal/types.go
@@ -0,0 +1,6 @@
+package internal
+
+type apiResponse struct {
+	Domain  string
+	Success bool
+}
diff --git a/providers/dns/domeneshop/domeneshop.go b/providers/dns/domeneshop/domeneshop.go
index c71cd1f9..c9f7fcd9 100644
--- a/providers/dns/domeneshop/domeneshop.go
+++ b/providers/dns/domeneshop/domeneshop.go
@@ -2,6 +2,7 @@
 package domeneshop
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -100,12 +101,14 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error {
 		return fmt.Errorf("domeneshop: %w", err)
 	}
 
-	domainInstance, err := d.client.GetDomainByName(zone)
+	ctx := context.Background()
+
+	domainInstance, err := d.client.GetDomainByName(ctx, zone)
 	if err != nil {
 		return fmt.Errorf("domeneshop: %w", err)
 	}
 
-	err = d.client.CreateTXTRecord(domainInstance, host, info.Value)
+	err = d.client.CreateTXTRecord(ctx, domainInstance, host, info.Value)
 	if err != nil {
 		return fmt.Errorf("domeneshop: failed to create record: %w", err)
 	}
@@ -122,12 +125,14 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 		return fmt.Errorf("domeneshop: %w", err)
 	}
 
-	domainInstance, err := d.client.GetDomainByName(zone)
+	ctx := context.Background()
+
+	domainInstance, err := d.client.GetDomainByName(ctx, zone)
 	if err != nil {
 		return fmt.Errorf("domeneshop: %w", err)
 	}
 
-	if err := d.client.DeleteTXTRecord(domainInstance, host, info.Value); err != nil {
+	if err := d.client.DeleteTXTRecord(ctx, domainInstance, host, info.Value); err != nil {
 		return fmt.Errorf("domeneshop: failed to create record: %w", err)
 	}
 
@@ -138,7 +143,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) {
 	zone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return "", "", err
+		return "", "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(fqdn, zone)
diff --git a/providers/dns/domeneshop/internal/client.go b/providers/dns/domeneshop/internal/client.go
index f578fb42..9b48d326 100644
--- a/providers/dns/domeneshop/internal/client.go
+++ b/providers/dns/domeneshop/internal/client.go
@@ -2,11 +2,16 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"strconv"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL string = "https://api.domeneshop.no/v0"
@@ -15,28 +20,38 @@ const defaultBaseURL string = "https://api.domeneshop.no/v0"
 // For now it will only deal with adding and removing TXT records, as required by ACME providers.
 // https://api.domeneshop.no/docs/
 type Client struct {
+	apiToken  string
+	apiSecret string
+
+	baseURL    *url.URL
 	HTTPClient *http.Client
-	baseURL    string
-	apiToken   string
-	apiSecret  string
 }
 
 // NewClient returns an instance of the Domeneshop API wrapper.
 func NewClient(apiToken, apiSecret string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
-		HTTPClient: &http.Client{Timeout: 5 * time.Second},
-		baseURL:    defaultBaseURL,
 		apiToken:   apiToken,
 		apiSecret:  apiSecret,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // GetDomainByName fetches the domain list and returns the Domain object for the matching domain.
 // https://api.domeneshop.no/docs/#operation/getDomains
-func (c *Client) GetDomainByName(domain string) (*Domain, error) {
+func (c *Client) GetDomainByName(ctx context.Context, domain string) (*Domain, error) {
+	endpoint := c.baseURL.JoinPath("domains")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
 	var domains []Domain
 
-	err := c.doRequest(http.MethodGet, "domains", nil, &domains)
+	err = c.do(req, &domains)
 	if err != nil {
 		return nil, err
 	}
@@ -57,37 +72,55 @@ func (c *Client) GetDomainByName(domain string) (*Domain, error) {
 
 // CreateTXTRecord creates a TXT record with the provided host (subdomain) and data.
 // https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns/post
-func (c *Client) CreateTXTRecord(domain *Domain, host string, data string) error {
-	jsonRecord, err := json.Marshal(DNSRecord{
+func (c *Client) CreateTXTRecord(ctx context.Context, domain *Domain, host string, data string) error {
+	endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns")
+
+	record := DNSRecord{
 		Data: data,
 		Host: host,
 		TTL:  300,
 		Type: "TXT",
-	})
+	}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return err
 	}
 
-	return c.doRequest(http.MethodPost, fmt.Sprintf("domains/%d/dns", domain.ID), jsonRecord, nil)
+	return c.do(req, nil)
 }
 
 // DeleteTXTRecord deletes the DNS record matching the provided host and data.
 // https://api.domeneshop.no/docs/#tag/dns/paths/~1domains~1{domainId}~1dns~1{recordId}/delete
-func (c *Client) DeleteTXTRecord(domain *Domain, host string, data string) error {
-	record, err := c.getDNSRecordByHostData(*domain, host, data)
+func (c *Client) DeleteTXTRecord(ctx context.Context, domain *Domain, host string, data string) error {
+	record, err := c.getDNSRecordByHostData(ctx, *domain, host, data)
 	if err != nil {
 		return err
 	}
 
-	return c.doRequest(http.MethodDelete, fmt.Sprintf("domains/%d/dns/%d", domain.ID, record.ID), nil, nil)
+	endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns", strconv.Itoa(record.ID))
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
 }
 
 // getDNSRecordByHostData finds the first matching DNS record with the provided host and data.
 // https://api.domeneshop.no/docs/#operation/getDnsRecords
-func (c *Client) getDNSRecordByHostData(domain Domain, host string, data string) (*DNSRecord, error) {
+func (c *Client) getDNSRecordByHostData(ctx context.Context, domain Domain, host string, data string) (*DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("domains", strconv.Itoa(domain.ID), "dns")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
 	var records []DNSRecord
 
-	err := c.doRequest(http.MethodGet, fmt.Sprintf("domains/%d/dns", domain.ID), nil, &records)
+	err = c.do(req, &records)
 	if err != nil {
 		return nil, err
 	}
@@ -101,35 +134,59 @@ func (c *Client) getDNSRecordByHostData(domain Domain, host string, data string)
 	return nil, fmt.Errorf("failed to find record with host %s for domain %s", host, domain.Name)
 }
 
-// doRequest makes a request against the API with an optional body,
+// do a request against the API,
 // and makes sure that the required Authorization header is set using `setBasicAuth`.
-func (c *Client) doRequest(method string, endpoint string, reqBody []byte, v interface{}) error {
-	req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", c.baseURL, endpoint), bytes.NewBuffer(reqBody))
-	if err != nil {
-		return err
-	}
-
+func (c *Client) do(req *http.Request, result any) error {
 	req.SetBasicAuth(c.apiToken, c.apiSecret)
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode >= http.StatusBadRequest {
-		respBody, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return err
-		}
-
-		return fmt.Errorf("API returned %s: %s", resp.Status, respBody)
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	if v != nil {
-		return json.NewDecoder(resp.Body).Decode(&v)
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/domeneshop/internal/client_test.go b/providers/dns/domeneshop/internal/client_test.go
index 569ca403..71205cac 100644
--- a/providers/dns/domeneshop/internal/client_test.go
+++ b/providers/dns/domeneshop/internal/client_test.go
@@ -1,31 +1,34 @@
 package internal
 
 import (
+	"context"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func setup(t *testing.T) (*Client, *http.ServeMux) {
+const authorizationHeader = "Authorization"
+
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
-
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
 	client := NewClient("token", "secret")
-
-	client.baseURL = server.URL
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
 
 	return client, mux
 }
 
 func TestClient_CreateTXTRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
@@ -33,21 +36,21 @@ func TestClient_CreateTXTRecord(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get("Authorization")
+		auth := req.Header.Get(authorizationHeader)
 		if auth != "Basic dG9rZW46c2VjcmV0" {
-			http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized)
+			http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
 			return
 		}
 
 		_, _ = rw.Write([]byte(`{"id": 1}`))
 	})
 
-	err := client.CreateTXTRecord(&Domain{ID: 1}, "example", "txtTXTtxt")
+	err := client.CreateTXTRecord(context.Background(), &Domain{ID: 1}, "example", "txtTXTtxt")
 	require.NoError(t, err)
 }
 
 func TestClient_DeleteTXTRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
@@ -55,9 +58,9 @@ func TestClient_DeleteTXTRecord(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get("Authorization")
+		auth := req.Header.Get(authorizationHeader)
 		if auth != "Basic dG9rZW46c2VjcmV0" {
-			http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized)
+			http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
 			return
 		}
 
@@ -78,19 +81,19 @@ func TestClient_DeleteTXTRecord(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get("Authorization")
+		auth := req.Header.Get(authorizationHeader)
 		if auth != "Basic dG9rZW46c2VjcmV0" {
-			http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized)
+			http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
 			return
 		}
 	})
 
-	err := client.DeleteTXTRecord(&Domain{ID: 1}, "example.com", "txtTXTtxt")
+	err := client.DeleteTXTRecord(context.Background(), &Domain{ID: 1}, "example.com", "txtTXTtxt")
 	require.NoError(t, err)
 }
 
 func TestClient_getDNSRecordByHostData(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
@@ -98,9 +101,9 @@ func TestClient_getDNSRecordByHostData(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get("Authorization")
+		auth := req.Header.Get(authorizationHeader)
 		if auth != "Basic dG9rZW46c2VjcmV0" {
-			http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized)
+			http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
 			return
 		}
 
@@ -115,7 +118,7 @@ func TestClient_getDNSRecordByHostData(t *testing.T) {
 ]`))
 	})
 
-	record, err := client.getDNSRecordByHostData(Domain{ID: 1}, "example.com", "txtTXTtxt")
+	record, err := client.getDNSRecordByHostData(context.Background(), Domain{ID: 1}, "example.com", "txtTXTtxt")
 	require.NoError(t, err)
 
 	expected := &DNSRecord{
@@ -130,7 +133,7 @@ func TestClient_getDNSRecordByHostData(t *testing.T) {
 }
 
 func TestClient_GetDomainByName(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
@@ -138,9 +141,9 @@ func TestClient_GetDomainByName(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get("Authorization")
+		auth := req.Header.Get(authorizationHeader)
 		if auth != "Basic dG9rZW46c2VjcmV0" {
-			http.Error(rw, "invalid method: "+req.Method, http.StatusUnauthorized)
+			http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
 			return
 		}
 
@@ -168,7 +171,7 @@ func TestClient_GetDomainByName(t *testing.T) {
 ]`))
 	})
 
-	domain, err := client.GetDomainByName("example.com")
+	domain, err := client.GetDomainByName(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	expected := &Domain{
diff --git a/providers/dns/dreamhost/client.go b/providers/dns/dreamhost/client.go
deleted file mode 100644
index 8ddc4da8..00000000
--- a/providers/dns/dreamhost/client.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package dreamhost
-
-import (
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"net/url"
-
-	"github.com/go-acme/lego/v4/log"
-)
-
-const (
-	defaultBaseURL = "https://api.dreamhost.com"
-
-	cmdAddRecord    = "dns-add_record"
-	cmdRemoveRecord = "dns-remove_record"
-)
-
-type apiResponse struct {
-	Data   string `json:"data"`
-	Result string `json:"result"`
-}
-
-func (d *DNSProvider) buildQuery(action, domain, txt string) (*url.URL, error) {
-	u, err := url.Parse(d.config.BaseURL)
-	if err != nil {
-		return nil, err
-	}
-
-	query := u.Query()
-	query.Set("key", d.config.APIKey)
-	query.Set("cmd", action)
-	query.Set("format", "json")
-	query.Set("record", domain)
-	query.Set("type", "TXT")
-	query.Set("value", txt)
-	query.Set("comment", url.QueryEscape("Managed By lego"))
-	u.RawQuery = query.Encode()
-
-	return u, nil
-}
-
-// updateTxtRecord will either add or remove a TXT record.
-// action is either cmdAddRecord or cmdRemoveRecord.
-func (d *DNSProvider) updateTxtRecord(u fmt.Stringer) error {
-	resp, err := d.config.HTTPClient.Get(u.String())
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("request failed with HTTP status code %d", resp.StatusCode)
-	}
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("failed to read body: %w", err)
-	}
-
-	var response apiResponse
-	err = json.Unmarshal(raw, &response)
-	if err != nil {
-		return fmt.Errorf("unable to decode API server response: %w: %s", err, string(raw))
-	}
-
-	if response.Result == "error" {
-		return fmt.Errorf("add TXT record failed: %s", response.Data)
-	}
-
-	log.Infof("dreamhost: %s", response.Data)
-	return nil
-}
diff --git a/providers/dns/dreamhost/dreamhost.go b/providers/dns/dreamhost/dreamhost.go
index 56cf74fb..8f0c850d 100644
--- a/providers/dns/dreamhost/dreamhost.go
+++ b/providers/dns/dreamhost/dreamhost.go
@@ -4,6 +4,7 @@
 package dreamhost
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -11,6 +12,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/dreamhost/internal"
 )
 
 // Environment variables names.
@@ -36,7 +38,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		BaseURL:            defaultBaseURL,
+		BaseURL:            internal.DefaultBaseURL,
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Minute),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute),
 		HTTPClient: &http.Client{
@@ -48,6 +50,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a new DNS provider using
@@ -74,44 +77,39 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("dreamhost: credentials missing")
 	}
 
-	if config.BaseURL == "" {
-		config.BaseURL = defaultBaseURL
+	client := internal.NewClient(config.APIKey)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
 	}
 
-	return &DNSProvider{config: config}, nil
+	if config.BaseURL != "" {
+		client.BaseURL = config.BaseURL
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record using the specified parameters.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
-	record := dns01.UnFqdn(info.EffectiveFQDN)
-
-	u, err := d.buildQuery(cmdAddRecord, record, info.Value)
+	err := d.client.AddRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
 	if err != nil {
 		return fmt.Errorf("dreamhost: %w", err)
 	}
 
-	err = d.updateTxtRecord(u)
-	if err != nil {
-		return fmt.Errorf("dreamhost: %w", err)
-	}
 	return nil
 }
 
 // CleanUp removes the TXT record matching the specified parameters.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
-	record := dns01.UnFqdn(info.EffectiveFQDN)
 
-	u, err := d.buildQuery(cmdRemoveRecord, record, info.Value)
+	err := d.client.RemoveRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
 	if err != nil {
 		return fmt.Errorf("dreamhost: %w", err)
 	}
 
-	err = d.updateTxtRecord(u)
-	if err != nil {
-		return fmt.Errorf("dreamhost: %w", err)
-	}
 	return nil
 }
 
diff --git a/providers/dns/dreamhost/internal/client.go b/providers/dns/dreamhost/internal/client.go
new file mode 100644
index 00000000..dee808ac
--- /dev/null
+++ b/providers/dns/dreamhost/internal/client.go
@@ -0,0 +1,114 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// DefaultBaseURL the default API endpoint.
+const DefaultBaseURL = "https://api.dreamhost.com"
+
+const (
+	cmdAddRecord    = "dns-add_record"
+	cmdRemoveRecord = "dns-remove_record"
+)
+
+// Client the Dreamhost API client.
+type Client struct {
+	apiKey string
+
+	BaseURL    string
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(apiKey string) *Client {
+	return &Client{
+		apiKey:     apiKey,
+		BaseURL:    DefaultBaseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// AddRecord adds a TXT record.
+func (c *Client) AddRecord(ctx context.Context, domain, value string) error {
+	query, err := c.buildEndpoint(cmdAddRecord, domain, value)
+	if err != nil {
+		return err
+	}
+
+	return c.updateTxtRecord(ctx, query)
+}
+
+// RemoveRecord removes a TXT record.
+func (c *Client) RemoveRecord(ctx context.Context, domain, value string) error {
+	query, err := c.buildEndpoint(cmdRemoveRecord, domain, value)
+	if err != nil {
+		return err
+	}
+
+	return c.updateTxtRecord(ctx, query)
+}
+
+// action is either cmdAddRecord or cmdRemoveRecord.
+func (c *Client) buildEndpoint(action, domain, txt string) (*url.URL, error) {
+	endpoint, err := url.Parse(c.BaseURL)
+	if err != nil {
+		return nil, err
+	}
+
+	query := endpoint.Query()
+	query.Set("key", c.apiKey)
+	query.Set("cmd", action)
+	query.Set("format", "json")
+	query.Set("record", domain)
+	query.Set("type", "TXT")
+	query.Set("value", txt)
+	query.Set("comment", url.QueryEscape("Managed By lego"))
+	endpoint.RawQuery = query.Encode()
+
+	return endpoint, nil
+}
+
+// updateTxtRecord will either add or remove a TXT record.
+func (c *Client) updateTxtRecord(ctx context.Context, endpoint *url.URL) error {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
+	if err != nil {
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var response apiResponse
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	if response.Result == "error" {
+		return fmt.Errorf("add TXT record failed: %s", response.Data)
+	}
+
+	return nil
+}
diff --git a/providers/dns/dreamhost/client_test.go b/providers/dns/dreamhost/internal/client_test.go
similarity index 71%
rename from providers/dns/dreamhost/client_test.go
rename to providers/dns/dreamhost/internal/client_test.go
index c8d195bd..348c50ce 100644
--- a/providers/dns/dreamhost/client_test.go
+++ b/providers/dns/dreamhost/internal/client_test.go
@@ -1,4 +1,4 @@
-package dreamhost
+package internal
 
 import (
 	"testing"
@@ -7,7 +7,9 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestDNSProvider_buildQuery(t *testing.T) {
+const fakeAPIKey = "asdf1234"
+
+func TestClient_buildQuery(t *testing.T) {
 	testCases := []struct {
 		desc     string
 		apiKey   string
@@ -40,23 +42,18 @@ func TestDNSProvider_buildQuery(t *testing.T) {
 		t.Run(test.desc, func(t *testing.T) {
 			t.Parallel()
 
-			config := NewDefaultConfig()
-			config.APIKey = test.apiKey
+			client := NewClient(test.apiKey)
 			if test.baseURL != "" {
-				config.BaseURL = test.baseURL
+				client.BaseURL = test.baseURL
 			}
 
-			provider, err := NewDNSProviderConfig(config)
-			require.NoError(t, err)
-			require.NotNil(t, provider)
-
-			u, err := provider.buildQuery(test.action, test.domain, test.txt)
+			endpoint, err := client.buildEndpoint(test.action, test.domain, test.txt)
 
 			if test.expected == "" {
 				require.Error(t, err)
 			} else {
 				require.NoError(t, err)
-				assert.Equal(t, test.expected, u.String())
+				assert.Equal(t, test.expected, endpoint.String())
 			}
 		})
 	}
diff --git a/providers/dns/dreamhost/internal/types.go b/providers/dns/dreamhost/internal/types.go
new file mode 100644
index 00000000..6a1e903f
--- /dev/null
+++ b/providers/dns/dreamhost/internal/types.go
@@ -0,0 +1,6 @@
+package internal
+
+type apiResponse struct {
+	Data   string `json:"data"`
+	Result string `json:"result"`
+}
diff --git a/providers/dns/duckdns/client.go b/providers/dns/duckdns/client.go
deleted file mode 100644
index 5eb9cb44..00000000
--- a/providers/dns/duckdns/client.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package duckdns
-
-import (
-	"fmt"
-	"io"
-	"net/url"
-	"strconv"
-	"strings"
-
-	"github.com/go-acme/lego/v4/challenge/dns01"
-	"github.com/miekg/dns"
-)
-
-// updateTxtRecord Update the domains TXT record
-// To update the TXT record we just need to make one simple get request.
-// In DuckDNS you only have one TXT record shared with the domain and all sub domains.
-func (d *DNSProvider) updateTxtRecord(domain, token, txt string, clear bool) error {
-	u, _ := url.Parse("https://www.duckdns.org/update")
-
-	mainDomain := getMainDomain(domain)
-	if mainDomain == "" {
-		return fmt.Errorf("unable to find the main domain for: %s", domain)
-	}
-
-	query := u.Query()
-	query.Set("domains", mainDomain)
-	query.Set("token", token)
-	query.Set("clear", strconv.FormatBool(clear))
-	query.Set("txt", txt)
-	u.RawQuery = query.Encode()
-
-	response, err := d.config.HTTPClient.Get(u.String())
-	if err != nil {
-		return err
-	}
-	defer response.Body.Close()
-
-	bodyBytes, err := io.ReadAll(response.Body)
-	if err != nil {
-		return err
-	}
-
-	body := string(bodyBytes)
-	if body != "OK" {
-		return fmt.Errorf("request to change TXT record for DuckDNS returned the following result (%s) this does not match expectation (OK) used url [%s]", body, u)
-	}
-	return nil
-}
-
-// DuckDNS only lets you write to your subdomain.
-// It must be in format subdomain.duckdns.org,
-// not in format subsubdomain.subdomain.duckdns.org.
-// So strip off everything that is not top 3 levels.
-func getMainDomain(domain string) string {
-	domain = dns01.UnFqdn(domain)
-
-	split := dns.Split(domain)
-	if strings.HasSuffix(strings.ToLower(domain), "duckdns.org") {
-		if len(split) < 3 {
-			return ""
-		}
-
-		firstSubDomainIndex := split[len(split)-3]
-		return domain[firstSubDomainIndex:]
-	}
-
-	return domain[split[len(split)-1]:]
-}
diff --git a/providers/dns/duckdns/duckdns.go b/providers/dns/duckdns/duckdns.go
index 65dafab7..8cb82aed 100644
--- a/providers/dns/duckdns/duckdns.go
+++ b/providers/dns/duckdns/duckdns.go
@@ -3,6 +3,7 @@
 package duckdns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/duckdns/internal"
 )
 
 // Environment variables names.
@@ -48,6 +50,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a new DNS provider using
@@ -74,19 +77,25 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("duckdns: credentials missing")
 	}
 
-	return &DNSProvider{config: config}, nil
+	client := internal.NewClient(config.Token)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record to fulfill the dns-01 challenge.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
-	return d.updateTxtRecord(dns01.UnFqdn(info.EffectiveFQDN), d.config.Token, info.Value, false)
+	return d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value)
 }
 
 // CleanUp clears DuckDNS TXT record.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
-	return d.updateTxtRecord(dns01.UnFqdn(info.EffectiveFQDN), d.config.Token, "", true)
+	return d.client.RemoveTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN))
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
diff --git a/providers/dns/duckdns/duckdns_test.go b/providers/dns/duckdns/duckdns_test.go
index 0fd291ab..b89966a3 100644
--- a/providers/dns/duckdns/duckdns_test.go
+++ b/providers/dns/duckdns/duckdns_test.go
@@ -5,7 +5,6 @@ import (
 	"time"
 
 	"github.com/go-acme/lego/v4/platform/tester"
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -89,65 +88,6 @@ func TestNewDNSProviderConfig(t *testing.T) {
 	}
 }
 
-func Test_getMainDomain(t *testing.T) {
-	testCases := []struct {
-		desc     string
-		domain   string
-		expected string
-	}{
-		{
-			desc:     "empty",
-			domain:   "",
-			expected: "",
-		},
-		{
-			desc:     "missing sub domain",
-			domain:   "duckdns.org",
-			expected: "",
-		},
-		{
-			desc:     "explicit domain: sub domain",
-			domain:   "_acme-challenge.sub.duckdns.org",
-			expected: "sub.duckdns.org",
-		},
-		{
-			desc:     "explicit domain: subsub domain",
-			domain:   "_acme-challenge.my.sub.duckdns.org",
-			expected: "sub.duckdns.org",
-		},
-		{
-			desc:     "explicit domain: subsubsub domain",
-			domain:   "_acme-challenge.my.sub.sub.duckdns.org",
-			expected: "sub.duckdns.org",
-		},
-		{
-			desc:     "only subname: sub domain",
-			domain:   "_acme-challenge.sub",
-			expected: "sub",
-		},
-		{
-			desc:     "only subname: subsub domain",
-			domain:   "_acme-challenge.my.sub",
-			expected: "sub",
-		},
-		{
-			desc:     "only subname: subsubsub domain",
-			domain:   "_acme-challenge.my.sub.sub",
-			expected: "sub",
-		},
-	}
-
-	for _, test := range testCases {
-		test := test
-		t.Run(test.desc, func(t *testing.T) {
-			t.Parallel()
-
-			wDomain := getMainDomain(test.domain)
-			assert.Equal(t, test.expected, wDomain)
-		})
-	}
-}
-
 func TestLivePresent(t *testing.T) {
 	if !envTest.IsLiveTest() {
 		t.Skip("skipping live test")
diff --git a/providers/dns/duckdns/internal/client.go b/providers/dns/duckdns/internal/client.go
new file mode 100644
index 00000000..e1985ee7
--- /dev/null
+++ b/providers/dns/duckdns/internal/client.go
@@ -0,0 +1,103 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"github.com/miekg/dns"
+)
+
+const defaultBaseURL = "https://www.duckdns.org/update"
+
+// Client the DuckDNS API client.
+type Client struct {
+	token string
+
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(token string) *Client {
+	return &Client{
+		token:      token,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c Client) AddTXTRecord(ctx context.Context, domain, value string) error {
+	return c.UpdateTxtRecord(ctx, domain, value, false)
+}
+
+func (c Client) RemoveTXTRecord(ctx context.Context, domain string) error {
+	return c.UpdateTxtRecord(ctx, domain, "", true)
+}
+
+// UpdateTxtRecord Update the domains TXT record
+// To update the TXT record we just need to make one simple get request.
+// In DuckDNS you only have one TXT record shared with the domain and all subdomains.
+func (c Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clear bool) error {
+	endpoint, _ := url.Parse(defaultBaseURL)
+
+	mainDomain := getMainDomain(domain)
+	if mainDomain == "" {
+		return fmt.Errorf("unable to find the main domain for: %s", domain)
+	}
+
+	query := endpoint.Query()
+	query.Set("domains", mainDomain)
+	query.Set("token", c.token)
+	query.Set("clear", strconv.FormatBool(clear))
+	query.Set("txt", txt)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
+	if err != nil {
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	body := string(raw)
+	if body != "OK" {
+		return fmt.Errorf("request to change TXT record for DuckDNS returned the following result (%s) this does not match expectation (OK) used url [%s]", body, endpoint)
+	}
+	return nil
+}
+
+// DuckDNS only lets you write to your subdomain.
+// It must be in format subdomain.duckdns.org,
+// not in format subsubdomain.subdomain.duckdns.org.
+// So strip off everything that is not top 3 levels.
+func getMainDomain(domain string) string {
+	domain = dns01.UnFqdn(domain)
+
+	split := dns.Split(domain)
+	if strings.HasSuffix(strings.ToLower(domain), "duckdns.org") {
+		if len(split) < 3 {
+			return ""
+		}
+
+		firstSubDomainIndex := split[len(split)-3]
+		return domain[firstSubDomainIndex:]
+	}
+
+	return domain[split[len(split)-1]:]
+}
diff --git a/providers/dns/duckdns/internal/client_test.go b/providers/dns/duckdns/internal/client_test.go
new file mode 100644
index 00000000..ec3196a7
--- /dev/null
+++ b/providers/dns/duckdns/internal/client_test.go
@@ -0,0 +1,66 @@
+package internal
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_getMainDomain(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		domain   string
+		expected string
+	}{
+		{
+			desc:     "empty",
+			domain:   "",
+			expected: "",
+		},
+		{
+			desc:     "missing sub domain",
+			domain:   "duckdns.org",
+			expected: "",
+		},
+		{
+			desc:     "explicit domain: sub domain",
+			domain:   "_acme-challenge.sub.duckdns.org",
+			expected: "sub.duckdns.org",
+		},
+		{
+			desc:     "explicit domain: subsub domain",
+			domain:   "_acme-challenge.my.sub.duckdns.org",
+			expected: "sub.duckdns.org",
+		},
+		{
+			desc:     "explicit domain: subsubsub domain",
+			domain:   "_acme-challenge.my.sub.sub.duckdns.org",
+			expected: "sub.duckdns.org",
+		},
+		{
+			desc:     "only subname: sub domain",
+			domain:   "_acme-challenge.sub",
+			expected: "sub",
+		},
+		{
+			desc:     "only subname: subsub domain",
+			domain:   "_acme-challenge.my.sub",
+			expected: "sub",
+		},
+		{
+			desc:     "only subname: subsubsub domain",
+			domain:   "_acme-challenge.my.sub.sub",
+			expected: "sub",
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			wDomain := getMainDomain(test.domain)
+			assert.Equal(t, test.expected, wDomain)
+		})
+	}
+}
diff --git a/providers/dns/dyn/client.go b/providers/dns/dyn/client.go
deleted file mode 100644
index fecef33c..00000000
--- a/providers/dns/dyn/client.go
+++ /dev/null
@@ -1,147 +0,0 @@
-package dyn
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"net/http"
-)
-
-const defaultBaseURL = "https://api.dynect.net/REST"
-
-type dynResponse struct {
-	// One of 'success', 'failure', or 'incomplete'
-	Status string `json:"status"`
-
-	// The structure containing the actual results of the request
-	Data json.RawMessage `json:"data"`
-
-	// The ID of the job that was created in response to a request.
-	JobID int `json:"job_id"`
-
-	// A list of zero or more messages
-	Messages json.RawMessage `json:"msgs"`
-}
-
-type credentials struct {
-	Customer string `json:"customer_name"`
-	User     string `json:"user_name"`
-	Pass     string `json:"password"`
-}
-
-type session struct {
-	Token   string `json:"token"`
-	Version string `json:"version"`
-}
-
-type publish struct {
-	Publish bool   `json:"publish"`
-	Notes   string `json:"notes"`
-}
-
-// Starts a new Dyn API Session. Authenticates using customerName, userName,
-// password and receives a token to be used in for subsequent requests.
-func (d *DNSProvider) login() error {
-	payload := &credentials{Customer: d.config.CustomerName, User: d.config.UserName, Pass: d.config.Password}
-	dynRes, err := d.sendRequest(http.MethodPost, "Session", payload)
-	if err != nil {
-		return err
-	}
-
-	var s session
-	err = json.Unmarshal(dynRes.Data, &s)
-	if err != nil {
-		return err
-	}
-
-	d.token = s.Token
-
-	return nil
-}
-
-// Destroys Dyn Session.
-func (d *DNSProvider) logout() error {
-	if d.token == "" {
-		// nothing to do
-		return nil
-	}
-
-	url := fmt.Sprintf("%s/Session", defaultBaseURL)
-	req, err := http.NewRequest(http.MethodDelete, url, nil)
-	if err != nil {
-		return err
-	}
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Auth-Token", d.token)
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-	resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("API request failed to delete session with HTTP status code %d", resp.StatusCode)
-	}
-
-	d.token = ""
-
-	return nil
-}
-
-func (d *DNSProvider) publish(zone, notes string) error {
-	pub := &publish{Publish: true, Notes: notes}
-	resource := fmt.Sprintf("Zone/%s/", zone)
-
-	_, err := d.sendRequest(http.MethodPut, resource, pub)
-	return err
-}
-
-func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*dynResponse, error) {
-	url := fmt.Sprintf("%s/%s", defaultBaseURL, resource)
-
-	body, err := json.Marshal(payload)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(method, url, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Set("Content-Type", "application/json")
-	if len(d.token) > 0 {
-		req.Header.Set("Auth-Token", d.token)
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode >= http.StatusInternalServerError {
-		return nil, fmt.Errorf("API request failed with HTTP status code %d", resp.StatusCode)
-	}
-
-	var dynRes dynResponse
-	err = json.NewDecoder(resp.Body).Decode(&dynRes)
-	if err != nil {
-		return nil, err
-	}
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return nil, fmt.Errorf("API request failed with HTTP status code %d: %s", resp.StatusCode, dynRes.Messages)
-	} else if resp.StatusCode == http.StatusTemporaryRedirect {
-		// TODO add support for HTTP 307 response and long running jobs
-		return nil, errors.New("API request returned HTTP 307. This is currently unsupported")
-	}
-
-	if dynRes.Status == "failure" {
-		// TODO add better error handling
-		return nil, fmt.Errorf("API request failed: %s", dynRes.Messages)
-	}
-
-	return &dynRes, nil
-}
diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go
index d0dec91a..1b2d8254 100644
--- a/providers/dns/dyn/dyn.go
+++ b/providers/dns/dyn/dyn.go
@@ -2,14 +2,15 @@
 package dyn
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
-	"strconv"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/dyn/internal"
 )
 
 // Environment variables names.
@@ -52,7 +53,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
-	token  string
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for Dyn DNS.
@@ -82,7 +83,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("dyn: credentials missing")
 	}
 
-	return &DNSProvider{config: config}, nil
+	client := internal.NewClient(config.CustomerName, config.UserName, config.Password)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record using the specified parameters.
@@ -91,33 +98,25 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("dyn: %w", err)
+		return fmt.Errorf("dyn: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	err = d.login()
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
 		return fmt.Errorf("dyn: %w", err)
 	}
 
-	data := map[string]interface{}{
-		"rdata": map[string]string{
-			"txtdata": info.Value,
-		},
-		"ttl": strconv.Itoa(d.config.TTL),
-	}
-
-	resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, info.EffectiveFQDN)
-	_, err = d.sendRequest(http.MethodPost, resource, data)
+	err = d.client.AddTXTRecord(ctx, authZone, info.EffectiveFQDN, info.Value, d.config.TTL)
 	if err != nil {
 		return fmt.Errorf("dyn: %w", err)
 	}
 
-	err = d.publish(authZone, "Added TXT record for ACME dns-01 challenge using lego client")
+	err = d.client.Publish(ctx, authZone, "Added TXT record for ACME dns-01 challenge using lego client")
 	if err != nil {
 		return fmt.Errorf("dyn: %w", err)
 	}
 
-	return d.logout()
+	return d.client.Logout(ctx)
 }
 
 // CleanUp removes the TXT record matching the specified parameters.
@@ -126,41 +125,25 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("dyn: %w", err)
+		return fmt.Errorf("dyn: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	err = d.login()
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
 		return fmt.Errorf("dyn: %w", err)
 	}
 
-	resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, info.EffectiveFQDN)
-	url := fmt.Sprintf("%s/%s", defaultBaseURL, resource)
-
-	req, err := http.NewRequest(http.MethodDelete, url, nil)
+	err = d.client.RemoveTXTRecord(ctx, authZone, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("dyn: %w", err)
 	}
 
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Auth-Token", d.token)
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return fmt.Errorf("dyn: %w", err)
-	}
-	resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("dyn: API request failed to delete TXT record HTTP status code %d", resp.StatusCode)
-	}
-
-	err = d.publish(authZone, "Removed TXT record for ACME dns-01 challenge using lego client")
+	err = d.client.Publish(ctx, authZone, "Removed TXT record for ACME dns-01 challenge using lego client")
 	if err != nil {
 		return fmt.Errorf("dyn: %w", err)
 	}
 
-	return d.logout()
+	return d.client.Logout(ctx)
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
diff --git a/providers/dns/dyn/internal/client.go b/providers/dns/dyn/internal/client.go
new file mode 100644
index 00000000..43981cc4
--- /dev/null
+++ b/providers/dns/dyn/internal/client.go
@@ -0,0 +1,178 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://api.dynect.net/REST"
+
+// Client the Dyn API client.
+type Client struct {
+	customerName string
+	username     string
+	password     string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(customerName string, username string, password string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		customerName: customerName,
+		username:     username,
+		password:     password,
+		baseURL:      baseURL,
+		HTTPClient:   &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// Publish updating Zone settings.
+// https://help.dyn.com/update-zone-api/
+func (c *Client) Publish(ctx context.Context, zone, notes string) error {
+	endpoint := c.baseURL.JoinPath("Zone", zone)
+
+	payload := &publish{Publish: true, Notes: notes}
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, payload)
+	if err != nil {
+		return err
+	}
+
+	_, err = c.do(req)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// AddTXTRecord creating TXT Records.
+// https://help.dyn.com/create-txt-record-api/
+func (c *Client) AddTXTRecord(ctx context.Context, authZone, fqdn, value string, ttl int) error {
+	endpoint := c.baseURL.JoinPath("TXTRecord", authZone, fqdn)
+
+	payload := map[string]any{
+		"rdata": map[string]string{
+			"txtdata": value,
+		},
+		"ttl": strconv.Itoa(ttl),
+	}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
+	if err != nil {
+		return err
+	}
+
+	_, err = c.do(req)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// RemoveTXTRecord deleting one or all existing TXT Records.
+// https://help.dyn.com/delete-txt-records-api/
+func (c *Client) RemoveTXTRecord(ctx context.Context, authZone, fqdn string) error {
+	endpoint := c.baseURL.JoinPath("TXTRecord", authZone, fqdn)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	return nil
+}
+
+func (c *Client) do(req *http.Request) (*APIResponse, error) {
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode >= http.StatusInternalServerError {
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var response APIResponse
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	if resp.StatusCode >= http.StatusBadRequest {
+		return nil, fmt.Errorf("%s: %w", response.Messages, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw))
+	}
+
+	if resp.StatusCode == http.StatusTemporaryRedirect {
+		// TODO add support for HTTP 307 response and long running jobs
+		return nil, errors.New("API request returned HTTP 307. This is currently unsupported")
+	}
+
+	if response.Status == "failure" {
+		return nil, fmt.Errorf("%s: %w", response.Messages, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw))
+	}
+
+	return &response, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	tok := getToken(req.Context())
+	if tok != "" {
+		req.Header.Set(authTokenHeader, tok)
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/dyn/internal/client_test.go b/providers/dns/dyn/internal/client_test.go
new file mode 100644
index 00000000..87bee1cd
--- /dev/null
+++ b/providers/dns/dyn/internal/client_test.go
@@ -0,0 +1,122 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, pattern string, handlerFunc http.HandlerFunc) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, handlerFunc)
+
+	client := NewClient("bob", "user", "secret")
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func authenticatedHandler(method string, status int, file string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		token := req.Header.Get(authTokenHeader)
+		if token != "tok" {
+			http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), http.StatusUnauthorized)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+}
+
+func unauthenticatedHandler(method string, status int, file string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		token := req.Header.Get(authTokenHeader)
+		if token != "" {
+			http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), http.StatusUnauthorized)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+}
+
+func TestClient_Publish(t *testing.T) {
+	client := setupTest(t, "/Zone/example.com", unauthenticatedHandler(http.MethodPut, http.StatusOK, "publish.json"))
+
+	err := client.Publish(context.Background(), "example.com", "my message")
+	require.NoError(t, err)
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+	client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodPost, http.StatusCreated, "create-txt-record.json"))
+
+	err := client.AddTXTRecord(context.Background(), "example.com", "example.com.", "txt", 120)
+	require.NoError(t, err)
+}
+
+func TestClient_RemoveTXTRecord(t *testing.T) {
+	client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodDelete, http.StatusOK, ""))
+
+	err := client.RemoveTXTRecord(context.Background(), "example.com", "example.com.")
+	require.NoError(t, err)
+}
diff --git a/providers/dns/dyn/internal/fixtures/create-txt-record.json b/providers/dns/dyn/internal/fixtures/create-txt-record.json
new file mode 100644
index 00000000..fd09a5d4
--- /dev/null
+++ b/providers/dns/dyn/internal/fixtures/create-txt-record.json
@@ -0,0 +1,10 @@
+{
+  "fqdn": "example.com.",
+  "rdata": {
+    "txtdata": "txt"
+  },
+  "record_type": "TXT",
+  "ttl": 120,
+  "zone": "example.com"
+}
+
diff --git a/providers/dns/dyn/internal/fixtures/login.json b/providers/dns/dyn/internal/fixtures/login.json
new file mode 100644
index 00000000..86434d7b
--- /dev/null
+++ b/providers/dns/dyn/internal/fixtures/login.json
@@ -0,0 +1,9 @@
+{
+  "status": "success",
+  "data": {
+    "token": "tok",
+    "version": "456"
+  },
+  "job_id": 123,
+  "msgs": []
+}
diff --git a/providers/dns/dyn/internal/fixtures/publish.json b/providers/dns/dyn/internal/fixtures/publish.json
new file mode 100644
index 00000000..c7e27945
--- /dev/null
+++ b/providers/dns/dyn/internal/fixtures/publish.json
@@ -0,0 +1,6 @@
+{
+  "status": "success",
+  "data": {},
+  "job_id": 123,
+  "msgs": []
+}
diff --git a/providers/dns/dyn/internal/session.go b/providers/dns/dyn/internal/session.go
new file mode 100644
index 00000000..647080fa
--- /dev/null
+++ b/providers/dns/dyn/internal/session.go
@@ -0,0 +1,89 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+type token string
+
+const tokenKey token = "token"
+
+const authTokenHeader = "Auth-Token"
+
+// login Starts a new Dyn API Session. Authenticates using customerName, username, password
+// and receives a token to be used in for subsequent requests.
+// https://help.dyn.com/session-log-in/
+func (c *Client) login(ctx context.Context) (session, error) {
+	endpoint := c.baseURL.JoinPath("Session")
+
+	payload := &credentials{Customer: c.customerName, User: c.username, Pass: c.password}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
+	if err != nil {
+		return session{}, err
+	}
+
+	dynRes, err := c.do(req)
+	if err != nil {
+		return session{}, err
+	}
+
+	var s session
+	err = json.Unmarshal(dynRes.Data, &s)
+	if err != nil {
+		return session{}, errutils.NewUnmarshalError(req, http.StatusOK, dynRes.Data, err)
+	}
+
+	return s, nil
+}
+
+// Logout Destroys Dyn Session.
+// https://help.dyn.com/session-log-out/
+func (c *Client) Logout(ctx context.Context) error {
+	endpoint := c.baseURL.JoinPath("Session")
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	tok := getToken(ctx)
+	if tok != "" {
+		req.Header.Set(authTokenHeader, tok)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	return nil
+}
+
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+	tok, err := c.login(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return context.WithValue(ctx, tokenKey, tok.Token), nil
+}
+
+func getToken(ctx context.Context) string {
+	tok, ok := ctx.Value(tokenKey).(string)
+	if !ok {
+		return ""
+	}
+
+	return tok
+}
diff --git a/providers/dns/dyn/internal/session_test.go b/providers/dns/dyn/internal/session_test.go
new file mode 100644
index 00000000..76d5bef4
--- /dev/null
+++ b/providers/dns/dyn/internal/session_test.go
@@ -0,0 +1,42 @@
+package internal
+
+import (
+	"context"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func mockContext() context.Context {
+	return context.WithValue(context.Background(), tokenKey, "tok")
+}
+
+func TestClient_login(t *testing.T) {
+	client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json"))
+
+	sess, err := client.login(context.Background())
+	require.NoError(t, err)
+
+	expected := session{Token: "tok", Version: "456"}
+
+	assert.Equal(t, expected, sess)
+}
+
+func TestClient_Logout(t *testing.T) {
+	client := setupTest(t, "/Session", authenticatedHandler(http.MethodDelete, http.StatusOK, ""))
+
+	err := client.Logout(mockContext())
+	require.NoError(t, err)
+}
+
+func TestClient_CreateAuthenticatedContext(t *testing.T) {
+	client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json"))
+
+	ctx, err := client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
+
+	at := getToken(ctx)
+	assert.Equal(t, "tok", at)
+}
diff --git a/providers/dns/dyn/internal/types.go b/providers/dns/dyn/internal/types.go
new file mode 100644
index 00000000..2b039c4e
--- /dev/null
+++ b/providers/dns/dyn/internal/types.go
@@ -0,0 +1,33 @@
+package internal
+
+import "encoding/json"
+
+type APIResponse struct {
+	// One of 'success', 'failure', or 'incomplete'
+	Status string `json:"status"`
+
+	// The structure containing the actual results of the request
+	Data json.RawMessage `json:"data"`
+
+	// The ID of the job that was created in response to a request.
+	JobID int `json:"job_id"`
+
+	// A list of zero or more messages
+	Messages json.RawMessage `json:"msgs"`
+}
+
+type credentials struct {
+	Customer string `json:"customer_name"`
+	User     string `json:"user_name"`
+	Pass     string `json:"password"`
+}
+
+type session struct {
+	Token   string `json:"token"`
+	Version string `json:"version"`
+}
+
+type publish struct {
+	Publish bool   `json:"publish"`
+	Notes   string `json:"notes"`
+}
diff --git a/providers/dns/dynu/dynu.go b/providers/dns/dynu/dynu.go
index a533a1c2..d0c396a2 100644
--- a/providers/dns/dynu/dynu.go
+++ b/providers/dns/dynu/dynu.go
@@ -2,6 +2,7 @@
 package dynu
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -97,12 +98,14 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	rootDomain, err := d.client.GetRootDomain(dns01.UnFqdn(info.EffectiveFQDN))
+	ctx := context.Background()
+
+	rootDomain, err := d.client.GetRootDomain(ctx, dns01.UnFqdn(info.EffectiveFQDN))
 	if err != nil {
 		return fmt.Errorf("dynu: could not find root domain for %s: %w", domain, err)
 	}
 
-	records, err := d.client.GetRecords(dns01.UnFqdn(info.EffectiveFQDN), "TXT")
+	records, err := d.client.GetRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN), "TXT")
 	if err != nil {
 		return fmt.Errorf("dynu: failed to get records for %s: %w", domain, err)
 	}
@@ -129,7 +132,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:        d.config.TTL,
 	}
 
-	err = d.client.AddNewRecord(rootDomain.ID, record)
+	err = d.client.AddNewRecord(ctx, rootDomain.ID, record)
 	if err != nil {
 		return fmt.Errorf("dynu: failed to add record to %s: %w", domain, err)
 	}
@@ -141,19 +144,21 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	rootDomain, err := d.client.GetRootDomain(dns01.UnFqdn(info.EffectiveFQDN))
+	ctx := context.Background()
+
+	rootDomain, err := d.client.GetRootDomain(ctx, dns01.UnFqdn(info.EffectiveFQDN))
 	if err != nil {
 		return fmt.Errorf("dynu: could not find root domain for %s: %w", domain, err)
 	}
 
-	records, err := d.client.GetRecords(dns01.UnFqdn(info.EffectiveFQDN), "TXT")
+	records, err := d.client.GetRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN), "TXT")
 	if err != nil {
 		return fmt.Errorf("dynu: failed to get records for %s: %w", domain, err)
 	}
 
 	for _, record := range records {
 		if record.Hostname == dns01.UnFqdn(info.EffectiveFQDN) && record.TextData == info.Value {
-			err = d.client.DeleteRecord(rootDomain.ID, record.ID)
+			err = d.client.DeleteRecord(ctx, rootDomain.ID, record.ID)
 			if err != nil {
 				return fmt.Errorf("dynu: failed to remove TXT record for %s: %w", domain, err)
 			}
diff --git a/providers/dns/dynu/internal/client.go b/providers/dns/dynu/internal/client.go
index a65681ca..d9e6e5bf 100644
--- a/providers/dns/dynu/internal/client.go
+++ b/providers/dns/dynu/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -13,35 +14,35 @@ import (
 
 	"github.com/cenkalti/backoff/v4"
 	"github.com/go-acme/lego/v4/log"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://api.dynu.com/v2"
 
 type Client struct {
+	baseURL    *url.URL
 	HTTPClient *http.Client
-	BaseURL    string
 }
 
 func NewClient() *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    defaultBaseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+		baseURL:    baseURL,
 	}
 }
 
 // GetRecords Get DNS records based on a hostname and resource record type.
-func (c Client) GetRecords(hostname, recordType string) ([]DNSRecord, error) {
-	endpoint, err := c.createEndpoint("dns", "record", hostname)
-	if err != nil {
-		return nil, err
-	}
+func (c Client) GetRecords(ctx context.Context, hostname, recordType string) ([]DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("dns", "record", hostname)
 
 	query := endpoint.Query()
 	query.Set("recordType", recordType)
 	endpoint.RawQuery = query.Encode()
 
 	apiResp := RecordsResponse{}
-	err = c.doRetry(http.MethodGet, endpoint.String(), nil, &apiResp)
+	err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp)
 	if err != nil {
 		return nil, err
 	}
@@ -54,19 +55,16 @@ func (c Client) GetRecords(hostname, recordType string) ([]DNSRecord, error) {
 }
 
 // AddNewRecord Add a new DNS record for DNS service.
-func (c Client) AddNewRecord(domainID int64, record DNSRecord) error {
-	endpoint, err := c.createEndpoint("dns", strconv.FormatInt(domainID, 10), "record")
-	if err != nil {
-		return err
-	}
+func (c Client) AddNewRecord(ctx context.Context, domainID int64, record DNSRecord) error {
+	endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record")
 
 	reqBody, err := json.Marshal(record)
 	if err != nil {
-		return err
+		return fmt.Errorf("failed to create request JSON body: %w", err)
 	}
 
 	apiResp := RecordResponse{}
-	err = c.doRetry(http.MethodPost, endpoint.String(), reqBody, &apiResp)
+	err = c.doRetry(ctx, http.MethodPost, endpoint.String(), reqBody, &apiResp)
 	if err != nil {
 		return err
 	}
@@ -79,14 +77,11 @@ func (c Client) AddNewRecord(domainID int64, record DNSRecord) error {
 }
 
 // DeleteRecord Remove a DNS record from DNS service.
-func (c Client) DeleteRecord(domainID, recordID int64) error {
-	endpoint, err := c.createEndpoint("dns", strconv.FormatInt(domainID, 10), "record", strconv.FormatInt(recordID, 10))
-	if err != nil {
-		return err
-	}
+func (c Client) DeleteRecord(ctx context.Context, domainID, recordID int64) error {
+	endpoint := c.baseURL.JoinPath("dns", strconv.FormatInt(domainID, 10), "record", strconv.FormatInt(recordID, 10))
 
 	apiResp := APIException{}
-	err = c.doRetry(http.MethodDelete, endpoint.String(), nil, &apiResp)
+	err := c.doRetry(ctx, http.MethodDelete, endpoint.String(), nil, &apiResp)
 	if err != nil {
 		return err
 	}
@@ -99,14 +94,11 @@ func (c Client) DeleteRecord(domainID, recordID int64) error {
 }
 
 // GetRootDomain Get the root domain name based on a hostname.
-func (c Client) GetRootDomain(hostname string) (*DNSHostname, error) {
-	endpoint, err := c.createEndpoint("dns", "getroot", hostname)
-	if err != nil {
-		return nil, err
-	}
+func (c Client) GetRootDomain(ctx context.Context, hostname string) (*DNSHostname, error) {
+	endpoint := c.baseURL.JoinPath("dns", "getroot", hostname)
 
 	apiResp := DNSHostname{}
-	err = c.doRetry(http.MethodGet, endpoint.String(), nil, &apiResp)
+	err := c.doRetry(ctx, http.MethodGet, endpoint.String(), nil, &apiResp)
 	if err != nil {
 		return nil, err
 	}
@@ -119,33 +111,9 @@ func (c Client) GetRootDomain(hostname string) (*DNSHostname, error) {
 }
 
 // doRetry the API is really unstable so we need to retry on EOF.
-func (c Client) doRetry(method, uri string, body []byte, data interface{}) error {
-	var resp *http.Response
-
+func (c Client) doRetry(ctx context.Context, method, uri string, body []byte, result any) error {
 	operation := func() error {
-		var reqBody io.Reader
-		if len(body) > 0 {
-			reqBody = bytes.NewReader(body)
-		}
-
-		req, err := http.NewRequest(method, uri, reqBody)
-		if err != nil {
-			return err
-		}
-
-		req.Header.Set("Content-Type", "application/json")
-		req.Header.Set("Accept", "application/json")
-
-		resp, err = c.HTTPClient.Do(req)
-		if errors.Is(err, io.EOF) {
-			return err
-		}
-
-		if err != nil {
-			return backoff.Permanent(fmt.Errorf("client error: %w", err))
-		}
-
-		return nil
+		return c.do(ctx, method, uri, body, result)
 	}
 
 	notify := func(err error, duration time.Duration) {
@@ -160,21 +128,43 @@ func (c Client) doRetry(method, uri string, body []byte, data interface{}) error
 		return err
 	}
 
+	return nil
+}
+
+func (c Client) do(ctx context.Context, method, uri string, body []byte, result any) error {
+	var reqBody io.Reader
+	if len(body) > 0 {
+		reqBody = bytes.NewReader(body)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, uri, reqBody)
+	if err != nil {
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.HTTPClient.Do(req)
+	if errors.Is(err, io.EOF) {
+		return err
+	}
+
+	if err != nil {
+		return backoff.Permanent(fmt.Errorf("client error: %w", errutils.NewHTTPDoError(req, err)))
+	}
+
 	defer func() { _ = resp.Body.Close() }()
 
-	all, err := io.ReadAll(resp.Body)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return fmt.Errorf("failed to read response body: %w", err)
+		return backoff.Permanent(errutils.NewReadResponseError(req, resp.StatusCode, err))
 	}
 
-	return json.Unmarshal(all, data)
-}
-
-func (c Client) createEndpoint(fragments ...string) (*url.URL, error) {
-	baseURL, err := url.Parse(c.BaseURL)
+	err = json.Unmarshal(raw, result)
 	if err != nil {
-		return nil, err
+		return backoff.Permanent(errutils.NewUnmarshalError(req, resp.StatusCode, raw, err))
 	}
 
-	return baseURL.JoinPath(fragments...), nil
+	return nil
 }
diff --git a/providers/dns/dynu/internal/client_test.go b/providers/dns/dynu/internal/client_test.go
index 56eef940..005ceff1 100644
--- a/providers/dns/dynu/internal/client_test.go
+++ b/providers/dns/dynu/internal/client_test.go
@@ -1,10 +1,12 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -43,7 +45,7 @@ func setupTest(t *testing.T, method, pattern string, status int, file string) *C
 
 	client := NewClient()
 	client.HTTPClient = server.Client()
-	client.BaseURL = server.URL
+	client.baseURL, _ = url.Parse(server.URL)
 
 	return client
 }
@@ -96,7 +98,7 @@ func TestGetRootDomain(t *testing.T) {
 
 			client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file)
 
-			domain, err := client.GetRootDomain("test.lego.freeddns.org")
+			domain, err := client.GetRootDomain(context.Background(), "test.lego.freeddns.org")
 
 			if test.expected.error != "" {
 				assert.EqualError(t, err, test.expected.error)
@@ -185,7 +187,7 @@ func TestGetRecords(t *testing.T) {
 
 			client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file)
 
-			records, err := client.GetRecords("_acme-challenge.lego.freeddns.org", "TXT")
+			records, err := client.GetRecords(context.Background(), "_acme-challenge.lego.freeddns.org", "TXT")
 
 			if test.expected.error != "" {
 				assert.EqualError(t, err, test.expected.error)
@@ -246,7 +248,7 @@ func TestAddNewRecord(t *testing.T) {
 				TTL:        300,
 			}
 
-			err := client.AddNewRecord(9007481, record)
+			err := client.AddNewRecord(context.Background(), 9007481, record)
 
 			if test.expected.error != "" {
 				assert.EqualError(t, err, test.expected.error)
@@ -294,7 +296,7 @@ func TestDeleteRecord(t *testing.T) {
 
 			client := setupTest(t, http.MethodDelete, test.pattern, test.status, test.file)
 
-			err := client.DeleteRecord(9007481, 6041418)
+			err := client.DeleteRecord(context.Background(), 9007481, 6041418)
 
 			if test.expected.error != "" {
 				assert.EqualError(t, err, test.expected.error)
diff --git a/providers/dns/dynu/internal/model.go b/providers/dns/dynu/internal/types.go
similarity index 100%
rename from providers/dns/dynu/internal/model.go
rename to providers/dns/dynu/internal/types.go
diff --git a/providers/dns/easydns/client.go b/providers/dns/easydns/client.go
deleted file mode 100644
index 2d3950ef..00000000
--- a/providers/dns/easydns/client.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package easydns
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"net/url"
-)
-
-const defaultEndpoint = "https://rest.easydns.net"
-
-type zoneRecord struct {
-	ID      string `json:"id,omitempty"`
-	Domain  string `json:"domain"`
-	Host    string `json:"host"`
-	TTL     string `json:"ttl"`
-	Prio    string `json:"prio"`
-	Type    string `json:"type"`
-	Rdata   string `json:"rdata"`
-	LastMod string `json:"last_mod,omitempty"`
-	Revoked int    `json:"revoked,omitempty"`
-	NewHost string `json:"new_host,omitempty"`
-}
-
-type addRecordResponse struct {
-	Msg    string     `json:"msg"`
-	Tm     int        `json:"tm"`
-	Data   zoneRecord `json:"data"`
-	Status int        `json:"status"`
-}
-
-func (d *DNSProvider) addRecord(domain string, record interface{}) (string, error) {
-	endpoint := d.config.Endpoint.JoinPath("zones", "records", "add", domain, "TXT")
-
-	response := &addRecordResponse{}
-	err := d.doRequest(http.MethodPut, endpoint, record, response)
-	if err != nil {
-		return "", err
-	}
-
-	recordID := response.Data.ID
-
-	return recordID, nil
-}
-
-func (d *DNSProvider) deleteRecord(domain, recordID string) error {
-	endpoint := d.config.Endpoint.JoinPath("zones", "records", domain, recordID)
-
-	return d.doRequest(http.MethodDelete, endpoint, nil, nil)
-}
-
-func (d *DNSProvider) doRequest(method string, endpoint *url.URL, requestMsg, responseMsg interface{}) error {
-	reqBody := &bytes.Buffer{}
-	if requestMsg != nil {
-		err := json.NewEncoder(reqBody).Encode(requestMsg)
-		if err != nil {
-			return err
-		}
-	}
-
-	query := endpoint.Query()
-	query.Set("format", "json")
-	endpoint.RawQuery = query.Encode()
-
-	request, err := http.NewRequest(method, endpoint.String(), reqBody)
-	if err != nil {
-		return err
-	}
-
-	request.Header.Set("Content-Type", "application/json")
-	request.Header.Set("Accept", "application/json")
-	request.SetBasicAuth(d.config.Token, d.config.Key)
-
-	response, err := d.config.HTTPClient.Do(request)
-	if err != nil {
-		return err
-	}
-	defer response.Body.Close()
-
-	if response.StatusCode >= http.StatusBadRequest {
-		body, err := io.ReadAll(response.Body)
-		if err != nil {
-			return fmt.Errorf("%d: failed to read response body: %w", response.StatusCode, err)
-		}
-
-		return fmt.Errorf("%d: request failed: %v", response.StatusCode, string(body))
-	}
-
-	if responseMsg != nil {
-		return json.NewDecoder(response.Body).Decode(responseMsg)
-	}
-
-	return nil
-}
diff --git a/providers/dns/easydns/easydns.go b/providers/dns/easydns/easydns.go
index 5ec4b97c..40d2ec0c 100644
--- a/providers/dns/easydns/easydns.go
+++ b/providers/dns/easydns/easydns.go
@@ -2,6 +2,7 @@
 package easydns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -13,6 +14,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/easydns/internal"
 	"github.com/miekg/dns"
 )
 
@@ -58,7 +60,9 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config      *Config
+	config *Config
+	client *internal.Client
+
 	recordIDs   map[string]string
 	recordIDsMu sync.Mutex
 }
@@ -67,7 +71,7 @@ type DNSProvider struct {
 func NewDNSProvider() (*DNSProvider, error) {
 	config := NewDefaultConfig()
 
-	endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, defaultEndpoint))
+	endpoint, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL))
 	if err != nil {
 		return nil, fmt.Errorf("easydns: %w", err)
 	}
@@ -98,7 +102,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("easydns: the API key is missing")
 	}
 
-	return &DNSProvider{config: config, recordIDs: map[string]string{}}, nil
+	client := internal.NewClient(config.Token, config.Key)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	if config.Endpoint != nil {
+		client.BaseURL = config.Endpoint
+	}
+
+	return &DNSProvider{config: config, client: client, recordIDs: map[string]string{}}, nil
 }
 
 // Present creates a TXT record to fulfill the dns-01 challenge.
@@ -106,16 +120,17 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
 	apiHost, apiDomain := splitFqdn(info.EffectiveFQDN)
-	record := &zoneRecord{
-		Domain: apiDomain,
-		Host:   apiHost,
-		Type:   "TXT",
-		Rdata:  info.Value,
-		TTL:    strconv.Itoa(d.config.TTL),
-		Prio:   "0",
+
+	record := internal.ZoneRecord{
+		Domain:   apiDomain,
+		Host:     apiHost,
+		Type:     "TXT",
+		Rdata:    info.Value,
+		TTL:      strconv.Itoa(d.config.TTL),
+		Priority: "0",
 	}
 
-	recordID, err := d.addRecord(apiDomain, record)
+	recordID, err := d.client.AddRecord(context.Background(), apiDomain, record)
 	if err != nil {
 		return fmt.Errorf("easydns: error adding zone record: %w", err)
 	}
@@ -134,13 +149,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
 	key := getMapKey(info.EffectiveFQDN, info.Value)
+
+	d.recordIDsMu.Lock()
 	recordID, exists := d.recordIDs[key]
+	d.recordIDsMu.Unlock()
+
 	if !exists {
 		return nil
 	}
 
 	_, apiDomain := splitFqdn(info.EffectiveFQDN)
-	err := d.deleteRecord(apiDomain, recordID)
+
+	err := d.client.DeleteRecord(context.Background(), apiDomain, recordID)
 
 	d.recordIDsMu.Lock()
 	defer delete(d.recordIDs, key)
diff --git a/providers/dns/easydns/easydns_test.go b/providers/dns/easydns/easydns_test.go
index f67c7406..ea1f854c 100644
--- a/providers/dns/easydns/easydns_test.go
+++ b/providers/dns/easydns/easydns_test.go
@@ -14,6 +14,8 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+const authorizationHeader = "Authorization"
+
 const envDomain = envNamespace + "DOMAIN"
 
 var envTest = tester.NewEnvTest(
@@ -149,7 +151,7 @@ func TestDNSProvider_Present(t *testing.T) {
 		assert.Equal(t, http.MethodPut, r.Method, "method")
 		assert.Equal(t, "format=json", r.URL.RawQuery, "query")
 		assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type")
-		assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get("Authorization"), "Authorization")
+		assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader)
 
 		reqBody, err := io.ReadAll(r.Body)
 		if err != nil {
@@ -201,7 +203,7 @@ func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) {
 	mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, http.MethodDelete, r.Method, "method")
 		assert.Equal(t, "format=json", r.URL.RawQuery, "query")
-		assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get("Authorization"), "Authorization")
+		assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader)
 
 		w.WriteHeader(http.StatusOK)
 		_, err := fmt.Fprintf(w, `{
@@ -235,7 +237,7 @@ func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) {
 	mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, http.MethodDelete, r.Method, "method")
 		assert.Equal(t, "format=json", r.URL.RawQuery, "query")
-		assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get("Authorization"), "Authorization")
+		assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader)
 
 		w.WriteHeader(http.StatusNotAcceptable)
 		_, err := fmt.Fprint(w, errorMessage)
@@ -247,7 +249,7 @@ func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) {
 
 	provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456"
 	err := provider.CleanUp("example.com", "token", "keyAuth")
-	expectedError := fmt.Sprintf("easydns: 406: request failed: %v", errorMessage)
+	expectedError := fmt.Sprintf("easydns: unexpected status code: [status code: 406] body: %v", errorMessage)
 	require.EqualError(t, err, expectedError)
 }
 
diff --git a/providers/dns/easydns/internal/client.go b/providers/dns/easydns/internal/client.go
new file mode 100644
index 00000000..363a2fc7
--- /dev/null
+++ b/providers/dns/easydns/internal/client.go
@@ -0,0 +1,127 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// DefaultBaseURL the default API endpoint.
+const DefaultBaseURL = "https://rest.easydns.net"
+
+// Client the EasyDNS API client.
+type Client struct {
+	token string
+	key   string
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(token string, key string) *Client {
+	baseURL, _ := url.Parse(DefaultBaseURL)
+
+	return &Client{
+		token:      token,
+		key:        key,
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord) (string, error) {
+	endpoint := c.BaseURL.JoinPath("zones", "records", "add", domain, "TXT")
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record)
+	if err != nil {
+		return "", err
+	}
+
+	response := &addRecordResponse{}
+	err = c.do(req, response)
+	if err != nil {
+		return "", err
+	}
+
+	recordID := response.Data.ID
+
+	return recordID, nil
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, domain, recordID string) error {
+	endpoint := c.BaseURL.JoinPath("zones", "records", domain, recordID)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	req.SetBasicAuth(c.token, c.key)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	query := endpoint.Query()
+	query.Set("format", "json")
+	endpoint.RawQuery = query.Encode()
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/easydns/internal/client_test.go b/providers/dns/easydns/internal/client_test.go
new file mode 100644
index 00000000..7ea61d3c
--- /dev/null
+++ b/providers/dns/easydns/internal/client_test.go
@@ -0,0 +1,93 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, method, pattern string, status int, file string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		token, key, ok := req.BasicAuth()
+		if token != "tok" || key != "k" || !ok {
+			http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return
+		}
+
+		if req.URL.Query().Get("format") != "json" {
+			http.Error(rw, fmt.Sprintf("invalid format: %s", req.URL.Query().Get("format")), http.StatusBadRequest)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	client := NewClient("tok", "k")
+	client.HTTPClient = server.Client()
+	client.BaseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func TestClient_AddRecord(t *testing.T) {
+	client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "add-record.json")
+
+	record := ZoneRecord{
+		Domain:   "example.com",
+		Host:     "test631",
+		Type:     "TXT",
+		Rdata:    "txt",
+		TTL:      "300",
+		Priority: "0",
+	}
+
+	recordID, err := client.AddRecord(context.Background(), "example.com", record)
+	require.NoError(t, err)
+
+	assert.Equal(t, "xxx", recordID)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+	client := setupTest(t, http.MethodDelete, "/zones/records/example.com/xxx", http.StatusOK, "")
+
+	err := client.DeleteRecord(context.Background(), "example.com", "xxx")
+	require.NoError(t, err)
+}
diff --git a/providers/dns/easydns/internal/fixtures/add-record.json b/providers/dns/easydns/internal/fixtures/add-record.json
new file mode 100644
index 00000000..66ddf4bc
--- /dev/null
+++ b/providers/dns/easydns/internal/fixtures/add-record.json
@@ -0,0 +1,14 @@
+{
+  "msg": "message",
+  "tm": 1,
+  "data": {
+    "id": "xxx",
+    "domain": "example.com",
+    "host": "test631",
+    "ttl": "300",
+    "prio": "0",
+    "type": "TXT",
+    "rdata": "txt"
+  },
+  "status": 201
+}
diff --git a/providers/dns/easydns/internal/types.go b/providers/dns/easydns/internal/types.go
new file mode 100644
index 00000000..5235c4d7
--- /dev/null
+++ b/providers/dns/easydns/internal/types.go
@@ -0,0 +1,21 @@
+package internal
+
+type ZoneRecord struct {
+	ID       string `json:"id,omitempty"`
+	Domain   string `json:"domain"`
+	Host     string `json:"host"`
+	TTL      string `json:"ttl"`
+	Priority string `json:"prio"`
+	Type     string `json:"type"`
+	Rdata    string `json:"rdata"`
+	LastMod  string `json:"last_mod,omitempty"`
+	Revoked  int    `json:"revoked,omitempty"`
+	NewHost  string `json:"new_host,omitempty"`
+}
+
+type addRecordResponse struct {
+	Msg    string     `json:"msg"`
+	Tm     int        `json:"tm"`
+	Data   ZoneRecord `json:"data"`
+	Status int        `json:"status"`
+}
diff --git a/providers/dns/edgedns/edgedns.go b/providers/dns/edgedns/edgedns.go
index 221f5b71..df6d93e7 100644
--- a/providers/dns/edgedns/edgedns.go
+++ b/providers/dns/edgedns/edgedns.go
@@ -109,7 +109,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := findZone(info.EffectiveFQDN)
+	zone, err := getZone(info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("edgedns: %w", err)
 	}
@@ -161,7 +161,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := findZone(info.EffectiveFQDN)
+	zone, err := getZone(info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("edgedns: %w", err)
 	}
@@ -214,10 +214,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	return nil
 }
 
-func findZone(domain string) (string, error) {
+func getZone(domain string) (string, error) {
 	zone, err := dns01.FindZoneByFqdn(domain)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
 	}
 
 	return dns01.UnFqdn(zone), nil
diff --git a/providers/dns/edgedns/edgedns_integration_test.go b/providers/dns/edgedns/edgedns_integration_test.go
index 5ad0a5e3..c4044d1e 100644
--- a/providers/dns/edgedns/edgedns_integration_test.go
+++ b/providers/dns/edgedns/edgedns_integration_test.go
@@ -66,7 +66,7 @@ func TestLiveTTL(t *testing.T) {
 	}()
 
 	fqdn := "_acme-challenge." + domain + "."
-	zone, err := findZone(fqdn)
+	zone, err := getZone(fqdn)
 	require.NoError(t, err)
 
 	resourceRecordSets, err := configdns.GetRecordList(zone, fqdn, "TXT")
diff --git a/providers/dns/edgedns/edgedns_test.go b/providers/dns/edgedns/edgedns_test.go
index 3e855292..a7f17b16 100644
--- a/providers/dns/edgedns/edgedns_test.go
+++ b/providers/dns/edgedns/edgedns_test.go
@@ -173,7 +173,7 @@ func TestDNSProvider_findZone(t *testing.T) {
 		t.Run(test.desc, func(t *testing.T) {
 			t.Parallel()
 
-			zone, err := findZone(test.domain)
+			zone, err := getZone(test.domain)
 			require.NoError(t, err)
 			require.Equal(t, test.expected, zone)
 		})
diff --git a/providers/dns/epik/epik.go b/providers/dns/epik/epik.go
index 0c82b7dc..8114a21c 100644
--- a/providers/dns/epik/epik.go
+++ b/providers/dns/epik/epik.go
@@ -2,6 +2,7 @@
 package epik
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -98,7 +99,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	// find authZone
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("epik: %w", err)
+		return fmt.Errorf("epik: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -113,7 +114,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:  d.config.TTL,
 	}
 
-	_, err = d.client.CreateHostRecord(dns01.UnFqdn(authZone), record)
+	_, err = d.client.CreateHostRecord(context.Background(), dns01.UnFqdn(authZone), record)
 	if err != nil {
 		return fmt.Errorf("epik: %w", err)
 	}
@@ -128,12 +129,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	// find authZone
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("epik: %w", err)
+		return fmt.Errorf("epik: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	dom := dns01.UnFqdn(authZone)
 
-	records, err := d.client.GetDNSRecords(dom)
+	ctx := context.Background()
+
+	records, err := d.client.GetDNSRecords(ctx, dom)
 	if err != nil {
 		return fmt.Errorf("epik: %w", err)
 	}
@@ -145,7 +148,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	for _, record := range records {
 		if strings.EqualFold(record.Type, "TXT") && record.Data == info.Value && record.Name == subDomain {
-			_, err = d.client.RemoveHostRecord(dom, record.ID)
+			_, err = d.client.RemoveHostRecord(ctx, dom, record.ID)
 			if err != nil {
 				return fmt.Errorf("epik: %w", err)
 			}
diff --git a/providers/dns/epik/internal/client.go b/providers/dns/epik/internal/client.go
index d5fb7829..0ca46c2c 100644
--- a/providers/dns/epik/internal/client.go
+++ b/providers/dns/epik/internal/client.go
@@ -2,56 +2,52 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://usersapiv2.epik.com/v2"
 
+// Client the Epik API client.
 type Client struct {
-	HTTPClient *http.Client
+	signature string
+
 	baseURL    *url.URL
-	signature  string
+	HTTPClient *http.Client
 }
 
+// NewClient Creates a new Client.
 func NewClient(signature string) *Client {
 	baseURL, _ := url.Parse(defaultBaseURL)
 
 	return &Client{
-		HTTPClient: &http.Client{Timeout: 5 * time.Second},
-		baseURL:    baseURL,
 		signature:  signature,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // GetDNSRecords gets DNS records for a domain.
 // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/getDnsRecord
-func (c Client) GetDNSRecords(domain string) ([]Record, error) {
-	resp, err := c.do(http.MethodGet, domain, url.Values{}, nil)
-	if err != nil {
-		return nil, err
-	}
+func (c Client) GetDNSRecords(ctx context.Context, domain string) ([]Record, error) {
+	endpoint := c.createEndpoint(domain, url.Values{})
 
-	defer func() { _ = resp.Body.Close() }()
-
-	all, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read request body (%d): %w", resp.StatusCode, err)
-	}
-
-	err = checkError(resp.StatusCode, all)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
 	var data GetDNSRecordResponse
-	err = json.Unmarshal(all, &data)
+	err = c.do(req, &data)
 	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal request body (%d): %s", resp.StatusCode, string(all))
+		return nil, err
 	}
 
 	return data.Data.Records, nil
@@ -59,35 +55,20 @@ func (c Client) GetDNSRecords(domain string) ([]Record, error) {
 
 // CreateHostRecord creates a record for a domain.
 // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/createHostRecord
-func (c Client) CreateHostRecord(domain string, record RecordRequest) (*Data, error) {
+func (c Client) CreateHostRecord(ctx context.Context, domain string, record RecordRequest) (*Data, error) {
+	endpoint := c.createEndpoint(domain, url.Values{})
+
 	payload := CreateHostRecords{Payload: record}
 
-	body, err := json.Marshal(payload)
-	if err != nil {
-		return nil, err
-	}
-
-	resp, err := c.do(http.MethodPost, domain, url.Values{}, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	all, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read request body (%d): %w", resp.StatusCode, err)
-	}
-
-	err = checkError(resp.StatusCode, all)
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
 	if err != nil {
 		return nil, err
 	}
 
 	var data Data
-	err = json.Unmarshal(all, &data)
+	err = c.do(req, &data)
 	if err != nil {
-		return nil, fmt.Errorf("%d: %s", resp.StatusCode, string(all))
+		return nil, err
 	}
 
 	return &data, nil
@@ -95,64 +76,95 @@ func (c Client) CreateHostRecord(domain string, record RecordRequest) (*Data, er
 
 // RemoveHostRecord removes a record for a domain.
 // https://docs.userapi.epik.com/v2/#/DNS%20Host%20Records/removeHostRecord
-func (c Client) RemoveHostRecord(domain string, recordID string) (*Data, error) {
+func (c Client) RemoveHostRecord(ctx context.Context, domain string, recordID string) (*Data, error) {
 	params := url.Values{}
 	params.Set("ID", recordID)
 
-	resp, err := c.do(http.MethodDelete, domain, params, nil)
-	if err != nil {
-		return nil, err
-	}
+	endpoint := c.createEndpoint(domain, params)
 
-	defer func() { _ = resp.Body.Close() }()
-
-	all, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read request body (%d): %w", resp.StatusCode, err)
-	}
-
-	err = checkError(resp.StatusCode, all)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
 	var data Data
-	err = json.Unmarshal(all, &data)
+	err = c.do(req, &data)
 	if err != nil {
-		return nil, fmt.Errorf("%d: %s", resp.StatusCode, string(all))
+		return nil, err
 	}
 
 	return &data, nil
 }
 
-func (c *Client) do(method, domain string, params url.Values, body io.Reader) (*http.Response, error) {
+func (c Client) do(req *http.Request, result any) error {
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func (c Client) createEndpoint(domain string, params url.Values) *url.URL {
 	endpoint := c.baseURL.JoinPath("domains", domain, "records")
 
 	params.Set("SIGNATURE", c.signature)
 	endpoint.RawQuery = params.Encode()
 
-	req, err := http.NewRequest(method, endpoint.String(), body)
+	return endpoint
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Accept", "application/json")
-	if body != nil {
+
+	if payload != nil {
 		req.Header.Set("Content-Type", "application/json")
 	}
 
-	return c.HTTPClient.Do(req)
+	return req, nil
 }
 
-func checkError(statusCode int, all []byte) error {
-	if statusCode == http.StatusOK {
-		return nil
-	}
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
 
 	var apiErr APIError
-	err := json.Unmarshal(all, &apiErr)
+	err := json.Unmarshal(raw, &apiErr)
 	if err != nil {
-		return fmt.Errorf("%d: %s", statusCode, string(all))
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 	}
 
 	return &apiErr
diff --git a/providers/dns/epik/internal/client_test.go b/providers/dns/epik/internal/client_test.go
index 47159d24..a1d0186a 100644
--- a/providers/dns/epik/internal/client_test.go
+++ b/providers/dns/epik/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -14,8 +15,9 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setupTest(t *testing.T) (*http.ServeMux, *Client) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
@@ -24,15 +26,15 @@ func setupTest(t *testing.T) (*http.ServeMux, *Client) {
 	client.HTTPClient = server.Client()
 	client.baseURL, _ = url.Parse(server.URL)
 
-	return mux, client
+	return client, mux
 }
 
 func TestClient_GetDNSRecords(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusOK, "getDnsRecord.json"))
 
-	records, err := client.GetDNSRecords("example.com")
+	records, err := client.GetDNSRecords(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -87,16 +89,16 @@ func TestClient_GetDNSRecords(t *testing.T) {
 }
 
 func TestClient_GetDNSRecords_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
 
-	_, err := client.GetDNSRecords("example.com")
+	_, err := client.GetDNSRecords(context.Background(), "example.com")
 	assert.Error(t, err)
 }
 
 func TestClient_CreateHostRecord(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusOK, "createHostRecord.json"))
 
@@ -108,7 +110,7 @@ func TestClient_CreateHostRecord(t *testing.T) {
 		TTL:  300,
 	}
 
-	data, err := client.CreateHostRecord("example.com", record)
+	data, err := client.CreateHostRecord(context.Background(), "example.com", record)
 	require.NoError(t, err)
 
 	expected := &Data{
@@ -120,7 +122,7 @@ func TestClient_CreateHostRecord(t *testing.T) {
 }
 
 func TestClient_CreateHostRecord_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json"))
 
@@ -132,16 +134,16 @@ func TestClient_CreateHostRecord_error(t *testing.T) {
 		TTL:  300,
 	}
 
-	_, err := client.CreateHostRecord("example.com", record)
+	_, err := client.CreateHostRecord(context.Background(), "example.com", record)
 	assert.Error(t, err)
 }
 
 func TestClient_RemoveHostRecord(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusOK, "removeHostRecord.json"))
 
-	data, err := client.RemoveHostRecord("example.com", "abc123")
+	data, err := client.RemoveHostRecord(context.Background(), "example.com", "abc123")
 	require.NoError(t, err)
 
 	expected := &Data{
@@ -153,11 +155,11 @@ func TestClient_RemoveHostRecord(t *testing.T) {
 }
 
 func TestClient_RemoveHostRecord_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json"))
 
-	_, err := client.RemoveHostRecord("example.com", "abc123")
+	_, err := client.RemoveHostRecord(context.Background(), "example.com", "abc123")
 	assert.Error(t, err)
 }
 
diff --git a/providers/dns/exec/exec.go b/providers/dns/exec/exec.go
index a6252ac7..a07cba0a 100644
--- a/providers/dns/exec/exec.go
+++ b/providers/dns/exec/exec.go
@@ -2,6 +2,7 @@
 package exec
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"os"
@@ -67,7 +68,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 // for adding and removing the DNS record.
 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	if config == nil {
-		return nil, errors.New("the configuration is nil")
+		return nil, errors.New("exec: the configuration is nil")
 	}
 
 	return &DNSProvider{config: config}, nil
@@ -75,42 +76,22 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 
 // Present creates a TXT record to fulfill the dns-01 challenge.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
-	var args []string
-	if d.config.Mode == "RAW" {
-		args = []string{"present", "--", domain, token, keyAuth}
-	} else {
-		info := dns01.GetChallengeInfo(domain, keyAuth)
-		args = []string{"present", info.EffectiveFQDN, info.Value}
+	err := d.run(context.Background(), "present", domain, token, keyAuth)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
 	}
 
-	cmd := exec.Command(d.config.Program, args...)
-
-	output, err := cmd.CombinedOutput()
-	if len(output) > 0 {
-		log.Println(string(output))
-	}
-
-	return err
+	return nil
 }
 
 // CleanUp removes the TXT record matching the specified parameters.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
-	var args []string
-	if d.config.Mode == "RAW" {
-		args = []string{"cleanup", "--", domain, token, keyAuth}
-	} else {
-		info := dns01.GetChallengeInfo(domain, keyAuth)
-		args = []string{"cleanup", info.EffectiveFQDN, info.Value}
+	err := d.run(context.Background(), "cleanup", domain, token, keyAuth)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
 	}
 
-	cmd := exec.Command(d.config.Program, args...)
-
-	output, err := cmd.CombinedOutput()
-	if len(output) > 0 {
-		log.Println(string(output))
-	}
-
-	return err
+	return nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -124,3 +105,22 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Sequential() time.Duration {
 	return d.config.SequenceInterval
 }
+
+func (d *DNSProvider) run(ctx context.Context, command, domain, token, keyAuth string) error {
+	var args []string
+	if d.config.Mode == "RAW" {
+		args = []string{command, "--", domain, token, keyAuth}
+	} else {
+		info := dns01.GetChallengeInfo(domain, keyAuth)
+		args = []string{command, info.EffectiveFQDN, info.Value}
+	}
+
+	cmd := exec.CommandContext(ctx, d.config.Program, args...)
+
+	output, err := cmd.CombinedOutput()
+	if len(output) > 0 {
+		log.Println(string(output))
+	}
+
+	return err
+}
diff --git a/providers/dns/exoscale/exoscale.go b/providers/dns/exoscale/exoscale.go
index e92bdd44..770899f9 100644
--- a/providers/dns/exoscale/exoscale.go
+++ b/providers/dns/exoscale/exoscale.go
@@ -246,7 +246,7 @@ func (d *DNSProvider) findExistingRecordID(zoneID, recordName string) (string, e
 func (d *DNSProvider) findZoneAndRecordName(fqdn string) (string, string, error) {
 	zone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return "", "", err
+		return "", "", fmt.Errorf("designate: could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	zone = dns01.UnFqdn(zone)
diff --git a/providers/dns/gandi/client.go b/providers/dns/gandi/client.go
deleted file mode 100644
index acdc8a4c..00000000
--- a/providers/dns/gandi/client.go
+++ /dev/null
@@ -1,322 +0,0 @@
-package gandi
-
-import (
-	"bytes"
-	"encoding/xml"
-	"errors"
-	"fmt"
-	"io"
-)
-
-// types for XML-RPC method calls and parameters
-
-type param interface {
-	param()
-}
-
-type paramString struct {
-	XMLName xml.Name `xml:"param"`
-	Value   string   `xml:"value>string"`
-}
-
-type paramInt struct {
-	XMLName xml.Name `xml:"param"`
-	Value   int      `xml:"value>int"`
-}
-
-type structMember interface {
-	structMember()
-}
-
-type structMemberString struct {
-	Name  string `xml:"name"`
-	Value string `xml:"value>string"`
-}
-
-type structMemberInt struct {
-	Name  string `xml:"name"`
-	Value int    `xml:"value>int"`
-}
-
-type paramStruct struct {
-	XMLName       xml.Name       `xml:"param"`
-	StructMembers []structMember `xml:"value>struct>member"`
-}
-
-func (p paramString) param()               {}
-func (p paramInt) param()                  {}
-func (m structMemberString) structMember() {}
-func (m structMemberInt) structMember()    {}
-func (p paramStruct) param()               {}
-
-type methodCall struct {
-	XMLName    xml.Name `xml:"methodCall"`
-	MethodName string   `xml:"methodName"`
-	Params     []param  `xml:"params"`
-}
-
-// types for XML-RPC responses
-
-type response interface {
-	faultCode() int
-	faultString() string
-}
-
-type responseFault struct {
-	FaultCode   int    `xml:"fault>value>struct>member>value>int"`
-	FaultString string `xml:"fault>value>struct>member>value>string"`
-}
-
-func (r responseFault) faultCode() int      { return r.FaultCode }
-func (r responseFault) faultString() string { return r.FaultString }
-
-type responseStruct struct {
-	responseFault
-	StructMembers []struct {
-		Name     string `xml:"name"`
-		ValueInt int    `xml:"value>int"`
-	} `xml:"params>param>value>struct>member"`
-}
-
-type responseInt struct {
-	responseFault
-	Value int `xml:"params>param>value>int"`
-}
-
-type responseBool struct {
-	responseFault
-	Value bool `xml:"params>param>value>boolean"`
-}
-
-type rpcError struct {
-	faultCode   int
-	faultString string
-}
-
-func (e rpcError) Error() string {
-	return fmt.Sprintf("Gandi DNS: RPC Error: (%d) %s", e.faultCode, e.faultString)
-}
-
-// rpcCall makes an XML-RPC call to Gandi's RPC endpoint by
-// marshaling the data given in the call argument to XML and sending
-// that via HTTP Post to Gandi.
-// The response is then unmarshalled into the resp argument.
-func (d *DNSProvider) rpcCall(call *methodCall, resp response) error {
-	// marshal
-	b, err := xml.MarshalIndent(call, "", "  ")
-	if err != nil {
-		return fmt.Errorf("marshal error: %w", err)
-	}
-
-	// post
-	b = append([]byte(`<?xml version="1.0"?>`+"\n"), b...)
-	respBody, err := d.httpPost(d.config.BaseURL, "text/xml", bytes.NewReader(b))
-	if err != nil {
-		return err
-	}
-
-	// unmarshal
-	err = xml.Unmarshal(respBody, resp)
-	if err != nil {
-		return fmt.Errorf("unmarshal error: %w", err)
-	}
-	if resp.faultCode() != 0 {
-		return rpcError{
-			faultCode: resp.faultCode(), faultString: resp.faultString(),
-		}
-	}
-	return nil
-}
-
-// functions to perform API actions
-
-func (d *DNSProvider) getZoneID(domain string) (int, error) {
-	resp := &responseStruct{}
-	err := d.rpcCall(&methodCall{
-		MethodName: "domain.info",
-		Params: []param{
-			paramString{Value: d.config.APIKey},
-			paramString{Value: domain},
-		},
-	}, resp)
-	if err != nil {
-		return 0, err
-	}
-
-	var zoneID int
-	for _, member := range resp.StructMembers {
-		if member.Name == "zone_id" {
-			zoneID = member.ValueInt
-		}
-	}
-
-	if zoneID == 0 {
-		return 0, fmt.Errorf("could not determine zone_id for %s", domain)
-	}
-	return zoneID, nil
-}
-
-func (d *DNSProvider) cloneZone(zoneID int, name string) (int, error) {
-	resp := &responseStruct{}
-	err := d.rpcCall(&methodCall{
-		MethodName: "domain.zone.clone",
-		Params: []param{
-			paramString{Value: d.config.APIKey},
-			paramInt{Value: zoneID},
-			paramInt{Value: 0},
-			paramStruct{
-				StructMembers: []structMember{
-					structMemberString{
-						Name:  "name",
-						Value: name,
-					},
-				},
-			},
-		},
-	}, resp)
-	if err != nil {
-		return 0, err
-	}
-
-	var newZoneID int
-	for _, member := range resp.StructMembers {
-		if member.Name == "id" {
-			newZoneID = member.ValueInt
-		}
-	}
-
-	if newZoneID == 0 {
-		return 0, errors.New("could not determine cloned zone_id")
-	}
-	return newZoneID, nil
-}
-
-func (d *DNSProvider) newZoneVersion(zoneID int) (int, error) {
-	resp := &responseInt{}
-	err := d.rpcCall(&methodCall{
-		MethodName: "domain.zone.version.new",
-		Params: []param{
-			paramString{Value: d.config.APIKey},
-			paramInt{Value: zoneID},
-		},
-	}, resp)
-	if err != nil {
-		return 0, err
-	}
-
-	if resp.Value == 0 {
-		return 0, errors.New("could not create new zone version")
-	}
-	return resp.Value, nil
-}
-
-func (d *DNSProvider) addTXTRecord(zoneID, version int, name, value string, ttl int) error {
-	resp := &responseStruct{}
-	err := d.rpcCall(&methodCall{
-		MethodName: "domain.zone.record.add",
-		Params: []param{
-			paramString{Value: d.config.APIKey},
-			paramInt{Value: zoneID},
-			paramInt{Value: version},
-			paramStruct{
-				StructMembers: []structMember{
-					structMemberString{
-						Name:  "type",
-						Value: "TXT",
-					}, structMemberString{
-						Name:  "name",
-						Value: name,
-					}, structMemberString{
-						Name:  "value",
-						Value: value,
-					}, structMemberInt{
-						Name:  "ttl",
-						Value: ttl,
-					},
-				},
-			},
-		},
-	}, resp)
-	return err
-}
-
-func (d *DNSProvider) setZoneVersion(zoneID, version int) error {
-	resp := &responseBool{}
-	err := d.rpcCall(&methodCall{
-		MethodName: "domain.zone.version.set",
-		Params: []param{
-			paramString{Value: d.config.APIKey},
-			paramInt{Value: zoneID},
-			paramInt{Value: version},
-		},
-	}, resp)
-	if err != nil {
-		return err
-	}
-
-	if !resp.Value {
-		return errors.New("could not set zone version")
-	}
-	return nil
-}
-
-func (d *DNSProvider) setZone(domain string, zoneID int) error {
-	resp := &responseStruct{}
-	err := d.rpcCall(&methodCall{
-		MethodName: "domain.zone.set",
-		Params: []param{
-			paramString{Value: d.config.APIKey},
-			paramString{Value: domain},
-			paramInt{Value: zoneID},
-		},
-	}, resp)
-	if err != nil {
-		return err
-	}
-
-	var respZoneID int
-	for _, member := range resp.StructMembers {
-		if member.Name == "zone_id" {
-			respZoneID = member.ValueInt
-		}
-	}
-
-	if respZoneID != zoneID {
-		return fmt.Errorf("could not set new zone_id for %s", domain)
-	}
-	return nil
-}
-
-func (d *DNSProvider) deleteZone(zoneID int) error {
-	resp := &responseBool{}
-	err := d.rpcCall(&methodCall{
-		MethodName: "domain.zone.delete",
-		Params: []param{
-			paramString{Value: d.config.APIKey},
-			paramInt{Value: zoneID},
-		},
-	}, resp)
-	if err != nil {
-		return err
-	}
-
-	if !resp.Value {
-		return errors.New("could not delete zone_id")
-	}
-	return nil
-}
-
-func (d *DNSProvider) httpPost(url, bodyType string, body io.Reader) ([]byte, error) {
-	resp, err := d.config.HTTPClient.Post(url, bodyType, body)
-	if err != nil {
-		return nil, fmt.Errorf("HTTP Post Error: %w", err)
-	}
-	defer resp.Body.Close()
-
-	b, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("HTTP Post Error: %w", err)
-	}
-
-	return b, nil
-}
diff --git a/providers/dns/gandi/gandi.go b/providers/dns/gandi/gandi.go
index ccf46c78..29af01a9 100644
--- a/providers/dns/gandi/gandi.go
+++ b/providers/dns/gandi/gandi.go
@@ -2,6 +2,7 @@
 package gandi
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -10,16 +11,13 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/gandi/internal"
 )
 
 // Gandi API reference:       http://doc.rpc.gandi.net/index.html
 // Gandi API domain examples: http://doc.rpc.gandi.net/domain/faq.html
 
-const (
-	// defaultBaseURL Gandi XML-RPC endpoint used by Present and CleanUp.
-	defaultBaseURL = "https://rpc.gandi.net/xmlrpc/"
-	minTTL         = 300
-)
+const minTTL = 300
 
 // Environment variables names.
 const (
@@ -64,11 +62,16 @@ type inProgressInfo struct {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
+	config *Config
+	client *internal.Client
+
 	inProgressFQDNs     map[string]inProgressInfo
 	inProgressAuthZones map[string]struct{}
 	inProgressMu        sync.Mutex
-	config              *Config
-	// findZoneByFqdn determines the DNS zone of an fqdn. It is overridden during tests.
+
+	// findZoneByFqdn determines the DNS zone of a FQDN.
+	// It is overridden during tests.
+	// only for testing purpose.
 	findZoneByFqdn func(fqdn string) (string, error)
 }
 
@@ -96,12 +99,19 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("gandi: no API Key given")
 	}
 
-	if config.BaseURL == "" {
-		config.BaseURL = defaultBaseURL
+	client := internal.NewClient(config.APIKey)
+
+	if config.BaseURL != "" {
+		client.BaseURL = config.BaseURL
+	}
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
 	}
 
 	return &DNSProvider{
 		config:              config,
+		client:              client,
 		inProgressFQDNs:     make(map[string]inProgressInfo),
 		inProgressAuthZones: make(map[string]struct{}),
 		findZoneByFqdn:      dns01.FindZoneByFqdn,
@@ -121,10 +131,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	// find authZone and Gandi zone_id for fqdn
 	authZone, err := d.findZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("gandi: findZoneByFqdn failure: %w", err)
+		return fmt.Errorf("gandi: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	zoneID, err := d.getZoneID(authZone)
+	ctx := context.Background()
+
+	zoneID, err := d.client.GetZoneID(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("gandi: %w", err)
 	}
@@ -148,27 +160,27 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	// containing the required TXT record
 	newZoneName := fmt.Sprintf("%s [ACME Challenge %s]", dns01.UnFqdn(authZone), time.Now().Format(time.RFC822Z))
 
-	newZoneID, err := d.cloneZone(zoneID, newZoneName)
+	newZoneID, err := d.client.CloneZone(ctx, zoneID, newZoneName)
 	if err != nil {
 		return err
 	}
 
-	newZoneVersion, err := d.newZoneVersion(newZoneID)
+	newZoneVersion, err := d.client.NewZoneVersion(ctx, newZoneID)
 	if err != nil {
 		return fmt.Errorf("gandi: %w", err)
 	}
 
-	err = d.addTXTRecord(newZoneID, newZoneVersion, subDomain, info.Value, d.config.TTL)
+	err = d.client.AddTXTRecord(ctx, newZoneID, newZoneVersion, subDomain, info.Value, d.config.TTL)
 	if err != nil {
 		return fmt.Errorf("gandi: %w", err)
 	}
 
-	err = d.setZoneVersion(newZoneID, newZoneVersion)
+	err = d.client.SetZoneVersion(ctx, newZoneID, newZoneVersion)
 	if err != nil {
 		return fmt.Errorf("gandi: %w", err)
 	}
 
-	err = d.setZone(authZone, newZoneID)
+	err = d.client.SetZone(ctx, authZone, newZoneID)
 	if err != nil {
 		return fmt.Errorf("gandi: %w", err)
 	}
@@ -205,13 +217,15 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	delete(d.inProgressFQDNs, info.EffectiveFQDN)
 	delete(d.inProgressAuthZones, authZone)
 
+	ctx := context.Background()
+
 	// perform API actions to restore old gandi zone for authZone
-	err := d.setZone(authZone, zoneID)
+	err := d.client.SetZone(ctx, authZone, zoneID)
 	if err != nil {
 		return fmt.Errorf("gandi: %w", err)
 	}
 
-	return d.deleteZone(newZoneID)
+	return d.client.DeleteZone(ctx, newZoneID)
 }
 
 // Timeout returns the values (40*time.Minute, 60*time.Second) which
diff --git a/providers/dns/gandi/gandi_mock_test.go b/providers/dns/gandi/gandi_mock_test.go
index 970588ef..34783fe8 100644
--- a/providers/dns/gandi/gandi_mock_test.go
+++ b/providers/dns/gandi/gandi_mock_test.go
@@ -1,7 +1,7 @@
 package gandi
 
 // CleanUp Request->Response 1 (setZone).
-const cleanupSetZoneRequestMock = `<?xml version="1.0"?>
+const cleanupSetZoneRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.zone.set</methodName>
   <param>
@@ -22,7 +22,7 @@ const cleanupSetZoneRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // CleanUp Request->Response 1 (setZone).
-const cleanupSetZoneResponseMock = `<?xml version='1.0'?>
+const cleanupSetZoneResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
@@ -192,7 +192,7 @@ const cleanupSetZoneResponseMock = `<?xml version='1.0'?>
 `
 
 // CleanUp Request->Response 2 (deleteZone).
-const cleanupDeleteZoneRequestMock = `<?xml version="1.0"?>
+const cleanupDeleteZoneRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.zone.delete</methodName>
   <param>
@@ -208,7 +208,7 @@ const cleanupDeleteZoneRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // CleanUp Request->Response 2 (deleteZone).
-const cleanupDeleteZoneResponseMock = `<?xml version='1.0'?>
+const cleanupDeleteZoneResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
@@ -219,7 +219,7 @@ const cleanupDeleteZoneResponseMock = `<?xml version='1.0'?>
 `
 
 // Present Request->Response 1 (getZoneID).
-const presentGetZoneIDRequestMock = `<?xml version="1.0"?>
+const presentGetZoneIDRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.info</methodName>
   <param>
@@ -235,7 +235,7 @@ const presentGetZoneIDRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // Present Request->Response 1 (getZoneID).
-const presentGetZoneIDResponseMock = `<?xml version='1.0'?>
+const presentGetZoneIDResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
@@ -405,7 +405,7 @@ const presentGetZoneIDResponseMock = `<?xml version='1.0'?>
 `
 
 // Present Request->Response 2 (cloneZone).
-const presentCloneZoneRequestMock = `<?xml version="1.0"?>
+const presentCloneZoneRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.zone.clone</methodName>
   <param>
@@ -438,7 +438,7 @@ const presentCloneZoneRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // Present Request->Response 2 (cloneZone).
-const presentCloneZoneResponseMock = `<?xml version='1.0'?>
+const presentCloneZoneResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
@@ -484,7 +484,7 @@ const presentCloneZoneResponseMock = `<?xml version='1.0'?>
 `
 
 // Present Request->Response 3 (newZoneVersion).
-const presentNewZoneVersionRequestMock = `<?xml version="1.0"?>
+const presentNewZoneVersionRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.zone.version.new</methodName>
   <param>
@@ -500,7 +500,7 @@ const presentNewZoneVersionRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // Present Request->Response 3 (newZoneVersion).
-const presentNewZoneVersionResponseMock = `<?xml version='1.0'?>
+const presentNewZoneVersionResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
@@ -511,7 +511,7 @@ const presentNewZoneVersionResponseMock = `<?xml version='1.0'?>
 `
 
 // Present Request->Response 4 (addTXTRecord).
-const presentAddTXTRecordRequestMock = `<?xml version="1.0"?>
+const presentAddTXTRecordRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.zone.record.add</methodName>
   <param>
@@ -562,7 +562,7 @@ const presentAddTXTRecordRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // Present Request->Response 4 (addTXTRecord).
-const presentAddTXTRecordResponseMock = `<?xml version='1.0'?>
+const presentAddTXTRecordResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
@@ -594,7 +594,7 @@ const presentAddTXTRecordResponseMock = `<?xml version='1.0'?>
 `
 
 // Present Request->Response 5 (setZoneVersion).
-const presentSetZoneVersionRequestMock = `<?xml version="1.0"?>
+const presentSetZoneVersionRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.zone.version.set</methodName>
   <param>
@@ -615,7 +615,7 @@ const presentSetZoneVersionRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // Present Request->Response 5 (setZoneVersion).
-const presentSetZoneVersionResponseMock = `<?xml version='1.0'?>
+const presentSetZoneVersionResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
@@ -626,7 +626,7 @@ const presentSetZoneVersionResponseMock = `<?xml version='1.0'?>
 `
 
 // Present Request->Response 6 (setZone).
-const presentSetZoneRequestMock = `<?xml version="1.0"?>
+const presentSetZoneRequestMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>domain.zone.set</methodName>
   <param>
@@ -647,7 +647,7 @@ const presentSetZoneRequestMock = `<?xml version="1.0"?>
 </methodCall>`
 
 // Present Request->Response 6 (setZone).
-const presentSetZoneResponseMock = `<?xml version='1.0'?>
+const presentSetZoneResponseMock = `<?xml version="1.0" encoding="UTF-8"?>
 <methodResponse>
 <params>
 <param>
diff --git a/providers/dns/gandi/gandi_test.go b/providers/dns/gandi/gandi_test.go
index f53ef3c2..36bc4ccd 100644
--- a/providers/dns/gandi/gandi_test.go
+++ b/providers/dns/gandi/gandi_test.go
@@ -132,7 +132,7 @@ func TestDNSProvider(t *testing.T) {
 
 		req = regexpDate.ReplaceAllLiteral(req, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`))
 		resp, ok := serverResponses[string(req)]
-		require.True(t, ok, "Server response for request not found")
+		require.Truef(t, ok, "Server response for request not found: %s", string(req))
 
 		_, errS = io.Copy(w, strings.NewReader(resp))
 		require.NoError(t, errS)
diff --git a/providers/dns/gandi/internal/client.go b/providers/dns/gandi/internal/client.go
new file mode 100644
index 00000000..6dc09648
--- /dev/null
+++ b/providers/dns/gandi/internal/client.go
@@ -0,0 +1,289 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// defaultBaseURL Gandi XML-RPC endpoint used by Present and CleanUp.
+const defaultBaseURL = "https://rpc.gandi.net/xmlrpc/"
+
+// Client the Gandi API client.
+type Client struct {
+	apiKey string
+
+	BaseURL    string
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(apiKey string) *Client {
+	return &Client{
+		apiKey:     apiKey,
+		BaseURL:    defaultBaseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c *Client) GetZoneID(ctx context.Context, domain string) (int, error) {
+	call := &methodCall{
+		MethodName: "domain.info",
+		Params: []param{
+			paramString{Value: c.apiKey},
+			paramString{Value: domain},
+		},
+	}
+
+	resp := &responseStruct{}
+
+	err := c.rpcCall(ctx, call, resp)
+	if err != nil {
+		return 0, err
+	}
+
+	var zoneID int
+	for _, member := range resp.StructMembers {
+		if member.Name == "zone_id" {
+			zoneID = member.ValueInt
+		}
+	}
+
+	if zoneID == 0 {
+		return 0, fmt.Errorf("could not find zone_id for %s", domain)
+	}
+	return zoneID, nil
+}
+
+func (c *Client) CloneZone(ctx context.Context, zoneID int, name string) (int, error) {
+	call := &methodCall{
+		MethodName: "domain.zone.clone",
+		Params: []param{
+			paramString{Value: c.apiKey},
+			paramInt{Value: zoneID},
+			paramInt{Value: 0},
+			paramStruct{
+				StructMembers: []structMember{
+					structMemberString{
+						Name:  "name",
+						Value: name,
+					},
+				},
+			},
+		},
+	}
+
+	resp := &responseStruct{}
+
+	err := c.rpcCall(ctx, call, resp)
+	if err != nil {
+		return 0, err
+	}
+
+	var newZoneID int
+	for _, member := range resp.StructMembers {
+		if member.Name == "id" {
+			newZoneID = member.ValueInt
+		}
+	}
+
+	if newZoneID == 0 {
+		return 0, errors.New("could not determine cloned zone_id")
+	}
+	return newZoneID, nil
+}
+
+func (c *Client) NewZoneVersion(ctx context.Context, zoneID int) (int, error) {
+	call := &methodCall{
+		MethodName: "domain.zone.version.new",
+		Params: []param{
+			paramString{Value: c.apiKey},
+			paramInt{Value: zoneID},
+		},
+	}
+
+	resp := &responseInt{}
+
+	err := c.rpcCall(ctx, call, resp)
+	if err != nil {
+		return 0, err
+	}
+
+	if resp.Value == 0 {
+		return 0, errors.New("could not create new zone version")
+	}
+	return resp.Value, nil
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, zoneID, version int, name, value string, ttl int) error {
+	call := &methodCall{
+		MethodName: "domain.zone.record.add",
+		Params: []param{
+			paramString{Value: c.apiKey},
+			paramInt{Value: zoneID},
+			paramInt{Value: version},
+			paramStruct{
+				StructMembers: []structMember{
+					structMemberString{
+						Name:  "type",
+						Value: "TXT",
+					}, structMemberString{
+						Name:  "name",
+						Value: name,
+					}, structMemberString{
+						Name:  "value",
+						Value: value,
+					}, structMemberInt{
+						Name:  "ttl",
+						Value: ttl,
+					},
+				},
+			},
+		},
+	}
+
+	resp := &responseStruct{}
+
+	return c.rpcCall(ctx, call, resp)
+}
+
+func (c *Client) SetZoneVersion(ctx context.Context, zoneID, version int) error {
+	call := &methodCall{
+		MethodName: "domain.zone.version.set",
+		Params: []param{
+			paramString{Value: c.apiKey},
+			paramInt{Value: zoneID},
+			paramInt{Value: version},
+		},
+	}
+
+	resp := &responseBool{}
+
+	err := c.rpcCall(ctx, call, resp)
+	if err != nil {
+		return err
+	}
+
+	if !resp.Value {
+		return errors.New("could not set zone version")
+	}
+	return nil
+}
+
+func (c *Client) SetZone(ctx context.Context, domain string, zoneID int) error {
+	call := &methodCall{
+		MethodName: "domain.zone.set",
+		Params: []param{
+			paramString{Value: c.apiKey},
+			paramString{Value: domain},
+			paramInt{Value: zoneID},
+		},
+	}
+
+	resp := &responseStruct{}
+
+	err := c.rpcCall(ctx, call, resp)
+	if err != nil {
+		return err
+	}
+
+	var respZoneID int
+	for _, member := range resp.StructMembers {
+		if member.Name == "zone_id" {
+			respZoneID = member.ValueInt
+		}
+	}
+
+	if respZoneID != zoneID {
+		return fmt.Errorf("could not set new zone_id for %s", domain)
+	}
+	return nil
+}
+
+func (c *Client) DeleteZone(ctx context.Context, zoneID int) error {
+	call := &methodCall{
+		MethodName: "domain.zone.delete",
+		Params: []param{
+			paramString{Value: c.apiKey},
+			paramInt{Value: zoneID},
+		},
+	}
+
+	resp := &responseBool{}
+
+	err := c.rpcCall(ctx, call, resp)
+	if err != nil {
+		return err
+	}
+
+	if !resp.Value {
+		return errors.New("could not delete zone_id")
+	}
+
+	return nil
+}
+
+// rpcCall makes an XML-RPC call to Gandi's RPC endpoint by marshaling the data given in the call argument to XML
+// and sending  that via HTTP Post to Gandi.
+// The response is then unmarshalled into the resp argument.
+func (c *Client) rpcCall(ctx context.Context, call *methodCall, result response) error {
+	req, err := newXMLRequest(ctx, c.BaseURL, call)
+	if err != nil {
+		return err
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = xml.Unmarshal(raw, result)
+	if err != nil {
+		return fmt.Errorf("unmarshal error: %w", err)
+	}
+
+	if result.faultCode() != 0 {
+		return RPCError{
+			FaultCode:   result.faultCode(),
+			FaultString: result.faultString(),
+		}
+	}
+
+	return nil
+}
+
+func newXMLRequest(ctx context.Context, endpoint string, payload *methodCall) (*http.Request, error) {
+	body := new(bytes.Buffer)
+	body.WriteString(xml.Header)
+
+	encoder := xml.NewEncoder(body)
+	encoder.Indent("", "  ")
+
+	err := encoder.Encode(payload)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "text/xml")
+
+	return req, nil
+}
diff --git a/providers/dns/gandi/internal/types.go b/providers/dns/gandi/internal/types.go
new file mode 100644
index 00000000..cdcd0a65
--- /dev/null
+++ b/providers/dns/gandi/internal/types.go
@@ -0,0 +1,95 @@
+package internal
+
+import (
+	"encoding/xml"
+	"fmt"
+)
+
+// types for XML-RPC method calls and parameters
+
+type param interface {
+	param()
+}
+
+type paramString struct {
+	XMLName xml.Name `xml:"param"`
+	Value   string   `xml:"value>string"`
+}
+
+type paramInt struct {
+	XMLName xml.Name `xml:"param"`
+	Value   int      `xml:"value>int"`
+}
+
+type structMember interface {
+	structMember()
+}
+
+type structMemberString struct {
+	Name  string `xml:"name"`
+	Value string `xml:"value>string"`
+}
+
+type structMemberInt struct {
+	Name  string `xml:"name"`
+	Value int    `xml:"value>int"`
+}
+
+type paramStruct struct {
+	XMLName       xml.Name       `xml:"param"`
+	StructMembers []structMember `xml:"value>struct>member"`
+}
+
+func (p paramString) param()               {}
+func (p paramInt) param()                  {}
+func (m structMemberString) structMember() {}
+func (m structMemberInt) structMember()    {}
+func (p paramStruct) param()               {}
+
+type methodCall struct {
+	XMLName    xml.Name `xml:"methodCall"`
+	MethodName string   `xml:"methodName"`
+	Params     []param  `xml:"params"`
+}
+
+// types for XML-RPC responses
+
+type response interface {
+	faultCode() int
+	faultString() string
+}
+
+type responseFault struct {
+	FaultCode   int    `xml:"fault>value>struct>member>value>int"`
+	FaultString string `xml:"fault>value>struct>member>value>string"`
+}
+
+func (r responseFault) faultCode() int      { return r.FaultCode }
+func (r responseFault) faultString() string { return r.FaultString }
+
+type responseStruct struct {
+	responseFault
+	StructMembers []struct {
+		Name     string `xml:"name"`
+		ValueInt int    `xml:"value>int"`
+	} `xml:"params>param>value>struct>member"`
+}
+
+type responseInt struct {
+	responseFault
+	Value int `xml:"params>param>value>int"`
+}
+
+type responseBool struct {
+	responseFault
+	Value bool `xml:"params>param>value>boolean"`
+}
+
+type RPCError struct {
+	FaultCode   int
+	FaultString string
+}
+
+func (e RPCError) Error() string {
+	return fmt.Sprintf("Gandi DNS: RPC Error: (%d) %s", e.FaultCode, e.FaultString)
+}
diff --git a/providers/dns/gandiv5/client.go b/providers/dns/gandiv5/client.go
deleted file mode 100644
index 4ec3e1b5..00000000
--- a/providers/dns/gandiv5/client.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package gandiv5
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-
-	"github.com/go-acme/lego/v4/log"
-)
-
-const apiKeyHeader = "X-Api-Key"
-
-// types for JSON responses with only a message.
-type apiResponse struct {
-	Message string `json:"message"`
-	UUID    string `json:"uuid,omitempty"`
-}
-
-// Record TXT record representation.
-type Record struct {
-	RRSetTTL    int      `json:"rrset_ttl"`
-	RRSetValues []string `json:"rrset_values"`
-	RRSetName   string   `json:"rrset_name,omitempty"`
-	RRSetType   string   `json:"rrset_type,omitempty"`
-}
-
-func (d *DNSProvider) addTXTRecord(domain, name, value string, ttl int) error {
-	// Get exiting values for the TXT records
-	// Needed to create challenges for both wildcard and base name domains
-	txtRecord, err := d.getTXTRecord(domain, name)
-	if err != nil {
-		return err
-	}
-
-	values := []string{value}
-	if len(txtRecord.RRSetValues) > 0 {
-		values = append(values, txtRecord.RRSetValues...)
-	}
-
-	target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name)
-
-	newRecord := &Record{RRSetTTL: ttl, RRSetValues: values}
-	req, err := d.newRequest(http.MethodPut, target, newRecord)
-	if err != nil {
-		return err
-	}
-
-	message := apiResponse{}
-	err = d.do(req, &message)
-	if err != nil {
-		return fmt.Errorf("unable to create TXT record for domain %s and name %s: %w", domain, name, err)
-	}
-
-	if len(message.Message) > 0 {
-		log.Infof("API response: %s", message.Message)
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) getTXTRecord(domain, name string) (*Record, error) {
-	target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name)
-
-	// Get exiting values for the TXT records
-	// Needed to create challenges for both wildcard and base name domains
-	req, err := d.newRequest(http.MethodGet, target, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	txtRecord := &Record{}
-	err = d.do(req, txtRecord)
-	if err != nil {
-		return nil, fmt.Errorf("unable to get TXT records for domain %s and name %s: %w", domain, name, err)
-	}
-
-	return txtRecord, nil
-}
-
-func (d *DNSProvider) deleteTXTRecord(domain, name string) error {
-	target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name)
-
-	req, err := d.newRequest(http.MethodDelete, target, nil)
-	if err != nil {
-		return err
-	}
-
-	message := apiResponse{}
-	err = d.do(req, &message)
-	if err != nil {
-		return fmt.Errorf("unable to delete TXT record for domain %s and name %s: %w", domain, name, err)
-	}
-
-	if len(message.Message) > 0 {
-		log.Infof("API response: %s", message.Message)
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) newRequest(method, resource string, body interface{}) (*http.Request, error) {
-	u := fmt.Sprintf("%s/%s", d.config.BaseURL, resource)
-
-	if body == nil {
-		req, err := http.NewRequest(method, u, nil)
-		if err != nil {
-			return nil, err
-		}
-
-		return req, nil
-	}
-
-	reqBody, err := json.Marshal(body)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(method, u, bytes.NewBuffer(reqBody))
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-
-	return req, nil
-}
-
-func (d *DNSProvider) do(req *http.Request, v interface{}) error {
-	if len(d.config.APIKey) > 0 {
-		req.Header.Set(apiKeyHeader, d.config.APIKey)
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-
-	err = checkResponse(resp)
-	if err != nil {
-		return err
-	}
-
-	if v == nil {
-		return nil
-	}
-
-	raw, err := readBody(resp)
-	if err != nil {
-		return fmt.Errorf("failed to read body: %w", err)
-	}
-
-	if len(raw) > 0 {
-		err = json.Unmarshal(raw, v)
-		if err != nil {
-			return fmt.Errorf("unmarshaling error: %w: %s", err, string(raw))
-		}
-	}
-
-	return nil
-}
-
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode == http.StatusNotFound && resp.Request.Method == http.MethodGet {
-		return nil
-	}
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		data, err := readBody(resp)
-		if err != nil {
-			return fmt.Errorf("%d [%s] request failed: %w", resp.StatusCode, http.StatusText(resp.StatusCode), err)
-		}
-
-		message := &apiResponse{}
-		err = json.Unmarshal(data, message)
-		if err != nil {
-			return fmt.Errorf("%d [%s] request failed: %w: %s", resp.StatusCode, http.StatusText(resp.StatusCode), err, data)
-		}
-		return fmt.Errorf("%d [%s] request failed: %s", resp.StatusCode, http.StatusText(resp.StatusCode), message.Message)
-	}
-
-	return nil
-}
-
-func readBody(resp *http.Response) ([]byte, error) {
-	if resp.Body == nil {
-		return nil, errors.New("response body is nil")
-	}
-
-	defer resp.Body.Close()
-
-	rawBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	return rawBody, nil
-}
diff --git a/providers/dns/gandiv5/gandiv5.go b/providers/dns/gandiv5/gandiv5.go
index 7a877ee2..44859397 100644
--- a/providers/dns/gandiv5/gandiv5.go
+++ b/providers/dns/gandiv5/gandiv5.go
@@ -2,23 +2,22 @@
 package gandiv5
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"sync"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/gandiv5/internal"
 )
 
 // Gandi API reference:       http://doc.livedns.gandi.net/
 
-const (
-	// defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp.
-	defaultBaseURL = "https://dns.api.gandi.net/api/v5"
-	minTTL         = 300
-)
+const minTTL = 300
 
 // Environment variables names.
 const (
@@ -62,10 +61,15 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config          *Config
+	config *Config
+	client *internal.Client
+
 	inProgressFQDNs map[string]inProgressInfo
 	inProgressMu    sync.Mutex
-	// findZoneByFqdn determines the DNS zone of an fqdn. It is overridden during tests.
+
+	// findZoneByFqdn determines the DNS zone of a FQDN.
+	// It is overridden during tests.
+	// only for testing purpose.
 	findZoneByFqdn func(fqdn string) (string, error)
 }
 
@@ -93,16 +97,27 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("gandiv5: no API Key given")
 	}
 
-	if config.BaseURL == "" {
-		config.BaseURL = defaultBaseURL
-	}
-
 	if config.TTL < minTTL {
 		return nil, fmt.Errorf("gandiv5: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
 	}
 
+	client := internal.NewClient(config.APIKey)
+
+	if config.BaseURL != "" {
+		baseURL, err := url.Parse(config.BaseURL)
+		if err != nil {
+			return nil, fmt.Errorf("gandiv5: %w", err)
+		}
+		client.BaseURL = baseURL
+	}
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
 	return &DNSProvider{
 		config:          config,
+		client:          client,
 		inProgressFQDNs: make(map[string]inProgressInfo),
 		findZoneByFqdn:  dns01.FindZoneByFqdn,
 	}, nil
@@ -115,7 +130,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	// find authZone
 	authZone, err := d.findZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("gandiv5: findZoneByFqdn failure: %w", err)
+		return fmt.Errorf("gandiv5: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// determine name of TXT record
@@ -130,7 +145,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	defer d.inProgressMu.Unlock()
 
 	// add TXT record into authZone
-	err = d.addTXTRecord(dns01.UnFqdn(authZone), subDomain, info.Value, d.config.TTL)
+	err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value, d.config.TTL)
 	if err != nil {
 		return err
 	}
@@ -160,7 +175,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	delete(d.inProgressFQDNs, info.EffectiveFQDN)
 
 	// delete TXT record from authZone
-	err := d.deleteTXTRecord(dns01.UnFqdn(authZone), fieldName)
+	err := d.client.DeleteTXTRecord(context.Background(), dns01.UnFqdn(authZone), fieldName)
 	if err != nil {
 		return fmt.Errorf("gandiv5: %w", err)
 	}
diff --git a/providers/dns/gandiv5/gandiv5_test.go b/providers/dns/gandiv5/gandiv5_test.go
index 31156e23..52e3b961 100644
--- a/providers/dns/gandiv5/gandiv5_test.go
+++ b/providers/dns/gandiv5/gandiv5_test.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/go-acme/lego/v4/log"
 	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/go-acme/lego/v4/providers/dns/gandiv5/internal"
 	"github.com/stretchr/testify/require"
 )
 
@@ -115,10 +116,13 @@ func TestDNSProvider(t *testing.T) {
 
 	// start fake RPC server
 	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
 	mux.HandleFunc("/domains/example.com/records/_acme-challenge.abc.def/TXT", func(rw http.ResponseWriter, req *http.Request) {
 		log.Infof("request: %s %s", req.Method, req.URL)
 
-		if req.Header.Get(apiKeyHeader) == "" {
+		if req.Header.Get(internal.APIKeyHeader) == "" {
 			http.Error(rw, `{"message": "missing API key"}`, http.StatusUnauthorized)
 			return
 		}
@@ -155,9 +159,6 @@ func TestDNSProvider(t *testing.T) {
 		http.Error(rw, fmt.Sprintf(`{"message": "URL doesn't match: %s"}`, req.URL), http.StatusNotFound)
 	})
 
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	// define function to override findZoneByFqdn with
 	fakeFindZoneByFqdn := func(fqdn string) (string, error) {
 		return "example.com.", nil
diff --git a/providers/dns/gandiv5/internal/client.go b/providers/dns/gandiv5/internal/client.go
new file mode 100644
index 00000000..bb280a3c
--- /dev/null
+++ b/providers/dns/gandiv5/internal/client.go
@@ -0,0 +1,208 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/log"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp.
+const defaultBaseURL = "https://dns.api.gandi.net/api/v5"
+
+// APIKeyHeader API key header.
+const APIKeyHeader = "X-Api-Key"
+
+// Client the Gandi API v5 client.
+type Client struct {
+	apiKey string
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(apiKey string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		apiKey:     apiKey,
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c *Client) AddTXTRecord(ctx context.Context, domain, name, value string, ttl int) error {
+	// Get exiting values for the TXT records
+	// Needed to create challenges for both wildcard and base name domains
+	txtRecord, err := c.getTXTRecord(ctx, domain, name)
+	if err != nil {
+		return err
+	}
+
+	values := []string{value}
+	if len(txtRecord.RRSetValues) > 0 {
+		values = append(values, txtRecord.RRSetValues...)
+	}
+
+	newRecord := &Record{RRSetTTL: ttl, RRSetValues: values}
+
+	err = c.addTXTRecord(ctx, domain, name, newRecord)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (c *Client) getTXTRecord(ctx context.Context, domain, name string) (*Record, error) {
+	endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT")
+
+	// Get exiting values for the TXT records
+	// Needed to create challenges for both wildcard and base name domains
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	txtRecord := &Record{}
+	err = c.do(req, txtRecord)
+	if err != nil {
+		return nil, fmt.Errorf("unable to get TXT records for domain %s and name %s: %w", domain, name, err)
+	}
+
+	return txtRecord, nil
+}
+
+func (c *Client) addTXTRecord(ctx context.Context, domain, name string, newRecord *Record) error {
+	endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT")
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, newRecord)
+	if err != nil {
+		return err
+	}
+
+	message := apiResponse{}
+	err = c.do(req, &message)
+	if err != nil {
+		return fmt.Errorf("unable to create TXT record for domain %s and name %s: %w", domain, name, err)
+	}
+
+	if message.Message != "" {
+		log.Infof("API response: %s", message.Message)
+	}
+
+	return nil
+}
+
+func (c *Client) DeleteTXTRecord(ctx context.Context, domain, name string) error {
+	endpoint := c.BaseURL.JoinPath("domains", domain, "records", name, "TXT")
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	message := apiResponse{}
+	err = c.do(req, &message)
+	if err != nil {
+		return fmt.Errorf("unable to delete TXT record for domain %s and name %s: %w", domain, name, err)
+	}
+
+	if message.Message != "" {
+		log.Infof("API response: %s", message.Message)
+	}
+
+	return nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	if c.apiKey != "" {
+		req.Header.Set(APIKeyHeader, c.apiKey)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	err = checkResponse(req, resp)
+	if err != nil {
+		return err
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	if len(raw) > 0 {
+		err = json.Unmarshal(raw, result)
+		if err != nil {
+			return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+		}
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func checkResponse(req *http.Request, resp *http.Response) error {
+	if resp.StatusCode == http.StatusNotFound && resp.Request.Method == http.MethodGet {
+		return nil
+	}
+
+	if resp.StatusCode < http.StatusBadRequest {
+		return nil
+	}
+
+	return parseError(req, resp)
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	response := apiResponse{}
+	err := json.Unmarshal(raw, &response)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("%d: request failed: %s", resp.StatusCode, response.Message)
+}
diff --git a/providers/dns/gandiv5/internal/types.go b/providers/dns/gandiv5/internal/types.go
new file mode 100644
index 00000000..2c0ba534
--- /dev/null
+++ b/providers/dns/gandiv5/internal/types.go
@@ -0,0 +1,15 @@
+package internal
+
+// types for JSON responses with only a message.
+type apiResponse struct {
+	Message string `json:"message"`
+	UUID    string `json:"uuid,omitempty"`
+}
+
+// Record TXT record representation.
+type Record struct {
+	RRSetTTL    int      `json:"rrset_ttl"`
+	RRSetValues []string `json:"rrset_values"`
+	RRSetName   string   `json:"rrset_name,omitempty"`
+	RRSetType   string   `json:"rrset_type,omitempty"`
+}
diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go
index 54169678..34a7d1e0 100644
--- a/providers/dns/gcloud/googlecloud.go
+++ b/providers/dns/gcloud/googlecloud.go
@@ -75,7 +75,7 @@ type DNSProvider struct {
 // or by specifying the keyfile location: GCE_SERVICE_ACCOUNT_FILE.
 func NewDNSProvider() (*DNSProvider, error) {
 	// Use a service account file if specified via environment variable.
-	if saKey := env.GetOrFile(EnvServiceAccount); len(saKey) > 0 {
+	if saKey := env.GetOrFile(EnvServiceAccount); saKey != "" {
 		return NewDNSProviderServiceAccountKey([]byte(saKey))
 	}
 
@@ -312,7 +312,7 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) getHostedZone(domain string) (string, error) {
 	authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("designate: could not find zone for FQDN %q: %w", domain, err)
 	}
 
 	zones, err := d.client.ManagedZones.
diff --git a/providers/dns/gcloud/googlecloud_test.go b/providers/dns/gcloud/googlecloud_test.go
index 87ba9dbd..02071b1c 100644
--- a/providers/dns/gcloud/googlecloud_test.go
+++ b/providers/dns/gcloud/googlecloud_test.go
@@ -144,6 +144,8 @@ func TestNewDNSProviderConfig(t *testing.T) {
 
 func TestPresentNoExistingRR(t *testing.T) {
 	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
 
 	// getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf.
 	mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) {
@@ -205,11 +207,8 @@ func TestPresentNoExistingRR(t *testing.T) {
 		}
 	})
 
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	config := NewDefaultConfig()
-	config.HTTPClient = &http.Client{}
+	config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
 	config.Project = "manhattan"
 
 	p, err := NewDNSProviderConfig(config)
@@ -225,6 +224,8 @@ func TestPresentNoExistingRR(t *testing.T) {
 
 func TestPresentWithExistingRR(t *testing.T) {
 	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
 
 	// getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf.
 	mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) {
@@ -306,11 +307,8 @@ func TestPresentWithExistingRR(t *testing.T) {
 		}
 	})
 
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	config := NewDefaultConfig()
-	config.HTTPClient = &http.Client{}
+	config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
 	config.Project = "manhattan"
 
 	p, err := NewDNSProviderConfig(config)
@@ -326,6 +324,8 @@ func TestPresentWithExistingRR(t *testing.T) {
 
 func TestPresentSkipExistingRR(t *testing.T) {
 	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
 
 	// getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf.
 	mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) {
@@ -370,11 +370,8 @@ func TestPresentSkipExistingRR(t *testing.T) {
 		}
 	})
 
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	config := NewDefaultConfig()
-	config.HTTPClient = &http.Client{}
+	config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
 	config.Project = "manhattan"
 
 	p, err := NewDNSProviderConfig(config)
diff --git a/providers/dns/gcore/internal/client.go b/providers/dns/gcore/internal/client.go
index 18160681..65841487 100644
--- a/providers/dns/gcore/internal/client.go
+++ b/providers/dns/gcore/internal/client.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"errors"
@@ -8,21 +9,26 @@ import (
 	"io"
 	"net/http"
 	"net/url"
-	"strings"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
+const defaultBaseURL = "https://api.gcorelabs.com/dns"
+
 const (
-	defaultBaseURL = "https://api.gcorelabs.com/dns"
-	tokenHeader    = "APIKey"
-	txtRecordType  = "TXT"
+	authorizationHeader = "Authorization"
+	tokenTypeHeader     = "APIKey"
 )
 
+const txtRecordType = "TXT"
+
 // Client for DNS API.
 type Client struct {
-	HTTPClient *http.Client
+	token string
+
 	baseURL    *url.URL
-	token      string
+	HTTPClient *http.Client
 }
 
 // NewClient constructor of Client.
@@ -42,7 +48,7 @@ func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) {
 	endpoint := c.baseURL.JoinPath("v2", "zones", name)
 
 	zone := Zone{}
-	err := c.do(ctx, http.MethodGet, endpoint, nil, &zone)
+	err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &zone)
 	if err != nil {
 		return Zone{}, fmt.Errorf("get zone %s: %w", name, err)
 	}
@@ -56,7 +62,7 @@ func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error)
 	endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
 
 	var result RRSet
-	err := c.do(ctx, http.MethodGet, endpoint, nil, &result)
+	err := c.doRequest(ctx, http.MethodGet, endpoint, nil, &result)
 	if err != nil {
 		return RRSet{}, fmt.Errorf("get txt records %s -> %s: %w", zone, name, err)
 	}
@@ -69,7 +75,7 @@ func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error)
 func (c *Client) DeleteRRSet(ctx context.Context, zone, name string) error {
 	endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
 
-	err := c.do(ctx, http.MethodDelete, endpoint, nil, nil)
+	err := c.doRequest(ctx, http.MethodDelete, endpoint, nil, nil)
 	if err != nil {
 		// Support DELETE idempotence https://developer.mozilla.org/en-US/docs/Glossary/Idempotent
 		statusErr := new(APIError)
@@ -100,59 +106,84 @@ func (c *Client) AddRRSet(ctx context.Context, zone, recordName, value string, t
 func (c *Client) createRRSet(ctx context.Context, zone, name string, record RRSet) error {
 	endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
 
-	return c.do(ctx, http.MethodPost, endpoint, record, nil)
+	return c.doRequest(ctx, http.MethodPost, endpoint, record, nil)
 }
 
 // https://dnsapi.gcorelabs.com/docs#operation/UpdateRRSet
 func (c *Client) updateRRSet(ctx context.Context, zone, name string, record RRSet) error {
 	endpoint := c.baseURL.JoinPath("v2", "zones", zone, name, txtRecordType)
 
-	return c.do(ctx, http.MethodPut, endpoint, record, nil)
+	return c.doRequest(ctx, http.MethodPut, endpoint, record, nil)
 }
 
-func (c *Client) do(ctx context.Context, method string, endpoint *url.URL, bodyParams interface{}, dest interface{}) error {
-	var bs []byte
-	if bodyParams != nil {
-		var err error
-		bs, err = json.Marshal(bodyParams)
-		if err != nil {
-			return fmt.Errorf("encode bodyParams: %w", err)
-		}
-	}
-
-	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), strings.NewReader(string(bs)))
+func (c *Client) doRequest(ctx context.Context, method string, endpoint *url.URL, bodyParams any, result any) error {
+	req, err := newJSONRequest(ctx, method, endpoint, bodyParams)
 	if err != nil {
 		return fmt.Errorf("new request: %w", err)
 	}
 
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenHeader, c.token))
+	req.Header.Set(authorizationHeader, fmt.Sprintf("%s %s", tokenTypeHeader, c.token))
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return fmt.Errorf("send request: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode/100 != 2 {
-		all, _ := io.ReadAll(resp.Body)
-
-		e := APIError{
-			StatusCode: resp.StatusCode,
-		}
-
-		err := json.Unmarshal(all, &e)
-		if err != nil {
-			e.Message = string(all)
-		}
-
-		return e
+		return parseError(resp)
 	}
 
-	if dest == nil {
+	if result == nil {
 		return nil
 	}
 
-	return json.NewDecoder(resp.Body).Decode(dest)
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errAPI := APIError{StatusCode: resp.StatusCode}
+	err := json.Unmarshal(raw, &errAPI)
+	if err != nil {
+		errAPI.Message = string(raw)
+	}
+
+	return errAPI
 }
diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/gcore/internal/client_test.go
index 86872260..f414b33e 100644
--- a/providers/dns/gcore/internal/client_test.go
+++ b/providers/dns/gcore/internal/client_test.go
@@ -21,22 +21,21 @@ const (
 	testTTL            = 10
 )
 
-func setupTest(t *testing.T) (*http.ServeMux, *Client) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
-
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
 	client := NewClient(testToken)
 	client.baseURL, _ = url.Parse(server.URL)
 
-	return mux, client
+	return client, mux
 }
 
 func TestClient_GetZone(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	expected := Zone{Name: "example.com"}
 
@@ -52,7 +51,7 @@ func TestClient_GetZone(t *testing.T) {
 }
 
 func TestClient_GetZone_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.Handle("/v2/zones/example.com", validationHandler{
 		method: http.MethodGet,
@@ -64,7 +63,7 @@ func TestClient_GetZone_error(t *testing.T) {
 }
 
 func TestClient_GetRRSet(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	expected := RRSet{
 		TTL: testTTL,
@@ -85,7 +84,7 @@ func TestClient_GetRRSet(t *testing.T) {
 }
 
 func TestClient_GetRRSet_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{
 		method: http.MethodGet,
@@ -97,7 +96,7 @@ func TestClient_GetRRSet_error(t *testing.T) {
 }
 
 func TestClient_DeleteRRSet(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType,
 		validationHandler{method: http.MethodDelete})
@@ -107,7 +106,7 @@ func TestClient_DeleteRRSet(t *testing.T) {
 }
 
 func TestClient_DeleteRRSet_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, validationHandler{
 		method: http.MethodDelete,
@@ -178,7 +177,7 @@ func TestClient_AddRRSet(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			mux, cl := setupTest(t)
+			cl, mux := setupTest(t)
 
 			for pattern, handler := range test.handlers {
 				mux.Handle(pattern, handler)
@@ -201,7 +200,7 @@ type validationHandler struct {
 }
 
 func (v validationHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
-	if req.Header.Get("Authorization") != fmt.Sprintf("%s %s", tokenHeader, testToken) {
+	if req.Header.Get(authorizationHeader) != fmt.Sprintf("%s %s", tokenTypeHeader, testToken) {
 		rw.WriteHeader(http.StatusForbidden)
 		_ = json.NewEncoder(rw).Encode(APIError{Message: "token up for parsing was not passed through the context"})
 		return
diff --git a/providers/dns/glesys/client.go b/providers/dns/glesys/client.go
deleted file mode 100644
index 32207280..00000000
--- a/providers/dns/glesys/client.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package glesys
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"net/http"
-
-	"github.com/go-acme/lego/v4/log"
-)
-
-// types for JSON method calls, parameters, and responses
-
-type addRecordRequest struct {
-	DomainName string `json:"domainname"`
-	Host       string `json:"host"`
-	Type       string `json:"type"`
-	Data       string `json:"data"`
-	TTL        int    `json:"ttl,omitempty"`
-}
-
-type deleteRecordRequest struct {
-	RecordID int `json:"recordid"`
-}
-
-type responseStruct struct {
-	Response struct {
-		Status struct {
-			Code int `json:"code"`
-		} `json:"status"`
-		Record deleteRecordRequest `json:"record"`
-	} `json:"response"`
-}
-
-func (d *DNSProvider) addTXTRecord(fqdn, domain, name, value string, ttl int) (int, error) {
-	response, err := d.sendRequest(http.MethodPost, "addrecord", addRecordRequest{
-		DomainName: domain,
-		Host:       name,
-		Type:       "TXT",
-		Data:       value,
-		TTL:        ttl,
-	})
-
-	if response != nil && response.Response.Status.Code == http.StatusOK {
-		log.Infof("[%s]: Successfully created record id %d", fqdn, response.Response.Record.RecordID)
-		return response.Response.Record.RecordID, nil
-	}
-	return 0, err
-}
-
-func (d *DNSProvider) deleteTXTRecord(fqdn string, recordid int) error {
-	response, err := d.sendRequest(http.MethodPost, "deleterecord", deleteRecordRequest{
-		RecordID: recordid,
-	})
-	if response != nil && response.Response.Status.Code == 200 {
-		log.Infof("[%s]: Successfully deleted record id %d", fqdn, recordid)
-	}
-	return err
-}
-
-func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*responseStruct, error) {
-	url := fmt.Sprintf("%s/%s", defaultBaseURL, resource)
-
-	body, err := json.Marshal(payload)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(method, url, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.SetBasicAuth(d.config.APIUser, d.config.APIKey)
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return nil, fmt.Errorf("request failed with HTTP status code %d", resp.StatusCode)
-	}
-
-	var response responseStruct
-	err = json.NewDecoder(resp.Body).Decode(&response)
-
-	return &response, err
-}
diff --git a/providers/dns/glesys/glesys.go b/providers/dns/glesys/glesys.go
index 2b5379b3..acdf1b44 100644
--- a/providers/dns/glesys/glesys.go
+++ b/providers/dns/glesys/glesys.go
@@ -2,6 +2,7 @@
 package glesys
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/glesys/internal"
 )
 
 const (
@@ -55,7 +57,9 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config        *Config
+	config *Config
+	client *internal.Client
+
 	activeRecords map[string]int
 	inProgressMu  sync.Mutex
 }
@@ -90,8 +94,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, fmt.Errorf("glesys: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
 	}
 
+	client := internal.NewClient(config.APIUser, config.APIKey)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
 	return &DNSProvider{
 		config:        config,
+		client:        client,
 		activeRecords: make(map[string]int),
 	}, nil
 }
@@ -103,7 +114,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	// find authZone
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("glesys: findZoneByFqdn failure: %w", err)
+		return fmt.Errorf("glesys: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -111,14 +122,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("glesys: %w", err)
 	}
 
-	// acquire lock and check there is not a challenge already in
-	// progress for this value of authZone
+	// acquire lock and check there is not a challenge already in progress for this value of authZone
 	d.inProgressMu.Lock()
 	defer d.inProgressMu.Unlock()
 
 	// add TXT record into authZone
-	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	recordID, err := d.addTXTRecord(domain, dns01.UnFqdn(authZone), subDomain, info.Value, d.config.TTL)
+	recordID, err := d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value, d.config.TTL)
 	if err != nil {
 		return err
 	}
@@ -144,8 +153,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	delete(d.activeRecords, info.EffectiveFQDN)
 
 	// delete TXT record from authZone
-	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	return d.deleteTXTRecord(domain, recordID)
+	return d.client.DeleteTXTRecord(context.Background(), recordID)
 }
 
 // Timeout returns the values (20*time.Minute, 20*time.Second) which
diff --git a/providers/dns/glesys/internal/client.go b/providers/dns/glesys/internal/client.go
new file mode 100644
index 00000000..038c6f0d
--- /dev/null
+++ b/providers/dns/glesys/internal/client.go
@@ -0,0 +1,135 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// defaultBaseURL is the GleSYS API endpoint used by Present and CleanUp.
+const defaultBaseURL = "https://api.glesys.com/"
+
+type Client struct {
+	apiUser string
+	apiKey  string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+func NewClient(apiUser string, apiKey string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		apiUser:    apiUser,
+		apiKey:     apiKey,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// AddTXTRecord adds a dns record to a domain.
+// https://github.com/GleSYS/API/wiki/API-Documentation#domainaddrecord
+func (c *Client) AddTXTRecord(ctx context.Context, domain, name, value string, ttl int) (int, error) {
+	endpoint := c.baseURL.JoinPath("domain", "addrecord")
+
+	request := addRecordRequest{
+		DomainName: domain,
+		Host:       name,
+		Type:       "TXT",
+		Data:       value,
+		TTL:        ttl,
+	}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, request)
+	if err != nil {
+		return 0, err
+	}
+
+	response, err := c.do(req)
+	if err != nil {
+		return 0, err
+	}
+
+	if response != nil && response.Response.Status.Code == http.StatusOK {
+		return response.Response.Record.RecordID, nil
+	}
+
+	return 0, err
+}
+
+// DeleteTXTRecord removes a dns record from a domain.
+// https://github.com/GleSYS/API/wiki/API-Documentation#domaindeleterecord
+func (c *Client) DeleteTXTRecord(ctx context.Context, recordID int) error {
+	endpoint := c.baseURL.JoinPath("domain", "deleterecord")
+
+	request := deleteRecordRequest{RecordID: recordID}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, request)
+	if err != nil {
+		return err
+	}
+
+	_, err = c.do(req)
+
+	return err
+}
+
+func (c *Client) do(req *http.Request) (*apiResponse, error) {
+	req.SetBasicAuth(c.apiUser, c.apiKey)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var response apiResponse
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return &response, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/glesys/internal/client_test.go b/providers/dns/glesys/internal/client_test.go
new file mode 100644
index 00000000..7e8ca972
--- /dev/null
+++ b/providers/dns/glesys/internal/client_test.go
@@ -0,0 +1,79 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, method, pattern string, status int, file string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		apiUser, apiKey, ok := req.BasicAuth()
+		if apiUser != "user" || apiKey != "secret" || !ok {
+			http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	client := NewClient("user", "secret")
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+	client := setupTest(t, http.MethodPost, "/domain/addrecord", http.StatusOK, "add-record.json")
+
+	recordID, err := client.AddTXTRecord(context.Background(), "example.com", "foo", "txt", 120)
+	require.NoError(t, err)
+
+	assert.Equal(t, 123, recordID)
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+	client := setupTest(t, http.MethodPost, "/domain/deleterecord", http.StatusOK, "delete-record.json")
+
+	err := client.DeleteTXTRecord(context.Background(), 123)
+	require.NoError(t, err)
+}
diff --git a/providers/dns/glesys/internal/fixtures/add-record.json b/providers/dns/glesys/internal/fixtures/add-record.json
new file mode 100644
index 00000000..c7d1fc82
--- /dev/null
+++ b/providers/dns/glesys/internal/fixtures/add-record.json
@@ -0,0 +1,10 @@
+{
+  "response": {
+    "status": {
+      "code": 200
+    },
+    "record": {
+      "recordid": 123
+    }
+  }
+}
diff --git a/providers/dns/glesys/internal/fixtures/delete-record.json b/providers/dns/glesys/internal/fixtures/delete-record.json
new file mode 100644
index 00000000..c7d1fc82
--- /dev/null
+++ b/providers/dns/glesys/internal/fixtures/delete-record.json
@@ -0,0 +1,10 @@
+{
+  "response": {
+    "status": {
+      "code": 200
+    },
+    "record": {
+      "recordid": 123
+    }
+  }
+}
diff --git a/providers/dns/glesys/internal/types.go b/providers/dns/glesys/internal/types.go
new file mode 100644
index 00000000..61949d1f
--- /dev/null
+++ b/providers/dns/glesys/internal/types.go
@@ -0,0 +1,30 @@
+package internal
+
+type addRecordRequest struct {
+	DomainName string `json:"domainname"`
+	Host       string `json:"host"`
+	Type       string `json:"type"`
+	Data       string `json:"data"`
+	TTL        int    `json:"ttl,omitempty"`
+}
+
+type deleteRecordRequest struct {
+	RecordID int `json:"recordid"`
+}
+
+type apiResponse struct {
+	Response Response `json:"response"`
+}
+
+type Response struct {
+	Status Status `json:"status"`
+	Record Record `json:"record"`
+}
+
+type Status struct {
+	Code int `json:"code"`
+}
+
+type Record struct {
+	RecordID int `json:"recordid"`
+}
diff --git a/providers/dns/godaddy/godaddy.go b/providers/dns/godaddy/godaddy.go
index 961b2fd9..d5adbeb3 100644
--- a/providers/dns/godaddy/godaddy.go
+++ b/providers/dns/godaddy/godaddy.go
@@ -2,6 +2,7 @@
 package godaddy
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -104,17 +105,21 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	domainZone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("godaddy: failed to get zone: %w", err)
+		return fmt.Errorf("godaddy: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, domainZone)
+	authZone = dns01.UnFqdn(authZone)
+
+	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 	if err != nil {
 		return fmt.Errorf("godaddy: %w", err)
 	}
 
-	records, err := d.client.GetRecords(domainZone, "TXT", subDomain)
+	ctx := context.Background()
+
+	records, err := d.client.GetRecords(ctx, authZone, "TXT", subDomain)
 	if err != nil {
 		return fmt.Errorf("godaddy: failed to get TXT records: %w", err)
 	}
@@ -134,7 +139,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	}
 	newRecords = append(newRecords, record)
 
-	err = d.client.UpdateTxtRecords(newRecords, domainZone, subDomain)
+	err = d.client.UpdateTxtRecords(ctx, newRecords, authZone, subDomain)
 	if err != nil {
 		return fmt.Errorf("godaddy: failed to add TXT record: %w", err)
 	}
@@ -146,17 +151,21 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	domainZone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("godaddy: failed to get zone: %w", err)
+		return fmt.Errorf("godaddy: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, domainZone)
+	authZone = dns01.UnFqdn(authZone)
+
+	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 	if err != nil {
 		return fmt.Errorf("godaddy: %w", err)
 	}
 
-	records, err := d.client.GetRecords(domainZone, "TXT", subDomain)
+	ctx := context.Background()
+
+	records, err := d.client.GetRecords(ctx, authZone, "TXT", subDomain)
 	if err != nil {
 		return fmt.Errorf("godaddy: failed to get TXT records: %w", err)
 	}
@@ -165,7 +174,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return nil
 	}
 
-	allTxtRecords, err := d.client.GetRecords(domainZone, "TXT", "")
+	allTxtRecords, err := d.client.GetRecords(ctx, authZone, "TXT", "")
 	if err != nil {
 		return fmt.Errorf("godaddy: failed to get all TXT records: %w", err)
 	}
@@ -183,19 +192,10 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		recordsKeep = append(recordsKeep, emptyRecord)
 	}
 
-	err = d.client.UpdateTxtRecords(recordsKeep, domainZone, "")
+	err = d.client.UpdateTxtRecords(ctx, recordsKeep, authZone, "")
 	if err != nil {
 		return fmt.Errorf("godaddy: failed to remove TXT record: %w", err)
 	}
 
 	return nil
 }
-
-func getZone(fqdn string) (string, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return "", err
-	}
-
-	return dns01.UnFqdn(authZone), nil
-}
diff --git a/providers/dns/godaddy/internal/client.go b/providers/dns/godaddy/internal/client.go
index 90f1ef01..64f9f0bf 100644
--- a/providers/dns/godaddy/internal/client.go
+++ b/providers/dns/godaddy/internal/client.go
@@ -2,54 +2,51 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
-	"path"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // DefaultBaseURL represents the API endpoint to call.
 const DefaultBaseURL = "https://api.godaddy.com"
 
+const authorizationHeader = "Authorization"
+
 type Client struct {
-	HTTPClient *http.Client
+	apiKey    string
+	apiSecret string
+
 	baseURL    *url.URL
-	apiKey     string
-	apiSecret  string
+	HTTPClient *http.Client
 }
 
 func NewClient(apiKey string, apiSecret string) *Client {
 	baseURL, _ := url.Parse(DefaultBaseURL)
 
 	return &Client{
-		HTTPClient: &http.Client{Timeout: 5 * time.Second},
-		baseURL:    baseURL,
 		apiKey:     apiKey,
 		apiSecret:  apiSecret,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
-func (d *Client) GetRecords(domainZone, rType, recordName string) ([]DNSRecord, error) {
-	resource := path.Clean(fmt.Sprintf("/v1/domains/%s/records/%s/%s", domainZone, rType, recordName))
+func (c *Client) GetRecords(ctx context.Context, domainZone, rType, recordName string) ([]DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("v1", "domains", domainZone, "records", rType, recordName)
 
-	resp, err := d.makeRequest(http.MethodGet, resource, nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		bodyBytes, _ := io.ReadAll(resp.Body)
-		return nil, fmt.Errorf("could not get records: Domain: %s; Record: %s, Status: %v; Body: %s",
-			domainZone, recordName, resp.StatusCode, string(bodyBytes))
-	}
-
 	var records []DNSRecord
-	err = json.NewDecoder(resp.Body).Decode(&records)
+	err = c.do(req, &records)
 	if err != nil {
 		return nil, err
 	}
@@ -57,41 +54,68 @@ func (d *Client) GetRecords(domainZone, rType, recordName string) ([]DNSRecord,
 	return records, nil
 }
 
-func (d *Client) UpdateTxtRecords(records []DNSRecord, domainZone, recordName string) error {
-	body, err := json.Marshal(records)
+func (c *Client) UpdateTxtRecords(ctx context.Context, records []DNSRecord, domainZone, recordName string) error {
+	endpoint := c.baseURL.JoinPath("v1", "domains", domainZone, "records", "TXT", recordName)
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, records)
 	if err != nil {
 		return err
 	}
 
-	resource := path.Clean(fmt.Sprintf("/v1/domains/%s/records/TXT/%s", domainZone, recordName))
+	return c.do(req, nil)
+}
 
-	var resp *http.Response
-	resp, err = d.makeRequest(http.MethodPut, resource, bytes.NewReader(body))
+func (c *Client) do(req *http.Request, result any) error {
+	req.Header.Set(authorizationHeader, fmt.Sprintf("sso-key %s:%s", c.apiKey, c.apiSecret))
+
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		bodyBytes, _ := io.ReadAll(resp.Body)
-		return fmt.Errorf("could not create record %v; Status: %v; Body: %s", string(body), resp.StatusCode, string(bodyBytes))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
 }
 
-func (d *Client) makeRequest(method, uri string, body io.Reader) (*http.Response, error) {
-	endpoint := d.baseURL.JoinPath(uri)
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
 
-	req, err := http.NewRequest(method, endpoint.String(), body)
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", d.apiKey, d.apiSecret))
 
-	return d.HTTPClient.Do(req)
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
 }
diff --git a/providers/dns/godaddy/internal/client_test.go b/providers/dns/godaddy/internal/client_test.go
index 5e297453..ccbab16d 100644
--- a/providers/dns/godaddy/internal/client_test.go
+++ b/providers/dns/godaddy/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -14,7 +15,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setupTest(t *testing.T) (*http.ServeMux, *Client) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
@@ -25,15 +26,15 @@ func setupTest(t *testing.T) (*http.ServeMux, *Client) {
 	client.HTTPClient = server.Client()
 	client.baseURL, _ = url.Parse(server.URL)
 
-	return mux, client
+	return client, mux
 }
 
 func TestClient_GetRecords(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusOK, "getrecords.json"))
 
-	records, err := client.GetRecords("example.com", "TXT", "")
+	records, err := client.GetRecords(context.Background(), "example.com", "TXT", "")
 	require.NoError(t, err)
 
 	expected := []DNSRecord{
@@ -49,17 +50,17 @@ func TestClient_GetRecords(t *testing.T) {
 }
 
 func TestClient_GetRecords_errors(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusUnprocessableEntity, "errors.json"))
 
-	records, err := client.GetRecords("example.com", "TXT", "")
+	records, err := client.GetRecords(context.Background(), "example.com", "TXT", "")
 	require.Error(t, err)
 	assert.Nil(t, records)
 }
 
 func TestClient_UpdateTxtRecords(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPut {
@@ -67,7 +68,7 @@ func TestClient_UpdateTxtRecords(t *testing.T) {
 			return
 		}
 
-		auth := req.Header.Get("Authorization")
+		auth := req.Header.Get(authorizationHeader)
 		if auth != "sso-key key:secret" {
 			http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized)
 			return
@@ -83,12 +84,12 @@ func TestClient_UpdateTxtRecords(t *testing.T) {
 		{Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600},
 	}
 
-	err := client.UpdateTxtRecords(records, "example.com", "lego")
+	err := client.UpdateTxtRecords(context.Background(), records, "example.com", "lego")
 	require.NoError(t, err)
 }
 
 func TestClient_UpdateTxtRecords_errors(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/domains/example.com/records/TXT/lego",
 		testHandler(http.MethodPut, http.StatusUnprocessableEntity, "errors.json"))
@@ -102,7 +103,7 @@ func TestClient_UpdateTxtRecords_errors(t *testing.T) {
 		{Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600},
 	}
 
-	err := client.UpdateTxtRecords(records, "example.com", "lego")
+	err := client.UpdateTxtRecords(context.Background(), records, "example.com", "lego")
 	require.Error(t, err)
 }
 
@@ -113,7 +114,7 @@ func testHandler(method string, statusCode int, filename string) http.HandlerFun
 			return
 		}
 
-		auth := req.Header.Get("Authorization")
+		auth := req.Header.Get(authorizationHeader)
 		if auth != "sso-key key:secret" {
 			http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized)
 			return
diff --git a/providers/dns/hetzner/hetzner.go b/providers/dns/hetzner/hetzner.go
index 405d02d3..58916b4a 100644
--- a/providers/dns/hetzner/hetzner.go
+++ b/providers/dns/hetzner/hetzner.go
@@ -2,6 +2,7 @@
 package hetzner
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -100,12 +101,16 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("hetzner: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("hetzner: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	zoneID, err := d.client.GetZoneID(zone)
+	zone := dns01.UnFqdn(authZone)
+
+	ctx := context.Background()
+
+	zoneID, err := d.client.GetZoneID(ctx, zone)
 	if err != nil {
 		return fmt.Errorf("hetzner: %w", err)
 	}
@@ -123,7 +128,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		ZoneID: zoneID,
 	}
 
-	if err := d.client.CreateRecord(record); err != nil {
+	if err := d.client.CreateRecord(ctx, record); err != nil {
 		return fmt.Errorf("hetzner: failed to add TXT record: fqdn=%s, zoneID=%s: %w", info.EffectiveFQDN, zoneID, err)
 	}
 
@@ -134,12 +139,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := getZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("hetzner: failed to find zone: fqdn=%s: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("hetzner: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	zoneID, err := d.client.GetZoneID(zone)
+	zone := dns01.UnFqdn(authZone)
+
+	ctx := context.Background()
+
+	zoneID, err := d.client.GetZoneID(ctx, zone)
 	if err != nil {
 		return fmt.Errorf("hetzner: %w", err)
 	}
@@ -149,23 +158,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("hetzner: %w", err)
 	}
 
-	record, err := d.client.GetTxtRecord(subDomain, info.Value, zoneID)
+	record, err := d.client.GetTxtRecord(ctx, subDomain, info.Value, zoneID)
 	if err != nil {
 		return fmt.Errorf("hetzner: %w", err)
 	}
 
-	if err := d.client.DeleteRecord(record.ID); err != nil {
+	if err := d.client.DeleteRecord(ctx, record.ID); err != nil {
 		return fmt.Errorf("hetzner: failed to delate TXT record: id=%s, name=%s: %w", record.ID, record.Name, err)
 	}
 
 	return nil
 }
-
-func getZone(fqdn string) (string, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return "", err
-	}
-
-	return dns01.UnFqdn(authZone), nil
-}
diff --git a/providers/dns/hetzner/internal/client.go b/providers/dns/hetzner/internal/client.go
index 326ecdb0..38192226 100644
--- a/providers/dns/hetzner/internal/client.go
+++ b/providers/dns/hetzner/internal/client.go
@@ -2,11 +2,15 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // defaultBaseURL represents the API endpoint to call.
@@ -16,24 +20,26 @@ const authHeader = "Auth-API-Token"
 
 // Client the Hetzner client.
 type Client struct {
-	HTTPClient *http.Client
-	BaseURL    string
-
 	apiKey string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient Creates a new Hetzner client.
 func NewClient(apiKey string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    defaultBaseURL,
 		apiKey:     apiKey,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // GetTxtRecord gets a TXT record.
-func (c *Client) GetTxtRecord(name, value, zoneID string) (*DNSRecord, error) {
-	records, err := c.getRecords(zoneID)
+func (c *Client) GetTxtRecord(ctx context.Context, name, value, zoneID string) (*DNSRecord, error) {
+	records, err := c.getRecords(ctx, zoneID)
 	if err != nil {
 		return nil, err
 	}
@@ -48,33 +54,38 @@ func (c *Client) GetTxtRecord(name, value, zoneID string) (*DNSRecord, error) {
 }
 
 // https://dns.hetzner.com/api-docs#operation/GetRecords
-func (c *Client) getRecords(zoneID string) (*DNSRecords, error) {
-	endpoint, err := c.createEndpoint("api", "v1", "records")
-	if err != nil {
-		return nil, fmt.Errorf("failed to create endpoint: %w", err)
-	}
+func (c *Client) getRecords(ctx context.Context, zoneID string) (*DNSRecords, error) {
+	endpoint := c.baseURL.JoinPath("api", "v1", "records")
 
 	query := endpoint.Query()
 	query.Set("zone_id", zoneID)
 	endpoint.RawQuery = query.Encode()
 
-	resp, err := c.do(http.MethodGet, endpoint, nil)
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		bodyBytes, _ := io.ReadAll(resp.Body)
-		return nil, fmt.Errorf("could not get records: zone ID: %s; Status: %s; Body: %s",
-			zoneID, resp.Status, string(bodyBytes))
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	records := &DNSRecords{}
-	err = json.NewDecoder(resp.Body).Decode(records)
+	err = json.Unmarshal(raw, records)
 	if err != nil {
-		return nil, fmt.Errorf("failed to decode response body: %w", err)
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return records, nil
@@ -82,25 +93,23 @@ func (c *Client) getRecords(zoneID string) (*DNSRecords, error) {
 
 // CreateRecord creates a DNS record.
 // https://dns.hetzner.com/api-docs#operation/CreateRecord
-func (c *Client) CreateRecord(record DNSRecord) error {
-	body, err := json.Marshal(record)
+func (c *Client) CreateRecord(ctx context.Context, record DNSRecord) error {
+	endpoint := c.baseURL.JoinPath("api", "v1", "records")
+
+	req, err := c.newRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return err
 	}
 
-	endpoint, err := c.createEndpoint("api", "v1", "records")
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return fmt.Errorf("failed to create endpoint: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	resp, err := c.do(http.MethodPost, endpoint, bytes.NewReader(body))
-	if err != nil {
-		return err
-	}
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		bodyBytes, _ := io.ReadAll(resp.Body)
-		return fmt.Errorf("could not create record %s; Status: %s; Body: %s", string(body), resp.Status, string(bodyBytes))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	return nil
@@ -108,27 +117,31 @@ func (c *Client) CreateRecord(record DNSRecord) error {
 
 // DeleteRecord deletes a DNS record.
 // https://dns.hetzner.com/api-docs#operation/DeleteRecord
-func (c *Client) DeleteRecord(recordID string) error {
-	endpoint, err := c.createEndpoint("api", "v1", "records", recordID)
-	if err != nil {
-		return fmt.Errorf("failed to create endpoint: %w", err)
-	}
+func (c *Client) DeleteRecord(ctx context.Context, recordID string) error {
+	endpoint := c.baseURL.JoinPath("api", "v1", "records", recordID)
 
-	resp, err := c.do(http.MethodDelete, endpoint, nil)
+	req, err := c.newRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
 
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
 	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("could not delete record: %s; Status: %s", resp.Status, recordID)
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	return nil
 }
 
 // GetZoneID gets the zone ID for a domain.
-func (c *Client) GetZoneID(domain string) (string, error) {
-	zones, err := c.getZones(domain)
+func (c *Client) GetZoneID(ctx context.Context, domain string) (string, error) {
+	zones, err := c.getZones(ctx, domain)
 	if err != nil {
 		return "", err
 	}
@@ -143,57 +156,70 @@ func (c *Client) GetZoneID(domain string) (string, error) {
 }
 
 // https://dns.hetzner.com/api-docs#operation/GetZones
-func (c *Client) getZones(name string) (*Zones, error) {
-	endpoint, err := c.createEndpoint("api", "v1", "zones")
-	if err != nil {
-		return nil, fmt.Errorf("failed to create endpoint: %w", err)
-	}
+func (c *Client) getZones(ctx context.Context, name string) (*Zones, error) {
+	endpoint := c.baseURL.JoinPath("api", "v1", "zones")
 
 	query := endpoint.Query()
 	query.Set("name", name)
 	endpoint.RawQuery = query.Encode()
 
-	resp, err := c.do(http.MethodGet, endpoint, nil)
+	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("could not get zones: %w", err)
 	}
 
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
 	// EOF fallback
 	if resp.StatusCode == http.StatusNotFound {
 		return &Zones{}, nil
 	}
 
 	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("could not get zones: %s", resp.Status)
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	zones := &Zones{}
-	err = json.NewDecoder(resp.Body).Decode(zones)
+	err = json.Unmarshal(raw, zones)
 	if err != nil {
-		return nil, fmt.Errorf("failed to decode response body: %w", err)
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return zones, nil
 }
 
-func (c *Client) do(method string, endpoint fmt.Stringer, body io.Reader) (*http.Response, error) {
-	req, err := http.NewRequest(method, endpoint.String(), body)
+func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set(authHeader, c.apiKey)
 
-	return c.HTTPClient.Do(req)
-}
-
-func (c *Client) createEndpoint(parts ...string) (*url.URL, error) {
-	baseURL, err := url.Parse(c.BaseURL)
-	if err != nil {
-		return nil, err
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
 	}
 
-	return baseURL.JoinPath(parts...), nil
+	req.Header.Set(authHeader, c.apiKey)
+
+	return req, nil
 }
diff --git a/providers/dns/hetzner/internal/client_test.go b/providers/dns/hetzner/internal/client_test.go
index 269f984a..aa217540 100644
--- a/providers/dns/hetzner/internal/client_test.go
+++ b/providers/dns/hetzner/internal/client_test.go
@@ -1,10 +1,12 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -12,14 +14,26 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_GetTxtRecord(t *testing.T) {
+func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
+	client := NewClient(apiKey)
+	client.baseURL, _ = url.Parse(server.URL)
+	client.HTTPClient = server.Client()
+
+	return client, mux
+}
+
+func TestClient_GetTxtRecord(t *testing.T) {
 	const zoneID = "zoneA"
 	const apiKey = "myKeyA"
 
+	client, mux := setupTest(t, apiKey)
+
 	mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
@@ -52,23 +66,18 @@ func TestClient_GetTxtRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient(apiKey)
-	client.BaseURL = server.URL
-
-	record, err := client.GetTxtRecord("test1", "txttxttxt", zoneID)
+	record, err := client.GetTxtRecord(context.Background(), "test1", "txttxttxt", zoneID)
 	require.NoError(t, err)
 
 	fmt.Println(record)
 }
 
 func TestClient_CreateRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	const zoneID = "zoneA"
 	const apiKey = "myKeyB"
 
+	client, mux := setupTest(t, apiKey)
+
 	mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
@@ -95,9 +104,6 @@ func TestClient_CreateRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient(apiKey)
-	client.BaseURL = server.URL
-
 	record := DNSRecord{
 		Name:   "test",
 		Type:   "TXT",
@@ -106,17 +112,15 @@ func TestClient_CreateRecord(t *testing.T) {
 		ZoneID: zoneID,
 	}
 
-	err := client.CreateRecord(record)
+	err := client.CreateRecord(context.Background(), record)
 	require.NoError(t, err)
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	const apiKey = "myKeyC"
 
+	client, mux := setupTest(t, apiKey)
+
 	mux.HandleFunc("/api/v1/records/recordID", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodDelete {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
@@ -130,19 +134,15 @@ func TestClient_DeleteRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient(apiKey)
-	client.BaseURL = server.URL
-
-	err := client.DeleteRecord("recordID")
+	err := client.DeleteRecord(context.Background(), "recordID")
 	require.NoError(t, err)
 }
 
 func TestClient_GetZoneID(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
 	const apiKey = "myKeyD"
+
+	client, mux := setupTest(t, apiKey)
+
 	mux.HandleFunc("/api/v1/zones", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
@@ -169,10 +169,7 @@ func TestClient_GetZoneID(t *testing.T) {
 		}
 	})
 
-	client := NewClient(apiKey)
-	client.BaseURL = server.URL
-
-	zoneID, err := client.GetZoneID("example.com")
+	zoneID, err := client.GetZoneID(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	assert.Equal(t, "zoneA", zoneID)
diff --git a/providers/dns/hostingde/client.go b/providers/dns/hostingde/client.go
deleted file mode 100644
index 047bb740..00000000
--- a/providers/dns/hostingde/client.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package hostingde
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"time"
-
-	"github.com/cenkalti/backoff/v4"
-)
-
-const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json"
-
-// https://www.hosting.de/api/?json#list-zoneconfigs
-func (d *DNSProvider) listZoneConfigs(findRequest ZoneConfigsFindRequest) (*ZoneConfigsFindResponse, error) {
-	uri := defaultBaseURL + "/zoneConfigsFind"
-
-	findResponse := &ZoneConfigsFindResponse{}
-
-	rawResp, err := d.post(uri, findRequest, findResponse)
-	if err != nil {
-		return nil, err
-	}
-
-	if len(findResponse.Response.Data) == 0 {
-		return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(uri, rawResp))
-	}
-
-	if findResponse.Status != "success" && findResponse.Status != "pending" {
-		return findResponse, errors.New(toUnreadableBodyMessage(uri, rawResp))
-	}
-
-	return findResponse, nil
-}
-
-// https://www.hosting.de/api/?json#updating-zones
-func (d *DNSProvider) updateZone(updateRequest ZoneUpdateRequest) (*ZoneUpdateResponse, error) {
-	uri := defaultBaseURL + "/zoneUpdate"
-
-	// but we'll need the ID later to delete the record
-	updateResponse := &ZoneUpdateResponse{}
-
-	rawResp, err := d.post(uri, updateRequest, updateResponse)
-	if err != nil {
-		return nil, err
-	}
-
-	if updateResponse.Status != "success" && updateResponse.Status != "pending" {
-		return nil, errors.New(toUnreadableBodyMessage(uri, rawResp))
-	}
-
-	return updateResponse, nil
-}
-
-func (d *DNSProvider) getZone(findRequest ZoneConfigsFindRequest) (*ZoneConfig, error) {
-	var zoneConfig *ZoneConfig
-
-	operation := func() error {
-		findResponse, err := d.listZoneConfigs(findRequest)
-		if err != nil {
-			return backoff.Permanent(err)
-		}
-
-		if findResponse.Response.Data[0].Status != "active" {
-			return fmt.Errorf("unexpected status: %q", findResponse.Response.Data[0].Status)
-		}
-
-		zoneConfig = &findResponse.Response.Data[0]
-
-		return nil
-	}
-
-	bo := backoff.NewExponentialBackOff()
-	bo.InitialInterval = 3 * time.Second
-	bo.MaxInterval = 10 * bo.InitialInterval
-	bo.MaxElapsedTime = 100 * bo.InitialInterval
-
-	// retry in case the zone was edited recently and is not yet active
-	err := backoff.Retry(operation, bo)
-	if err != nil {
-		return nil, err
-	}
-
-	return zoneConfig, nil
-}
-
-func (d *DNSProvider) post(uri string, request, response interface{}) ([]byte, error) {
-	body, err := json.Marshal(request)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(http.MethodPost, uri, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, fmt.Errorf("error querying API: %w", err)
-	}
-
-	defer resp.Body.Close()
-
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.New(toUnreadableBodyMessage(uri, content))
-	}
-
-	err = json.Unmarshal(content, response)
-	if err != nil {
-		return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(uri, content))
-	}
-
-	return content, nil
-}
-
-func toUnreadableBodyMessage(uri string, rawBody []byte) string {
-	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", uri, string(rawBody))
-}
diff --git a/providers/dns/hostingde/hostingde.go b/providers/dns/hostingde/hostingde.go
index e8ebefe1..10d9b5c0 100644
--- a/providers/dns/hostingde/hostingde.go
+++ b/providers/dns/hostingde/hostingde.go
@@ -2,6 +2,7 @@
 package hostingde
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/hostingde/internal"
 )
 
 // Environment variables names.
@@ -49,7 +51,9 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config      *Config
+	config *Config
+	client *internal.Client
+
 	recordIDs   map[string]string
 	recordIDsMu sync.Mutex
 }
@@ -82,6 +86,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 
 	return &DNSProvider{
 		config:    config,
+		client:    internal.NewClient(config.APIKey),
 		recordIDs: make(map[string]string),
 	}, nil
 }
@@ -98,42 +103,43 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	zoneName, err := d.getZoneName(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("hostingde: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("hostingde: could not find zone for domain %q: %w", domain, err)
 	}
 
+	ctx := context.Background()
+
 	// get the ZoneConfig for that domain
-	zonesFind := ZoneConfigsFindRequest{
-		Filter: Filter{Field: "zoneName", Value: zoneName},
+	zonesFind := internal.ZoneConfigsFindRequest{
+		Filter: internal.Filter{Field: "zoneName", Value: zoneName},
 		Limit:  1,
 		Page:   1,
 	}
-	zonesFind.AuthToken = d.config.APIKey
 
-	zoneConfig, err := d.getZone(zonesFind)
+	zoneConfig, err := d.client.GetZone(ctx, zonesFind)
 	if err != nil {
 		return fmt.Errorf("hostingde: %w", err)
 	}
+
 	zoneConfig.Name = zoneName
 
-	rec := []DNSRecord{{
+	rec := []internal.DNSRecord{{
 		Type:    "TXT",
 		Name:    dns01.UnFqdn(info.EffectiveFQDN),
 		Content: info.Value,
 		TTL:     d.config.TTL,
 	}}
 
-	req := ZoneUpdateRequest{
+	req := internal.ZoneUpdateRequest{
 		ZoneConfig:   *zoneConfig,
 		RecordsToAdd: rec,
 	}
-	req.AuthToken = d.config.APIKey
 
-	resp, err := d.updateZone(req)
+	response, err := d.client.UpdateZone(ctx, req)
 	if err != nil {
 		return fmt.Errorf("hostingde: %w", err)
 	}
 
-	for _, record := range resp.Response.Records {
+	for _, record := range response.Records {
 		if record.Name == dns01.UnFqdn(info.EffectiveFQDN) && record.Content == fmt.Sprintf(`%q`, info.Value) {
 			d.recordIDsMu.Lock()
 			d.recordIDs[info.EffectiveFQDN] = record.ID
@@ -154,41 +160,41 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	zoneName, err := d.getZoneName(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("hostingde: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("hostingde: could not find zone for domain %q: %w", domain, err)
 	}
 
-	rec := []DNSRecord{{
-		Type:    "TXT",
-		Name:    dns01.UnFqdn(info.EffectiveFQDN),
-		Content: `"` + info.Value + `"`,
-	}}
+	ctx := context.Background()
 
 	// get the ZoneConfig for that domain
-	zonesFind := ZoneConfigsFindRequest{
-		Filter: Filter{Field: "zoneName", Value: zoneName},
+	zonesFind := internal.ZoneConfigsFindRequest{
+		Filter: internal.Filter{Field: "zoneName", Value: zoneName},
 		Limit:  1,
 		Page:   1,
 	}
-	zonesFind.AuthToken = d.config.APIKey
 
-	zoneConfig, err := d.getZone(zonesFind)
+	zoneConfig, err := d.client.GetZone(ctx, zonesFind)
 	if err != nil {
 		return fmt.Errorf("hostingde: %w", err)
 	}
 	zoneConfig.Name = zoneName
 
-	req := ZoneUpdateRequest{
+	rec := []internal.DNSRecord{{
+		Type:    "TXT",
+		Name:    dns01.UnFqdn(info.EffectiveFQDN),
+		Content: `"` + info.Value + `"`,
+	}}
+
+	req := internal.ZoneUpdateRequest{
 		ZoneConfig:      *zoneConfig,
 		RecordsToDelete: rec,
 	}
-	req.AuthToken = d.config.APIKey
 
 	// Delete record ID from map
 	d.recordIDsMu.Lock()
 	delete(d.recordIDs, info.EffectiveFQDN)
 	d.recordIDsMu.Unlock()
 
-	_, err = d.updateZone(req)
+	_, err = d.client.UpdateZone(ctx, req)
 	if err != nil {
 		return fmt.Errorf("hostingde: %w", err)
 	}
@@ -202,7 +208,7 @@ func (d *DNSProvider) getZoneName(fqdn string) (string, error) {
 
 	zoneName, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	if zoneName == "" {
diff --git a/providers/dns/hostingde/internal/client.go b/providers/dns/hostingde/internal/client.go
new file mode 100644
index 00000000..0f5c6d18
--- /dev/null
+++ b/providers/dns/hostingde/internal/client.go
@@ -0,0 +1,147 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://secure.hosting.de/api/dns/v1/json"
+
+// Client the API client for Hosting.de.
+type Client struct {
+	apiKey string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates new Client.
+func NewClient(apiKey string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		apiKey:     apiKey,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// GetZone gets a zone.
+func (c Client) GetZone(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneConfig, error) {
+	var zoneConfig *ZoneConfig
+
+	operation := func() error {
+		response, err := c.ListZoneConfigs(ctx, req)
+		if err != nil {
+			return backoff.Permanent(err)
+		}
+
+		if response.Data[0].Status != "active" {
+			return fmt.Errorf("unexpected status: %q", response.Data[0].Status)
+		}
+
+		zoneConfig = &response.Data[0]
+
+		return nil
+	}
+
+	bo := backoff.NewExponentialBackOff()
+	bo.InitialInterval = 3 * time.Second
+	bo.MaxInterval = 10 * bo.InitialInterval
+	bo.MaxElapsedTime = 100 * bo.InitialInterval
+
+	// retry in case the zone was edited recently and is not yet active
+	err := backoff.Retry(operation, bo)
+	if err != nil {
+		return nil, err
+	}
+
+	return zoneConfig, nil
+}
+
+// ListZoneConfigs lists zone configuration.
+// https://www.hosting.de/api/?json#list-zoneconfigs
+func (c Client) ListZoneConfigs(ctx context.Context, req ZoneConfigsFindRequest) (*ZoneResponse, error) {
+	endpoint := c.baseURL.JoinPath("zoneConfigsFind")
+
+	req.AuthToken = c.apiKey
+
+	response := &BaseResponse[*ZoneResponse]{}
+
+	rawResp, err := c.post(ctx, endpoint, req, response)
+	if err != nil {
+		return nil, err
+	}
+
+	if response.Status != "success" && response.Status != "pending" {
+		return nil, fmt.Errorf("unexpected status: %q, %s", response.Status, string(rawResp))
+	}
+
+	if response.Response == nil || len(response.Response.Data) == 0 {
+		return nil, fmt.Errorf("no data, status: %q, %s", response.Status, string(rawResp))
+	}
+
+	return response.Response, nil
+}
+
+// UpdateZone updates a zone.
+// https://www.hosting.de/api/?json#updating-zones
+func (c Client) UpdateZone(ctx context.Context, req ZoneUpdateRequest) (*Zone, error) {
+	endpoint := c.baseURL.JoinPath("zoneUpdate")
+
+	req.AuthToken = c.apiKey
+
+	// but we'll need the ID later to delete the record
+	response := &BaseResponse[*Zone]{}
+
+	rawResp, err := c.post(ctx, endpoint, req, response)
+	if err != nil {
+		return nil, err
+	}
+
+	if response.Status != "success" && response.Status != "pending" {
+		return nil, fmt.Errorf("unexpected status: %q, %s", response.Status, string(rawResp))
+	}
+
+	return response.Response, nil
+}
+
+func (c Client) post(ctx context.Context, endpoint *url.URL, request, result any) ([]byte, error) {
+	body, err := json.Marshal(request)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return raw, nil
+}
diff --git a/providers/dns/hostingde/internal/client_test.go b/providers/dns/hostingde/internal/client_test.go
new file mode 100644
index 00000000..af76d0d2
--- /dev/null
+++ b/providers/dns/hostingde/internal/client_test.go
@@ -0,0 +1,264 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	client := NewClient("secret")
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
+
+	mux.HandleFunc(pattern, handler)
+
+	return client
+}
+
+func writeFixture(rw http.ResponseWriter, filename string) {
+	file, err := os.Open(filepath.Join("fixtures", filename))
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer func() { _ = file.Close() }()
+
+	_, _ = io.Copy(rw, file)
+}
+
+func TestClient_ListZoneConfigs(t *testing.T) {
+	client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		raw, err := io.ReadAll(req.Body)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		body := string(bytes.TrimSpace(raw))
+		if body != `{"authToken":"secret","filter":{"field":"zoneName","value":"example.com"},"limit":1,"page":1}` {
+			http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest)
+			return
+		}
+
+		writeFixture(rw, "zoneConfigsFind.json")
+	})
+
+	zonesFind := ZoneConfigsFindRequest{
+		Filter: Filter{Field: "zoneName", Value: "example.com"},
+		Limit:  1,
+		Page:   1,
+	}
+
+	zoneResponse, err := client.ListZoneConfigs(context.Background(), zonesFind)
+	require.NoError(t, err)
+
+	expected := &ZoneResponse{
+		Limit:        10,
+		Page:         1,
+		TotalEntries: 15,
+		TotalPages:   2,
+		Type:         "FindZoneConfigsResult",
+		Data: []ZoneConfig{{
+			ID:                    "123",
+			AccountID:             "456",
+			Status:                "s",
+			Name:                  "n",
+			NameUnicode:           "u",
+			MasterIP:              "m",
+			Type:                  "t",
+			EMailAddress:          "e",
+			ZoneTransferWhitelist: []string{"a", "b"},
+			LastChangeDate:        "l",
+			DNSServerGroupID:      "g",
+			DNSSecMode:            "m",
+			SOAValues: &SOAValues{
+				Refresh:     1,
+				Retry:       2,
+				Expire:      3,
+				TTL:         4,
+				NegativeTTL: 5,
+			},
+			TemplateValues: json.RawMessage(nil),
+		}},
+	}
+
+	assert.Equal(t, expected, zoneResponse)
+}
+
+func TestClient_ListZoneConfigs_error(t *testing.T) {
+	client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		writeFixture(rw, "zoneConfigsFind_error.json")
+	})
+
+	zonesFind := ZoneConfigsFindRequest{
+		Filter: Filter{Field: "zoneName", Value: "example.com"},
+		Limit:  1,
+		Page:   1,
+	}
+
+	_, err := client.ListZoneConfigs(context.Background(), zonesFind)
+	require.Error(t, err)
+}
+
+func TestClient_UpdateZone(t *testing.T) {
+	client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		raw, err := io.ReadAll(req.Body)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		body := string(bytes.TrimSpace(raw))
+		if body != `{"authToken":"secret","zoneConfig":{"id":"123","accountId":"456","status":"s","name":"n","nameUnicode":"u","masterIp":"m","type":"t","emailAddress":"e","zoneTransferWhitelist":["a","b"],"lastChangeDate":"l","dnsServerGroupId":"g","dnsSecMode":"m","soaValues":{"refresh":1,"retry":2,"expire":3,"ttl":4,"negativeTtl":5}},"recordsToAdd":null,"recordsToDelete":[{"name":"_acme-challenge.example.com","type":"TXT","content":"\"txt\""}]}` {
+			http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest)
+			return
+		}
+
+		writeFixture(rw, "zoneUpdate.json")
+	})
+
+	request := ZoneUpdateRequest{
+		ZoneConfig: ZoneConfig{
+			ID:                    "123",
+			AccountID:             "456",
+			Status:                "s",
+			Name:                  "n",
+			NameUnicode:           "u",
+			MasterIP:              "m",
+			Type:                  "t",
+			EMailAddress:          "e",
+			ZoneTransferWhitelist: []string{"a", "b"},
+			LastChangeDate:        "l",
+			DNSServerGroupID:      "g",
+			DNSSecMode:            "m",
+			SOAValues: &SOAValues{
+				Refresh:     1,
+				Retry:       2,
+				Expire:      3,
+				TTL:         4,
+				NegativeTTL: 5,
+			},
+		},
+		RecordsToDelete: []DNSRecord{{
+			Type:    "TXT",
+			Name:    "_acme-challenge.example.com",
+			Content: `"txt"`,
+		}},
+	}
+
+	response, err := client.UpdateZone(context.Background(), request)
+	require.NoError(t, err)
+
+	expected := &Zone{
+		Records: []DNSRecord{{
+			ID:               "123",
+			ZoneID:           "456",
+			RecordTemplateID: "789",
+			Name:             "n",
+			Type:             "TXT",
+			Content:          "txt",
+			TTL:              120,
+			Priority:         5,
+			LastChangeDate:   "d",
+		}},
+		ZoneConfig: ZoneConfig{
+			ID:                    "123",
+			AccountID:             "456",
+			Status:                "s",
+			Name:                  "n",
+			NameUnicode:           "u",
+			MasterIP:              "m",
+			Type:                  "t",
+			EMailAddress:          "e",
+			ZoneTransferWhitelist: []string{"a", "b"},
+			LastChangeDate:        "l",
+			DNSServerGroupID:      "g",
+			DNSSecMode:            "m",
+			SOAValues: &SOAValues{
+				Refresh:     1,
+				Retry:       2,
+				Expire:      3,
+				TTL:         4,
+				NegativeTTL: 5,
+			},
+		},
+	}
+
+	assert.Equal(t, expected, response)
+}
+
+func TestClient_UpdateZone_error(t *testing.T) {
+	client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		writeFixture(rw, "zoneUpdate_error.json")
+	})
+
+	request := ZoneUpdateRequest{
+		ZoneConfig: ZoneConfig{
+			ID:                    "123",
+			AccountID:             "456",
+			Status:                "s",
+			Name:                  "n",
+			NameUnicode:           "u",
+			MasterIP:              "m",
+			Type:                  "t",
+			EMailAddress:          "e",
+			ZoneTransferWhitelist: []string{"a", "b"},
+			LastChangeDate:        "l",
+			DNSServerGroupID:      "g",
+			DNSSecMode:            "m",
+			SOAValues: &SOAValues{
+				Refresh:     1,
+				Retry:       2,
+				Expire:      3,
+				TTL:         4,
+				NegativeTTL: 5,
+			},
+		},
+		RecordsToDelete: []DNSRecord{{
+			Type:    "TXT",
+			Name:    "_acme-challenge.example.com",
+			Content: `"txt"`,
+		}},
+	}
+
+	_, err := client.UpdateZone(context.Background(), request)
+	require.Error(t, err)
+}
diff --git a/providers/dns/hostingde/internal/fixtures/zoneConfigsFind.json b/providers/dns/hostingde/internal/fixtures/zoneConfigsFind.json
new file mode 100644
index 00000000..7c44d5d0
--- /dev/null
+++ b/providers/dns/hostingde/internal/fixtures/zoneConfigsFind.json
@@ -0,0 +1,44 @@
+{
+  "metadata": {
+    "clientTransactionId": "1",
+    "serverTransactionId": "2"
+  },
+  "warnings": [
+    "aaa",
+    "bbb"
+  ],
+  "status": "success",
+  "response": {
+    "limit": 10,
+    "page": 1,
+    "totalEntries": 15,
+    "totalPages": 2,
+    "type": "FindZoneConfigsResult",
+    "data": [
+      {
+        "id": "123",
+        "accountId": "456",
+        "status": "s",
+        "name": "n",
+        "nameUnicode": "u",
+        "masterIp": "m",
+        "type": "t",
+        "emailAddress": "e",
+        "zoneTransferWhitelist": [
+          "a",
+          "b"
+        ],
+        "lastChangeDate": "l",
+        "dnsServerGroupId": "g",
+        "dnsSecMode": "m",
+        "soaValues": {
+          "refresh": 1,
+          "retry": 2,
+          "expire": 3,
+          "ttl": 4,
+          "negativeTtl": 5
+        }
+      }
+    ]
+  }
+}
diff --git a/providers/dns/hostingde/internal/fixtures/zoneConfigsFind_error.json b/providers/dns/hostingde/internal/fixtures/zoneConfigsFind_error.json
new file mode 100644
index 00000000..101f02f0
--- /dev/null
+++ b/providers/dns/hostingde/internal/fixtures/zoneConfigsFind_error.json
@@ -0,0 +1,57 @@
+{
+  "errors": [
+    {
+      "code": 123,
+      "contextObject": "o",
+      "contextPath": "p",
+      "details": [
+        "a",
+        "b"
+      ],
+      "text": "t",
+      "value": "v"
+    }
+  ],
+  "metadata": {
+    "clientTransactionId": "1",
+    "serverTransactionId": "2"
+  },
+  "warnings": [
+    "aaa",
+    "bbb"
+  ],
+  "status": "error",
+  "response": {
+    "limit": 10,
+    "page": 1,
+    "totalEntries": 15,
+    "totalPages": 2,
+    "type": "FindZoneConfigsResult",
+    "data": [
+      {
+        "id": "123",
+        "accountId": "456",
+        "status": "s",
+        "name": "n",
+        "nameUnicode": "u",
+        "masterIp": "m",
+        "type": "t",
+        "emailAddress": "e",
+        "zoneTransferWhitelist": [
+          "a",
+          "b"
+        ],
+        "lastChangeDate": "l",
+        "dnsServerGroupId": "g",
+        "dnsSecMode": "m",
+        "soaValues": {
+          "refresh": 1,
+          "retry": 2,
+          "expire": 3,
+          "ttl": 4,
+          "negativeTtl": 5
+        }
+      }
+    ]
+  }
+}
diff --git a/providers/dns/hostingde/internal/fixtures/zoneUpdate.json b/providers/dns/hostingde/internal/fixtures/zoneUpdate.json
new file mode 100644
index 00000000..ac758c07
--- /dev/null
+++ b/providers/dns/hostingde/internal/fixtures/zoneUpdate.json
@@ -0,0 +1,50 @@
+{
+  "metadata": {
+    "clientTransactionId": "",
+    "serverTransactionId": ""
+  },
+  "warnings": [
+    "aaa",
+    "bbb"
+  ],
+  "status": "success",
+  "response": {
+    "records": [
+      {
+        "id": "123",
+        "zoneId": "456",
+        "recordTemplateId": "789",
+        "name": "n",
+        "type": "TXT",
+        "content": "txt",
+        "ttl": 120,
+        "priority": 5,
+        "lastChangeDate": "d"
+      }
+    ],
+    "zoneConfig": {
+      "id": "123",
+      "accountId": "456",
+      "status": "s",
+      "name": "n",
+      "nameUnicode": "u",
+      "masterIp": "m",
+      "type": "t",
+      "emailAddress": "e",
+      "zoneTransferWhitelist": [
+        "a",
+        "b"
+      ],
+      "lastChangeDate": "l",
+      "dnsServerGroupId": "g",
+      "dnsSecMode": "m",
+      "soaValues": {
+        "refresh": 1,
+        "retry": 2,
+        "expire": 3,
+        "ttl": 4,
+        "negativeTtl": 5
+      }
+    }
+  }
+}
diff --git a/providers/dns/hostingde/internal/fixtures/zoneUpdate_error.json b/providers/dns/hostingde/internal/fixtures/zoneUpdate_error.json
new file mode 100644
index 00000000..74a26508
--- /dev/null
+++ b/providers/dns/hostingde/internal/fixtures/zoneUpdate_error.json
@@ -0,0 +1,63 @@
+{
+  "errors": [
+    {
+      "code": 123,
+      "contextObject": "o",
+      "contextPath": "p",
+      "details": [
+        "a",
+        "b"
+      ],
+      "text": "t",
+      "value": "v"
+    }
+  ],
+  "metadata": {
+    "clientTransactionId": "",
+    "serverTransactionId": ""
+  },
+  "warnings": [
+    "aaa",
+    "bbb"
+  ],
+  "status": "error",
+  "response": {
+    "records": [
+      {
+        "id": "123",
+        "zoneId": "456",
+        "recordTemplateId": "789",
+        "name": "n",
+        "type": "TXT",
+        "content": "txt",
+        "ttl": 120,
+        "priority": 5,
+        "lastChangeDate": "d"
+      }
+    ],
+    "zoneConfig": {
+      "id": "123",
+      "accountId": "456",
+      "status": "s",
+      "name": "n",
+      "nameUnicode": "u",
+      "masterIp": "m",
+      "type": "t",
+      "emailAddress": "e",
+      "zoneTransferWhitelist": [
+        "a",
+        "b"
+      ],
+      "lastChangeDate": "l",
+      "dnsServerGroupId": "g",
+      "dnsSecMode": "m",
+      "soaValues": {
+        "refresh": 1,
+        "retry": 2,
+        "expire": 3,
+        "ttl": 4,
+        "negativeTtl": 5
+      }
+    }
+  }
+}
diff --git a/providers/dns/hostingde/model.go b/providers/dns/hostingde/internal/types.go
similarity index 84%
rename from providers/dns/hostingde/model.go
rename to providers/dns/hostingde/internal/types.go
index 9c67784b..a706008a 100644
--- a/providers/dns/hostingde/model.go
+++ b/providers/dns/hostingde/internal/types.go
@@ -1,4 +1,4 @@
-package hostingde
+package internal
 
 import "encoding/json"
 
@@ -93,13 +93,6 @@ type ZoneUpdateRequest struct {
 	RecordsToDelete []DNSRecord `json:"recordsToDelete"`
 }
 
-// ZoneUpdateResponse represents a response from the API.
-// https://www.hosting.de/api/?json#updating-zones
-type ZoneUpdateResponse struct {
-	BaseResponse
-	Response Zone `json:"response"`
-}
-
 // ZoneConfigsFindRequest represents a API ZonesFind request.
 // https://www.hosting.de/api/?json#list-zoneconfigs
 type ZoneConfigsFindRequest struct {
@@ -110,27 +103,25 @@ type ZoneConfigsFindRequest struct {
 	Sort   *Sort  `json:"sort,omitempty"`
 }
 
-// ZoneConfigsFindResponse represents the API response for ZoneConfigsFind.
-// https://www.hosting.de/api/?json#list-zoneconfigs
-type ZoneConfigsFindResponse struct {
-	BaseResponse
-	Response struct {
-		Limit        int          `json:"limit"`
-		Page         int          `json:"page"`
-		TotalEntries int          `json:"totalEntries"`
-		TotalPages   int          `json:"totalPages"`
-		Type         string       `json:"type"`
-		Data         []ZoneConfig `json:"data"`
-	} `json:"response"`
+type ZoneResponse struct {
+	Limit        int          `json:"limit"`
+	Page         int          `json:"page"`
+	TotalEntries int          `json:"totalEntries"`
+	TotalPages   int          `json:"totalPages"`
+	Type         string       `json:"type"`
+	Data         []ZoneConfig `json:"data"`
 }
 
 // BaseResponse Common response struct.
-// https://www.hosting.de/api/?json#responses
-type BaseResponse struct {
+// base: https://www.hosting.de/api/?json#responses
+// ZoneConfigsFind: https://www.hosting.de/api/?json#list-zoneconfigs
+// ZoneUpdate: https://www.hosting.de/api/?json#updating-zones
+type BaseResponse[T any] struct {
 	Errors   []APIError `json:"errors"`
 	Metadata Metadata   `json:"metadata"`
 	Warnings []string   `json:"warnings"`
 	Status   string     `json:"status"`
+	Response T          `json:"response"`
 }
 
 // BaseRequest Common request struct.
diff --git a/providers/dns/hosttech/hosttech.go b/providers/dns/hosttech/hosttech.go
index bd153150..41073f3c 100644
--- a/providers/dns/hosttech/hosttech.go
+++ b/providers/dns/hosttech/hosttech.go
@@ -2,6 +2,7 @@
 package hosttech
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -80,11 +81,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("hosttech: missing credentials")
 	}
 
-	client := internal.NewClient(config.APIKey)
-
-	if config.HTTPClient != nil {
-		client.HTTPClient = config.HTTPClient
-	}
+	client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.APIKey))
 
 	return &DNSProvider{
 		config:    config,
@@ -105,10 +102,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("hosttech: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	zone, err := d.client.GetZone(dns01.UnFqdn(authZone))
+	ctx := context.Background()
+
+	zone, err := d.client.GetZone(ctx, dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, authZone, err)
 	}
@@ -125,7 +124,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:  d.config.TTL,
 	}
 
-	newRecord, err := d.client.AddRecord(strconv.Itoa(zone.ID), record)
+	newRecord, err := d.client.AddRecord(ctx, strconv.Itoa(zone.ID), record)
 	if err != nil {
 		return fmt.Errorf("hosttech: %w", err)
 	}
@@ -143,10 +142,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("hosttech: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	zone, err := d.client.GetZone(dns01.UnFqdn(authZone))
+	ctx := context.Background()
+
+	zone, err := d.client.GetZone(ctx, dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("hosttech: could not find zone for domain %q (%s): %w", domain, authZone, err)
 	}
@@ -159,7 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("hosttech: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	err = d.client.DeleteRecord(strconv.Itoa(zone.ID), strconv.Itoa(recordID))
+	err = d.client.DeleteRecord(ctx, strconv.Itoa(zone.ID), strconv.Itoa(recordID))
 	if err != nil {
 		return fmt.Errorf("hosttech: %w", err)
 	}
diff --git a/providers/dns/hosttech/internal/client.go b/providers/dns/hosttech/internal/client.go
index 0786674c..78b59455 100644
--- a/providers/dns/hosttech/internal/client.go
+++ b/providers/dns/hosttech/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -9,32 +10,33 @@ import (
 	"net/url"
 	"strconv"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/oauth2"
 )
 
 const defaultBaseURL = "https://api.ns1.hosttech.eu/api"
 
 // Client a Hosttech client.
 type Client struct {
-	HTTPClient *http.Client
 	baseURL    *url.URL
-
-	apiKey string
+	httpClient *http.Client
 }
 
 // NewClient creates a new Client.
-func NewClient(apiKey string) *Client {
+func NewClient(hc *http.Client) *Client {
 	baseURL, _ := url.Parse(defaultBaseURL)
 
-	return &Client{
-		HTTPClient: &http.Client{Timeout: 10 * time.Second},
-		baseURL:    baseURL,
-		apiKey:     apiKey,
+	if hc == nil {
+		hc = &http.Client{Timeout: 10 * time.Second}
 	}
+
+	return &Client{baseURL: baseURL, httpClient: hc}
 }
 
 // GetZones Get a list of all zones.
 // https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones
-func (c Client) GetZones(query string, limit, offset int) ([]Zone, error) {
+func (c Client) GetZones(ctx context.Context, query string, limit, offset int) ([]Zone, error) {
 	endpoint := c.baseURL.JoinPath("user", "v1", "zones")
 
 	values := endpoint.Query()
@@ -50,52 +52,42 @@ func (c Client) GetZones(query string, limit, offset int) ([]Zone, error) {
 
 	endpoint.RawQuery = values.Encode()
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	raw, err := c.do(req)
+	result := apiResponse[[]Zone]{}
+	err = c.do(req, &result)
 	if err != nil {
 		return nil, err
 	}
 
-	var r []Zone
-	err = json.Unmarshal(raw, &r)
-	if err != nil {
-		return nil, fmt.Errorf("unmarshal response data: %s: %w", string(raw), err)
-	}
-
-	return r, nil
+	return result.Data, nil
 }
 
 // GetZone Get a single zone.
 // https://api.ns1.hosttech.eu/api/documentation/#/Zones/get_api_user_v1_zones__zoneId_
-func (c Client) GetZone(zoneID string) (*Zone, error) {
+func (c Client) GetZone(ctx context.Context, zoneID string) (*Zone, error) {
 	endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID)
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	raw, err := c.do(req)
+	result := apiResponse[*Zone]{}
+	err = c.do(req, &result)
 	if err != nil {
 		return nil, err
 	}
 
-	var r Zone
-	err = json.Unmarshal(raw, &r)
-	if err != nil {
-		return nil, fmt.Errorf("unmarshal response data: %s: %w", string(raw), err)
-	}
-
-	return &r, nil
+	return result.Data, nil
 }
 
 // GetRecords Returns a list of all records for the given zone.
 // https://api.ns1.hosttech.eu/api/documentation/#/Records/get_api_user_v1_zones__zoneId__records
-func (c Client) GetRecords(zoneID, recordType string) ([]Record, error) {
+func (c Client) GetRecords(ctx context.Context, zoneID, recordType string) ([]Record, error) {
 	endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records")
 
 	values := endpoint.Query()
@@ -106,107 +98,127 @@ func (c Client) GetRecords(zoneID, recordType string) ([]Record, error) {
 
 	endpoint.RawQuery = values.Encode()
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	raw, err := c.do(req)
+	result := apiResponse[[]Record]{}
+	err = c.do(req, &result)
 	if err != nil {
 		return nil, err
 	}
 
-	var r []Record
-	err = json.Unmarshal(raw, &r)
-	if err != nil {
-		return nil, fmt.Errorf("unmarshal response data: %s: %w", string(raw), err)
-	}
-
-	return r, nil
+	return result.Data, nil
 }
 
 // AddRecord Adds a new record to the zone and returns the newly created record.
 // https://api.ns1.hosttech.eu/api/documentation/#/Records/post_api_user_v1_zones__zoneId__records
-func (c Client) AddRecord(zoneID string, record Record) (*Record, error) {
+func (c Client) AddRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
 	endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records")
 
-	body, err := json.Marshal(record)
-	if err != nil {
-		return nil, fmt.Errorf("marshal request data: %w", err)
-	}
-
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	raw, err := c.do(req)
+	result := apiResponse[*Record]{}
+	err = c.do(req, &result)
 	if err != nil {
 		return nil, err
 	}
 
-	var r Record
-	err = json.Unmarshal(raw, &r)
-	if err != nil {
-		return nil, fmt.Errorf("unmarshal response data: %s: %w", string(raw), err)
-	}
-
-	return &r, nil
+	return result.Data, nil
 }
 
 // DeleteRecord Deletes a single record for the given id.
 // https://api.ns1.hosttech.eu/api/documentation/#/Records/delete_api_user_v1_zones__zoneId__records__recordId_
-func (c Client) DeleteRecord(zoneID, recordID string) error {
+func (c Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
 	endpoint := c.baseURL.JoinPath("user", "v1", "zones", zoneID, "records", recordID)
 
-	req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return fmt.Errorf("create request: %w", err)
 	}
 
-	_, err = c.do(req)
-
-	return err
+	return c.do(req, nil)
 }
 
-func (c Client) do(req *http.Request) (json.RawMessage, error) {
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
-
-	resp, errD := c.HTTPClient.Do(req)
+func (c Client) do(req *http.Request, result any) error {
+	resp, errD := c.httpClient.Do(req)
 	if errD != nil {
-		return nil, fmt.Errorf("send request: %w", errD)
+		return errutils.NewHTTPDoError(req, errD)
 	}
+
 	defer func() { _ = resp.Body.Close() }()
 
 	switch resp.StatusCode {
 	case http.StatusOK, http.StatusCreated:
-		all, err := io.ReadAll(resp.Body)
+		raw, err := io.ReadAll(resp.Body)
 		if err != nil {
-			return nil, fmt.Errorf("read response: %w", err)
+			return errutils.NewReadResponseError(req, resp.StatusCode, err)
 		}
 
-		var r apiResponse
-		err = json.Unmarshal(all, &r)
+		err = json.Unmarshal(raw, result)
 		if err != nil {
-			return nil, fmt.Errorf("unmarshal response: %w", err)
+			return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 		}
 
-		return r.Data, nil
+		return nil
 
 	case http.StatusNoContent:
-		return nil, nil
+		return nil
 
 	default:
-		data, _ := io.ReadAll(resp.Body)
-
-		e := APIError{StatusCode: resp.StatusCode}
-		err := json.Unmarshal(data, &e)
-		if err != nil {
-			e.Message = string(data)
-		}
-
-		return nil, e
+		return parseError(req, resp)
 	}
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errAPI := &APIError{StatusCode: resp.StatusCode}
+	err := json.Unmarshal(raw, errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return errAPI
+}
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
+}
diff --git a/providers/dns/hosttech/internal/client_test.go b/providers/dns/hosttech/internal/client_test.go
index b1073cfe..bf90acc9 100644
--- a/providers/dns/hosttech/internal/client_test.go
+++ b/providers/dns/hosttech/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -19,7 +20,7 @@ const testAPIKey = "secret"
 func TestClient_GetZones(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusOK, "zones.json"))
 
-	zones, err := client.GetZones("", 100, 0)
+	zones, err := client.GetZones(context.Background(), "", 100, 0)
 	require.NoError(t, err)
 
 	expected := []Zone{
@@ -40,14 +41,14 @@ func TestClient_GetZones(t *testing.T) {
 func TestClient_GetZones_error(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
 
-	_, err := client.GetZones("", 100, 0)
+	_, err := client.GetZones(context.Background(), "", 100, 0)
 	require.Error(t, err)
 }
 
 func TestClient_GetZone(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusOK, "zone.json"))
 
-	zone, err := client.GetZone("123")
+	zone, err := client.GetZone(context.Background(), "123")
 	require.NoError(t, err)
 
 	expected := &Zone{
@@ -66,14 +67,14 @@ func TestClient_GetZone(t *testing.T) {
 func TestClient_GetZone_error(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
 
-	_, err := client.GetZone("123")
+	_, err := client.GetZone(context.Background(), "123")
 	require.Error(t, err)
 }
 
 func TestClient_GetRecords(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusOK, "records.json"))
 
-	records, err := client.GetRecords("123", "TXT")
+	records, err := client.GetRecords(context.Background(), "123", "TXT")
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -153,7 +154,7 @@ func TestClient_GetRecords(t *testing.T) {
 func TestClient_GetRecords_error(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json"))
 
-	_, err := client.GetRecords("123", "TXT")
+	_, err := client.GetRecords(context.Background(), "123", "TXT")
 	require.Error(t, err)
 }
 
@@ -168,7 +169,7 @@ func TestClient_AddRecord(t *testing.T) {
 		Comment: "example",
 	}
 
-	newRecord, err := client.AddRecord("123", record)
+	newRecord, err := client.AddRecord(context.Background(), "123", record)
 	require.NoError(t, err)
 
 	expected := &Record{
@@ -194,21 +195,21 @@ func TestClient_AddRecord_error(t *testing.T) {
 		Comment: "example",
 	}
 
-	_, err := client.AddRecord("123", record)
+	_, err := client.AddRecord(context.Background(), "123", record)
 	require.Error(t, err)
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json"))
 
-	err := client.DeleteRecord("123", "6")
+	err := client.DeleteRecord(context.Background(), "123", "6")
 	require.Error(t, err)
 }
 
 func TestClient_DeleteRecord_error(t *testing.T) {
 	client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusNoContent, ""))
 
-	err := client.DeleteRecord("123", "6")
+	err := client.DeleteRecord(context.Background(), "123", "6")
 	require.NoError(t, err)
 }
 
@@ -221,7 +222,7 @@ func setupTest(t *testing.T, path string, handler http.Handler) *Client {
 
 	mux.Handle(path, handler)
 
-	client := NewClient(testAPIKey)
+	client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey))
 	client.baseURL, _ = url.Parse(server.URL)
 
 	return client
diff --git a/providers/dns/hosttech/internal/types.go b/providers/dns/hosttech/internal/types.go
index 53489e60..bf86964f 100644
--- a/providers/dns/hosttech/internal/types.go
+++ b/providers/dns/hosttech/internal/types.go
@@ -1,18 +1,17 @@
 package internal
 
 import (
-	"encoding/json"
 	"fmt"
 )
 
-type apiResponse struct {
-	Data json.RawMessage `json:"data"`
+type apiResponse[T any] struct {
+	Data T `json:"data"`
 }
 
 type APIError struct {
-	Message    string                 `json:"message,omitempty"`
-	Errors     map[string]interface{} `json:"errors,omitempty"`
-	StatusCode int                    `json:"-"`
+	Message    string         `json:"message,omitempty"`
+	Errors     map[string]any `json:"errors,omitempty"`
+	StatusCode int            `json:"-"`
 }
 
 func (a APIError) Error() string {
diff --git a/providers/dns/httpreq/httpreq.go b/providers/dns/httpreq/httpreq.go
index f7b97027..782f9a2a 100644
--- a/providers/dns/httpreq/httpreq.go
+++ b/providers/dns/httpreq/httpreq.go
@@ -3,16 +3,17 @@ package httpreq
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
 	"net/http"
 	"net/url"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // Environment variables names.
@@ -108,6 +109,8 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 
 // Present creates a TXT record to fulfill the dns-01 challenge.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	ctx := context.Background()
+
 	if d.config.Mode == "RAW" {
 		msg := &messageRaw{
 			Domain:  domain,
@@ -115,7 +118,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 			KeyAuth: keyAuth,
 		}
 
-		err := d.doPost("/present", msg)
+		err := d.doPost(ctx, "/present", msg)
 		if err != nil {
 			return fmt.Errorf("httpreq: %w", err)
 		}
@@ -128,7 +131,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Value: info.Value,
 	}
 
-	err := d.doPost("/present", msg)
+	err := d.doPost(ctx, "/present", msg)
 	if err != nil {
 		return fmt.Errorf("httpreq: %w", err)
 	}
@@ -137,6 +140,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 // CleanUp removes the TXT record matching the specified parameters.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	ctx := context.Background()
+
 	if d.config.Mode == "RAW" {
 		msg := &messageRaw{
 			Domain:  domain,
@@ -144,7 +149,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 			KeyAuth: keyAuth,
 		}
 
-		err := d.doPost("/cleanup", msg)
+		err := d.doPost(ctx, "/cleanup", msg)
 		if err != nil {
 			return fmt.Errorf("httpreq: %w", err)
 		}
@@ -157,46 +162,43 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		Value: info.Value,
 	}
 
-	err := d.doPost("/cleanup", msg)
+	err := d.doPost(ctx, "/cleanup", msg)
 	if err != nil {
 		return fmt.Errorf("httpreq: %w", err)
 	}
 	return nil
 }
 
-func (d *DNSProvider) doPost(uri string, msg interface{}) error {
-	reqBody := &bytes.Buffer{}
+func (d *DNSProvider) doPost(ctx context.Context, uri string, msg any) error {
+	reqBody := new(bytes.Buffer)
 	err := json.NewEncoder(reqBody).Encode(msg)
 	if err != nil {
-		return err
+		return fmt.Errorf("failed to create request JSON body: %w", err)
 	}
 
 	endpoint := d.config.Endpoint.JoinPath(uri)
 
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), reqBody)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), reqBody)
 	if err != nil {
-		return err
+		return fmt.Errorf("unable to create request: %w", err)
 	}
 
+	req.Header.Set("Accept", "application/json")
 	req.Header.Set("Content-Type", "application/json")
 
-	if len(d.config.Username) > 0 && len(d.config.Password) > 0 {
+	if d.config.Username != "" && d.config.Password != "" {
 		req.SetBasicAuth(d.config.Username, d.config.Password)
 	}
 
 	resp, err := d.config.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
-	defer resp.Body.Close()
 
-	if resp.StatusCode >= http.StatusBadRequest {
-		body, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return fmt.Errorf("%d: failed to read response body: %w", resp.StatusCode, err)
-		}
+	defer func() { _ = resp.Body.Close() }()
 
-		return fmt.Errorf("%d: request failed: %v", resp.StatusCode, string(body))
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	return nil
diff --git a/providers/dns/httpreq/httpreq_test.go b/providers/dns/httpreq/httpreq_test.go
index 99c56371..a545bd17 100644
--- a/providers/dns/httpreq/httpreq_test.go
+++ b/providers/dns/httpreq/httpreq_test.go
@@ -121,7 +121,7 @@ func TestNewDNSProvider_Present(t *testing.T) {
 		{
 			desc:          "error",
 			handler:       http.NotFound,
-			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+			expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
 		},
 		{
 			desc:    "success raw mode",
@@ -132,7 +132,7 @@ func TestNewDNSProvider_Present(t *testing.T) {
 			desc:          "error raw mode",
 			mode:          "RAW",
 			handler:       http.NotFound,
-			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+			expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
 		},
 		{
 			desc:     "basic auth",
@@ -157,11 +157,11 @@ func TestNewDNSProvider_Present(t *testing.T) {
 			t.Parallel()
 
 			mux := http.NewServeMux()
-			mux.HandleFunc(path.Join("/", test.pathPrefix, "present"), test.handler)
-
 			server := httptest.NewServer(mux)
 			t.Cleanup(server.Close)
 
+			mux.HandleFunc(path.Join("/", test.pathPrefix, "present"), test.handler)
+
 			config := NewDefaultConfig()
 			config.Endpoint = mustParse(server.URL + test.pathPrefix)
 			config.Mode = test.mode
@@ -199,7 +199,7 @@ func TestNewDNSProvider_Cleanup(t *testing.T) {
 		{
 			desc:          "error",
 			handler:       http.NotFound,
-			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+			expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
 		},
 		{
 			desc:    "success raw mode",
@@ -210,7 +210,7 @@ func TestNewDNSProvider_Cleanup(t *testing.T) {
 			desc:          "error raw mode",
 			mode:          "RAW",
 			handler:       http.NotFound,
-			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+			expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found",
 		},
 		{
 			desc:     "basic auth",
@@ -234,11 +234,11 @@ func TestNewDNSProvider_Cleanup(t *testing.T) {
 			t.Parallel()
 
 			mux := http.NewServeMux()
-			mux.HandleFunc("/cleanup", test.handler)
-
 			server := httptest.NewServer(mux)
 			t.Cleanup(server.Close)
 
+			mux.HandleFunc("/cleanup", test.handler)
+
 			config := NewDefaultConfig()
 			config.Endpoint = mustParse(server.URL)
 			config.Mode = test.mode
diff --git a/providers/dns/hurricane/internal/client.go b/providers/dns/hurricane/internal/client.go
index e5848112..bbc90758 100644
--- a/providers/dns/hurricane/internal/client.go
+++ b/providers/dns/hurricane/internal/client.go
@@ -12,6 +12,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	"golang.org/x/time/rate"
 )
 
@@ -59,7 +60,7 @@ func (c *Client) UpdateTxtRecord(ctx context.Context, hostname string, txt strin
 	c.credMu.Unlock()
 
 	if !ok {
-		return fmt.Errorf("hurricane: Domain %s not found in credentials, check your credentials map", domain)
+		return fmt.Errorf("domain %s not found in credentials, check your credentials map", domain)
 	}
 
 	data := url.Values{}
@@ -67,32 +68,37 @@ func (c *Client) UpdateTxtRecord(ctx context.Context, hostname string, txt strin
 	data.Set("hostname", hostname)
 	data.Set("txt", txt)
 
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(data.Encode()))
+	if err != nil {
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
 	rl, _ := c.rateLimiters.LoadOrStore(hostname, rate.NewLimiter(limit(defaultBurst), defaultBurst))
 
-	err := rl.(*rate.Limiter).Wait(ctx)
+	err = rl.(*rate.Limiter).Wait(ctx)
 	if err != nil {
 		return err
 	}
 
-	resp, err := c.HTTPClient.PostForm(c.baseURL, data)
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
-	bodyBytes, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-
-	body := string(bytes.TrimSpace(bodyBytes))
-
 	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("%d: attempt to change TXT record %s returned %s", resp.StatusCode, hostname, body)
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	return evaluateBody(body, hostname)
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	return evaluateBody(string(bytes.TrimSpace(raw)), hostname)
 }
 
 func evaluateBody(body string, hostname string) error {
diff --git a/providers/dns/hurricane/internal/client_test.go b/providers/dns/hurricane/internal/client_test.go
index f68d9b73..16d4f60f 100644
--- a/providers/dns/hurricane/internal/client_test.go
+++ b/providers/dns/hurricane/internal/client_test.go
@@ -74,6 +74,7 @@ func TestClient_UpdateTxtRecord(t *testing.T) {
 
 			client := NewClient(map[string]string{"example.com": "secret"})
 			client.baseURL = server.URL
+			client.HTTPClient = server.Client()
 
 			err := client.UpdateTxtRecord(context.Background(), "_acme-challenge.example.com", "foo")
 			test.expected(t, err)
diff --git a/providers/dns/hyperone/hyperone.go b/providers/dns/hyperone/hyperone.go
index c5fcdc0c..5e23c0a3 100644
--- a/providers/dns/hyperone/hyperone.go
+++ b/providers/dns/hyperone/hyperone.go
@@ -2,6 +2,7 @@
 package hyperone
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"os"
@@ -105,18 +106,20 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.getHostedZone(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", info.EffectiveFQDN, err)
 	}
 
-	recordset, err := d.client.FindRecordset(zone.ID, "TXT", info.EffectiveFQDN)
+	recordset, err := d.client.FindRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", info.EffectiveFQDN, zone.ID, err)
 	}
 
 	if recordset == nil {
-		_, err = d.client.CreateRecordset(zone.ID, "TXT", info.EffectiveFQDN, info.Value, d.config.TTL)
+		_, err = d.client.CreateRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN, info.Value, d.config.TTL)
 		if err != nil {
 			return fmt.Errorf("hyperone: failed to create recordset: fqdn=%s, zone ID=%s, value=%s: %w", info.EffectiveFQDN, zone.ID, info.Value, err)
 		}
@@ -124,7 +127,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return nil
 	}
 
-	_, err = d.client.CreateRecord(zone.ID, recordset.ID, info.Value)
+	_, err = d.client.CreateRecord(ctx, zone.ID, recordset.ID, info.Value)
 	if err != nil {
 		return fmt.Errorf("hyperone: failed to create record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", info.EffectiveFQDN, zone.ID, recordset.ID, err)
 	}
@@ -137,12 +140,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.getHostedZone(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", info.EffectiveFQDN, err)
 	}
 
-	recordset, err := d.client.FindRecordset(zone.ID, "TXT", info.EffectiveFQDN)
+	recordset, err := d.client.FindRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", info.EffectiveFQDN, zone.ID, err)
 	}
@@ -151,7 +156,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 		return fmt.Errorf("hyperone: recordset to remove not found: fqdn=%s", info.EffectiveFQDN)
 	}
 
-	records, err := d.client.GetRecords(zone.ID, recordset.ID)
+	records, err := d.client.GetRecords(ctx, zone.ID, recordset.ID)
 	if err != nil {
 		return fmt.Errorf("hyperone: %w", err)
 	}
@@ -160,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 			return fmt.Errorf("hyperone: record with content %s not found: fqdn=%s", info.Value, info.EffectiveFQDN)
 		}
 
-		err = d.client.DeleteRecordset(zone.ID, recordset.ID)
+		err = d.client.DeleteRecordset(ctx, zone.ID, recordset.ID)
 		if err != nil {
 			return fmt.Errorf("hyperone: failed to delete record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", info.EffectiveFQDN, zone.ID, recordset.ID, err)
 		}
@@ -170,7 +175,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 
 	for _, record := range records {
 		if record.Content == info.Value {
-			err = d.client.DeleteRecord(zone.ID, recordset.ID, record.ID)
+			err = d.client.DeleteRecord(ctx, zone.ID, recordset.ID, record.ID)
 			if err != nil {
 				return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s, recordset ID=%s, record ID=%s: %w", info.EffectiveFQDN, zone.ID, recordset.ID, record.ID, err)
 			}
@@ -183,13 +188,13 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 }
 
 // getHostedZone gets the hosted zone.
-func (d *DNSProvider) getHostedZone(fqdn string) (*internal.Zone, error) {
+func (d *DNSProvider) getHostedZone(ctx context.Context, fqdn string) (*internal.Zone, error) {
 	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("hetzner: could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
-	return d.client.FindZone(authZone)
+	return d.client.FindZone(ctx, authZone)
 }
 
 func GetDefaultPassportLocation() (string, error) {
diff --git a/providers/dns/hyperone/internal/client.go b/providers/dns/hyperone/internal/client.go
index 1231d9bc..09fa6876 100644
--- a/providers/dns/hyperone/internal/client.go
+++ b/providers/dns/hyperone/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -9,6 +10,8 @@ import (
 	"net/http"
 	"net/url"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://api.hyperone.com/v2"
@@ -21,12 +24,11 @@ type signer interface {
 
 // Client the HyperOne client.
 type Client struct {
-	HTTPClient *http.Client
-
-	apiEndpoint *url.URL
-
 	passport *Passport
 	signer   signer
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient Creates a new HyperOne client.
@@ -62,10 +64,10 @@ func NewClient(apiEndpoint, locationID string, passport *Passport) (*Client, err
 	}
 
 	client := &Client{
-		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
-		apiEndpoint: baseURL.JoinPath("dns", locationID, "project", projectID),
-		passport:    passport,
-		signer:      tokenSigner,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+		baseURL:    baseURL.JoinPath("dns", locationID, "project", projectID),
+		passport:   passport,
+		signer:     tokenSigner,
 	}
 
 	return client, nil
@@ -74,11 +76,11 @@ func NewClient(apiEndpoint, locationID string, passport *Passport) (*Client, err
 // FindRecordset looks for recordset with given recordType and name and returns it.
 // In case if recordset is not found returns nil.
 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_list
-func (c *Client) FindRecordset(zoneID, recordType, name string) (*Recordset, error) {
+func (c *Client) FindRecordset(ctx context.Context, zoneID, recordType, name string) (*Recordset, error) {
 	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
-	endpoint := c.apiEndpoint.JoinPath("zone", zoneID, "recordset")
+	endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset")
 
-	req, err := c.createRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -102,7 +104,10 @@ func (c *Client) FindRecordset(zoneID, recordType, name string) (*Recordset, err
 
 // CreateRecordset creates recordset and record with given value within one request.
 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_create
-func (c *Client) CreateRecordset(zoneID, recordType, name, recordValue string, ttl int) (*Recordset, error) {
+func (c *Client) CreateRecordset(ctx context.Context, zoneID, recordType, name, recordValue string, ttl int) (*Recordset, error) {
+	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
+	endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset")
+
 	recordsetInput := Recordset{
 		RecordType: recordType,
 		Name:       name,
@@ -110,15 +115,7 @@ func (c *Client) CreateRecordset(zoneID, recordType, name, recordValue string, t
 		Record:     &Record{Content: recordValue},
 	}
 
-	requestBody, err := json.Marshal(recordsetInput)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshal recordset: %w", err)
-	}
-
-	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
-	endpoint := c.apiEndpoint.JoinPath("zone", zoneID, "recordset")
-
-	req, err := c.createRequest(http.MethodPost, endpoint.String(), bytes.NewBuffer(requestBody))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, recordsetInput)
 	if err != nil {
 		return nil, err
 	}
@@ -135,11 +132,11 @@ func (c *Client) CreateRecordset(zoneID, recordType, name, recordValue string, t
 
 // DeleteRecordset deletes a recordset.
 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_delete
-func (c *Client) DeleteRecordset(zoneID string, recordsetID string) error {
+func (c *Client) DeleteRecordset(ctx context.Context, zoneID string, recordsetID string) error {
 	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}
-	endpoint := c.apiEndpoint.JoinPath("zone", zoneID, "recordset", recordsetID)
+	endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID)
 
-	req, err := c.createRequest(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
@@ -149,11 +146,11 @@ func (c *Client) DeleteRecordset(zoneID string, recordsetID string) error {
 
 // GetRecords gets all records within specified recordset.
 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_list
-func (c *Client) GetRecords(zoneID string, recordsetID string) ([]Record, error) {
+func (c *Client) GetRecords(ctx context.Context, zoneID string, recordsetID string) ([]Record, error) {
 	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
-	endpoint := c.apiEndpoint.JoinPath("zone", zoneID, "recordset", recordsetID, "record")
+	endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record")
 
-	req, err := c.createRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -170,16 +167,11 @@ func (c *Client) GetRecords(zoneID string, recordsetID string) ([]Record, error)
 
 // CreateRecord creates a record.
 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_create
-func (c *Client) CreateRecord(zoneID, recordsetID, recordContent string) (*Record, error) {
+func (c *Client) CreateRecord(ctx context.Context, zoneID, recordsetID, recordContent string) (*Record, error) {
 	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
-	endpoint := c.apiEndpoint.JoinPath("zone", zoneID, "recordset", recordsetID, "record")
+	endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record")
 
-	requestBody, err := json.Marshal(Record{Content: recordContent})
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshal record: %w", err)
-	}
-
-	req, err := c.createRequest(http.MethodPost, endpoint.String(), bytes.NewBuffer(requestBody))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, Record{Content: recordContent})
 	if err != nil {
 		return nil, err
 	}
@@ -196,11 +188,11 @@ func (c *Client) CreateRecord(zoneID, recordsetID, recordContent string) (*Recor
 
 // DeleteRecord deletes a record.
 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_delete
-func (c *Client) DeleteRecord(zoneID, recordsetID, recordID string) error {
+func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordsetID, recordID string) error {
 	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record/{recordId}
-	endpoint := c.apiEndpoint.JoinPath("zone", zoneID, "recordset", recordsetID, "record", recordID)
+	endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record", recordID)
 
-	req, err := c.createRequest(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
@@ -209,8 +201,8 @@ func (c *Client) DeleteRecord(zoneID, recordsetID, recordID string) error {
 }
 
 // FindZone looks for DNS Zone and returns nil if it does not exist.
-func (c *Client) FindZone(name string) (*Zone, error) {
-	zones, err := c.GetZones()
+func (c *Client) FindZone(ctx context.Context, name string) (*Zone, error) {
+	zones, err := c.GetZones(ctx)
 	if err != nil {
 		return nil, err
 	}
@@ -226,11 +218,11 @@ func (c *Client) FindZone(name string) (*Zone, error) {
 
 // GetZones gets all user's zones.
 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_list
-func (c *Client) GetZones() ([]Zone, error) {
+func (c *Client) GetZones(ctx context.Context) ([]Zone, error) {
 	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone
-	endpoint := c.apiEndpoint.JoinPath("zone")
+	endpoint := c.baseURL.JoinPath("zone")
 
-	req, err := c.createRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -245,69 +237,72 @@ func (c *Client) GetZones() ([]Zone, error) {
 	return zones, nil
 }
 
-func (c *Client) createRequest(method, endpoint string, body io.Reader) (*http.Request, error) {
-	req, err := http.NewRequest(method, endpoint, body)
-	if err != nil {
-		return nil, err
-	}
-
+func (c *Client) do(req *http.Request, result any) error {
 	jwt, err := c.signer.GetJWT()
 	if err != nil {
-		return nil, fmt.Errorf("failed to sign the request: %w", err)
+		return fmt.Errorf("failed to sign the request: %w", err)
 	}
 
 	req.Header.Set("Authorization", "Bearer "+jwt)
-	req.Header.Set("Content-Type", "application/json")
 
-	return req, nil
-}
-
-func (c *Client) do(req *http.Request, v interface{}) error {
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
-	err = checkResponse(resp)
-	if err != nil {
-		return err
+	if resp.StatusCode/100 != 2 {
+		return parseError(req, resp)
 	}
 
-	if v == nil {
+	if result == nil {
 		return nil
 	}
 
 	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return fmt.Errorf("failed to read body: %w", err)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	if err = json.Unmarshal(raw, v); err != nil {
-		return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
+	if err = json.Unmarshal(raw, result); err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
 }
 
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode/100 == 2 {
-		return nil
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
 	}
 
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
 	var msg string
 	if resp.StatusCode == http.StatusForbidden {
 		msg = "forbidden: check if service account you are trying to use has permissions required for managing DNS"
 	} else {
-		msg = fmt.Sprintf("%d: unknown error", resp.StatusCode)
+		msg = "unknown error"
 	}
 
-	// add response body to error message if not empty
-	responseBody, _ := io.ReadAll(resp.Body)
-	if len(responseBody) > 0 {
-		msg = fmt.Sprintf("%s: %s", msg, string(responseBody))
-	}
-
-	return errors.New(msg)
+	return fmt.Errorf("%s: %w", msg, errutils.NewUnexpectedResponseStatusCodeError(req, resp))
 }
diff --git a/providers/dns/hyperone/internal/client_test.go b/providers/dns/hyperone/internal/client_test.go
index 2f503094..e3a1073e 100644
--- a/providers/dns/hyperone/internal/client_test.go
+++ b/providers/dns/hyperone/internal/client_test.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -24,7 +25,7 @@ func (s signerMock) GetJWT() (string, error) {
 func TestClient_FindRecordset(t *testing.T) {
 	client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/zone321/recordset", respFromFile("recordset.json"))
 
-	recordset, err := client.FindRecordset("zone321", "SOA", "example.com.")
+	recordset, err := client.FindRecordset(context.Background(), "zone321", "SOA", "example.com.")
 	require.NoError(t, err)
 
 	expected := &Recordset{
@@ -48,7 +49,7 @@ func TestClient_CreateRecordset(t *testing.T) {
 	client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/zone123/recordset",
 		hasReqBody(expectedReqBody), respFromFile("createRecordset.json"))
 
-	rs, err := client.CreateRecordset("zone123", "TXT", "test.example.com.", "value", 3600)
+	rs, err := client.CreateRecordset(context.Background(), "zone123", "TXT", "test.example.com.", "value", 3600)
 	require.NoError(t, err)
 
 	expected := &Recordset{RecordType: "TXT", Name: "test.example.com.", TTL: 3600, ID: "1234567890qwertyuiop"}
@@ -58,14 +59,14 @@ func TestClient_CreateRecordset(t *testing.T) {
 func TestClient_DeleteRecordset(t *testing.T) {
 	client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/zone321/recordset/rs322")
 
-	err := client.DeleteRecordset("zone321", "rs322")
+	err := client.DeleteRecordset(context.Background(), "zone321", "rs322")
 	require.NoError(t, err)
 }
 
 func TestClient_GetRecords(t *testing.T) {
 	client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/321/recordset/322/record", respFromFile("record.json"))
 
-	records, err := client.GetRecords("321", "322")
+	records, err := client.GetRecords(context.Background(), "321", "322")
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -87,7 +88,7 @@ func TestClient_CreateRecord(t *testing.T) {
 	client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/z123/recordset/rs325/record",
 		hasReqBody(expectedReqBody), respFromFile("createRecord.json"))
 
-	rs, err := client.CreateRecord("z123", "rs325", "value")
+	rs, err := client.CreateRecord(context.Background(), "z123", "rs325", "value")
 	require.NoError(t, err)
 
 	expected := &Record{ID: "123321qwerqwewqerq", Content: "value", Enabled: true}
@@ -97,14 +98,14 @@ func TestClient_CreateRecord(t *testing.T) {
 func TestClient_DeleteRecord(t *testing.T) {
 	client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/321/recordset/322/record/323")
 
-	err := client.DeleteRecord("321", "322", "323")
+	err := client.DeleteRecord(context.Background(), "321", "322", "323")
 	require.NoError(t, err)
 }
 
 func TestClient_FindZone(t *testing.T) {
 	client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json"))
 
-	zone, err := client.FindZone("example.com")
+	zone, err := client.FindZone(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	expected := &Zone{
@@ -121,7 +122,7 @@ func TestClient_FindZone(t *testing.T) {
 func TestClient_GetZones(t *testing.T) {
 	client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json"))
 
-	zones, err := client.GetZones()
+	zones, err := client.GetZones(context.Background())
 	require.NoError(t, err)
 
 	expected := []Zone{
@@ -194,7 +195,7 @@ func hasReqBody(v interface{}) assertHandler {
 			return http.StatusInternalServerError, err
 		}
 
-		if !bytes.Equal(marshal, reqBody) {
+		if !bytes.Equal(marshal, bytes.TrimSpace(reqBody)) {
 			return http.StatusBadRequest, fmt.Errorf("invalid request body, got: %s, expect: %s", string(reqBody), string(marshal))
 		}
 
diff --git a/providers/dns/hyperone/internal/models.go b/providers/dns/hyperone/internal/types.go
similarity index 100%
rename from providers/dns/hyperone/internal/models.go
rename to providers/dns/hyperone/internal/types.go
diff --git a/providers/dns/iij/iij.go b/providers/dns/iij/iij.go
index 48838172..ed5b8770 100644
--- a/providers/dns/iij/iij.go
+++ b/providers/dns/iij/iij.go
@@ -231,7 +231,7 @@ func splitDomain(domain string, zones []string) (string, string, error) {
 		zone = strings.Join(parts[i:], ".")
 		if zoneContains(zone, zones) {
 			baseOwner := strings.Join(parts[0:i], ".")
-			if len(baseOwner) > 0 {
+			if baseOwner != "" {
 				baseOwner = "." + baseOwner
 			}
 			owner = "_acme-challenge" + baseOwner
diff --git a/providers/dns/iijdpf/client.go b/providers/dns/iijdpf/wrapper.go
similarity index 100%
rename from providers/dns/iijdpf/client.go
rename to providers/dns/iijdpf/wrapper.go
diff --git a/providers/dns/infomaniak/infomaniak.go b/providers/dns/infomaniak/infomaniak.go
index eafba97c..53d98c4f 100644
--- a/providers/dns/infomaniak/infomaniak.go
+++ b/providers/dns/infomaniak/infomaniak.go
@@ -2,6 +2,7 @@
 package infomaniak
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -29,8 +30,6 @@ const (
 	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
 )
 
-const defaultBaseURL = "https://api.infomaniak.com"
-
 // Config is used to configure the creation of the DNSProvider.
 type Config struct {
 	APIEndpoint        string
@@ -44,7 +43,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		APIEndpoint:        env.GetOrDefaultString(EnvEndpoint, defaultBaseURL),
+		APIEndpoint:        env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL),
 		TTL:                env.GetOrDefaultInt(EnvTTL, 7200),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
@@ -94,10 +93,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("infomaniak: missing access token")
 	}
 
-	client := internal.New(config.APIEndpoint, config.AccessToken)
-
-	if config.HTTPClient != nil {
-		client.HTTPClient = config.HTTPClient
+	client, err := internal.New(internal.OAuthStaticAccessToken(config.HTTPClient, config.AccessToken), config.APIEndpoint)
+	if err != nil {
+		return nil, fmt.Errorf("infomaniak: %w", err)
 	}
 
 	return &DNSProvider{
@@ -112,7 +110,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	ikDomain, err := d.client.GetDomainByName(dns01.UnFqdn(info.EffectiveFQDN))
+	ctx := context.Background()
+
+	ikDomain, err := d.client.GetDomainByName(ctx, dns01.UnFqdn(info.EffectiveFQDN))
 	if err != nil {
 		return fmt.Errorf("infomaniak: could not get domain %q: %w", info.EffectiveFQDN, err)
 	}
@@ -133,7 +133,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:    d.config.TTL,
 	}
 
-	recordID, err := d.client.CreateDNSRecord(ikDomain, record)
+	recordID, err := d.client.CreateDNSRecord(ctx, ikDomain, record)
 	if err != nil {
 		return fmt.Errorf("infomaniak: error when calling api to create DNS record: %w", err)
 	}
@@ -165,7 +165,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("infomaniak: unknown domain ID for '%s'", info.EffectiveFQDN)
 	}
 
-	err := d.client.DeleteDNSRecord(domainID, recordID)
+	err := d.client.DeleteDNSRecord(context.Background(), domainID, recordID)
 	if err != nil {
 		return fmt.Errorf("infomaniak: could not delete record %q: %w", dns01.UnFqdn(info.EffectiveFQDN), err)
 	}
diff --git a/providers/dns/infomaniak/internal/client.go b/providers/dns/infomaniak/internal/client.go
index cb27cfb6..886a8966 100644
--- a/providers/dns/infomaniak/internal/client.go
+++ b/providers/dns/infomaniak/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -13,71 +14,63 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/log"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/oauth2"
 )
 
+// DefaultBaseURL Default API endpoint.
+const DefaultBaseURL = "https://api.infomaniak.com"
+
 // Client the Infomaniak client.
 type Client struct {
-	apiEndpoint string
-	apiToken    string
-	HTTPClient  *http.Client
+	baseURL    *url.URL
+	httpClient *http.Client
 }
 
 // New Creates a new Infomaniak client.
-func New(apiEndpoint, apiToken string) *Client {
-	return &Client{
-		apiEndpoint: apiEndpoint,
-		apiToken:    apiToken,
-		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
+func New(hc *http.Client, apiEndpoint string) (*Client, error) {
+	baseURL, err := url.Parse(apiEndpoint)
+	if err != nil {
+		return nil, err
 	}
+
+	if hc == nil {
+		hc = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	return &Client{baseURL: baseURL, httpClient: hc}, nil
 }
 
-func (c *Client) CreateDNSRecord(domain *DNSDomain, record Record) (string, error) {
-	rawJSON, err := json.Marshal(record)
-	if err != nil {
-		return "", err
-	}
+func (c *Client) CreateDNSRecord(ctx context.Context, domain *DNSDomain, record Record) (string, error) {
+	endpoint := c.baseURL.JoinPath("1", "domain", strconv.FormatUint(domain.ID, 10), "dns", "record")
 
-	endpoint, err := url.JoinPath(c.apiEndpoint, "1", "domain", strconv.FormatUint(domain.ID, 10), "dns", "record")
-	if err != nil {
-		return "", err
-	}
-
-	req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(rawJSON))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return "", fmt.Errorf("failed to create request: %w", err)
 	}
 
-	resp, err := c.do(req)
+	result := APIResponse[string]{}
+	err = c.do(req, &result)
 	if err != nil {
 		return "", err
 	}
 
-	var recordID string
-	if err = json.Unmarshal(resp.Data, &recordID); err != nil {
-		return "", fmt.Errorf("expected record, got: %s", string(resp.Data))
-	}
-
-	return recordID, err
+	return result.Data, err
 }
 
-func (c *Client) DeleteDNSRecord(domainID uint64, recordID string) error {
-	endpoint, err := url.JoinPath(c.apiEndpoint, "1", "domain", strconv.FormatUint(domainID, 10), "dns", "record", recordID)
-	if err != nil {
-		return err
-	}
+func (c *Client) DeleteDNSRecord(ctx context.Context, domainID uint64, recordID string) error {
+	endpoint := c.baseURL.JoinPath("1", "domain", strconv.FormatUint(domainID, 10), "dns", "record", recordID)
 
-	req, err := http.NewRequest(http.MethodDelete, endpoint, http.NoBody)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return fmt.Errorf("failed to create request: %w", err)
 	}
 
-	_, err = c.do(req)
-
-	return err
+	return c.do(req, &APIResponse[json.RawMessage]{})
 }
 
 // GetDomainByName gets a Domain object from its name.
-func (c *Client) GetDomainByName(name string) (*DNSDomain, error) {
+func (c *Client) GetDomainByName(ctx context.Context, name string) (*DNSDomain, error) {
 	name = dns01.UnFqdn(name)
 
 	// Try to find the most specific domain
@@ -88,7 +81,7 @@ func (c *Client) GetDomainByName(name string) (*DNSDomain, error) {
 			break
 		}
 
-		domain, err := c.getDomainByName(name)
+		domain, err := c.getDomainByName(ctx, name)
 		if err != nil {
 			return nil, err
 		}
@@ -105,35 +98,26 @@ func (c *Client) GetDomainByName(name string) (*DNSDomain, error) {
 	return nil, fmt.Errorf("domain not found %s", name)
 }
 
-func (c *Client) getDomainByName(name string) (*DNSDomain, error) {
-	baseURL, err := url.Parse(c.apiEndpoint)
-	if err != nil {
-		return nil, err
-	}
-
-	endpoint := baseURL.JoinPath("1", "product")
+func (c *Client) getDomainByName(ctx context.Context, name string) (*DNSDomain, error) {
+	endpoint := c.baseURL.JoinPath("1", "product")
 
 	query := endpoint.Query()
 	query.Add("service_name", "domain")
 	query.Add("customer_name", name)
 	endpoint.RawQuery = query.Encode()
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), http.NoBody)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	resp, err := c.do(req)
+	result := APIResponse[[]DNSDomain]{}
+	err = c.do(req, &result)
 	if err != nil {
 		return nil, err
 	}
 
-	var domains []DNSDomain
-	if err = json.Unmarshal(resp.Data, &domains); err != nil {
-		return nil, fmt.Errorf("failed to marshal domains: %s", string(resp.Data))
-	}
-
-	for _, domain := range domains {
+	for _, domain := range result.Data {
 		if domain.CustomerName == name {
 			return &domain, nil
 		}
@@ -142,30 +126,63 @@ func (c *Client) getDomainByName(name string) (*DNSDomain, error) {
 	return nil, nil
 }
 
-func (c *Client) do(req *http.Request) (*APIResponse, error) {
-	req.Header.Set("Authorization", "Bearer "+c.apiToken)
-	req.Header.Set("Content-Type", "application/json")
-
-	rawResp, err := c.HTTPClient.Do(req)
+func (c *Client) do(req *http.Request, result Response) error {
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("failed to perform API request: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	defer func() { _ = rawResp.Body.Close() }()
+	defer func() { _ = resp.Body.Close() }()
 
-	content, err := io.ReadAll(rawResp.Body)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read the response body, status code: %d", rawResp.StatusCode)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	var resp APIResponse
-	if err := json.Unmarshal(content, &resp); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal the response body: %s, %w", string(content), err)
+	if err := json.Unmarshal(raw, result); err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
-	if resp.Result != "success" {
-		return nil, fmt.Errorf("%d: unexpected API result (%s): %w", rawResp.StatusCode, resp.Result, resp.ErrResponse)
+	if result.GetResult() != "success" {
+		return fmt.Errorf("%d: unexpected API result (%s): %w", resp.StatusCode, result.GetResult(), result.GetError())
 	}
 
-	return &resp, nil
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
 }
diff --git a/providers/dns/infomaniak/internal/client_test.go b/providers/dns/infomaniak/internal/client_test.go
index 7dd8c9e2..4fadaf0f 100644
--- a/providers/dns/infomaniak/internal/client_test.go
+++ b/providers/dns/infomaniak/internal/client_test.go
@@ -1,6 +1,8 @@
 package internal
 
 import (
+	"bytes"
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -18,7 +20,10 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	return New(server.URL, "token"), mux
+	client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL)
+	require.NoError(t, err)
+
+	return client, mux
 }
 
 func TestClient_CreateDNSRecord(t *testing.T) {
@@ -42,7 +47,7 @@ func TestClient_CreateDNSRecord(t *testing.T) {
 		}
 		defer func() { _ = req.Body.Close() }()
 
-		if string(raw) != `{"source":"foo","type":"TXT","ttl":60,"target":"txtxtxttxt"}` {
+		if string(bytes.TrimSpace(raw)) != `{"source":"foo","type":"TXT","ttl":60,"target":"txtxtxttxt"}` {
 			http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
 			return
 		}
@@ -68,7 +73,7 @@ func TestClient_CreateDNSRecord(t *testing.T) {
 		TTL:    60,
 	}
 
-	recordID, err := client.CreateDNSRecord(domain, record)
+	recordID, err := client.CreateDNSRecord(context.Background(), domain, record)
 	require.NoError(t, err)
 
 	assert.Equal(t, "123", recordID)
@@ -95,7 +100,6 @@ func TestClient_GetDomainByName(t *testing.T) {
 		}
 
 		customerName := req.URL.Query().Get("customer_name")
-		fmt.Println("customerName", customerName)
 		if customerName == "" {
 			http.Error(rw, fmt.Sprintf("invalid customer_name: %s", customerName), http.StatusBadRequest)
 			return
@@ -124,7 +128,7 @@ func TestClient_GetDomainByName(t *testing.T) {
 		}
 	})
 
-	domain, err := client.GetDomainByName("one.two.three.example.com.")
+	domain, err := client.GetDomainByName(context.Background(), "one.two.three.example.com.")
 	require.NoError(t, err)
 
 	expected := &DNSDomain{ID: 123, CustomerName: "two.three.example.com"}
@@ -152,6 +156,6 @@ func TestClient_DeleteDNSRecord(t *testing.T) {
 		}
 	})
 
-	err := client.DeleteDNSRecord(123, "456")
+	err := client.DeleteDNSRecord(context.Background(), 123, "456")
 	require.NoError(t, err)
 }
diff --git a/providers/dns/infomaniak/internal/models.go b/providers/dns/infomaniak/internal/types.go
similarity index 74%
rename from providers/dns/infomaniak/internal/models.go
rename to providers/dns/infomaniak/internal/types.go
index 7056354d..059bc9e9 100644
--- a/providers/dns/infomaniak/internal/models.go
+++ b/providers/dns/infomaniak/internal/types.go
@@ -1,7 +1,6 @@
 package internal
 
 import (
-	"encoding/json"
 	"fmt"
 )
 
@@ -19,12 +18,25 @@ type DNSDomain struct {
 	CustomerName string `json:"customer_name,omitempty"`
 }
 
-type APIResponse struct {
+type Response interface {
+	GetResult() string
+	GetError() *APIErrorResponse
+}
+
+type APIResponse[T any] struct {
 	Result      string            `json:"result"`
-	Data        json.RawMessage   `json:"data,omitempty"`
+	Data        T                 `json:"data,omitempty"`
 	ErrResponse *APIErrorResponse `json:"error,omitempty"`
 }
 
+func (a APIResponse[T]) GetResult() string {
+	return a.Result
+}
+
+func (a APIResponse[T]) GetError() *APIErrorResponse {
+	return a.ErrResponse
+}
+
 type APIErrorResponse struct {
 	Code        string             `json:"code"`
 	Description string             `json:"description,omitempty"`
diff --git a/providers/dns/internal/errutils/client.go b/providers/dns/internal/errutils/client.go
new file mode 100644
index 00000000..09f1344b
--- /dev/null
+++ b/providers/dns/internal/errutils/client.go
@@ -0,0 +1,133 @@
+package errutils
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"strconv"
+)
+
+const legoDebugClientVerboseError = "LEGO_DEBUG_CLIENT_VERBOSE_ERROR"
+
+// HTTPDoError uses with `(http.Client).Do` error.
+type HTTPDoError struct {
+	req *http.Request
+	err error
+}
+
+// NewHTTPDoError creates a new HTTPDoError.
+func NewHTTPDoError(req *http.Request, err error) *HTTPDoError {
+	return &HTTPDoError{req: req, err: err}
+}
+
+func (h HTTPDoError) Error() string {
+	msg := "unable to communicate with the API server:"
+
+	if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok {
+		msg += fmt.Sprintf(" [request: %s %s]", h.req.Method, h.req.URL)
+	}
+
+	if h.err == nil {
+		return msg
+	}
+
+	return msg + fmt.Sprintf(" error: %v", h.err)
+}
+
+func (h HTTPDoError) Unwrap() error {
+	return h.err
+}
+
+// ReadResponseError use with `io.ReadAll` when reading response body.
+type ReadResponseError struct {
+	req        *http.Request
+	StatusCode int
+	err        error
+}
+
+// NewReadResponseError creates a new ReadResponseError.
+func NewReadResponseError(req *http.Request, statusCode int, err error) *ReadResponseError {
+	return &ReadResponseError{req: req, StatusCode: statusCode, err: err}
+}
+
+func (r ReadResponseError) Error() string {
+	msg := "unable to read response body:"
+
+	if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok {
+		msg += fmt.Sprintf(" [request: %s %s]", r.req.Method, r.req.URL)
+	}
+
+	msg += fmt.Sprintf(" [status code: %d]", r.StatusCode)
+
+	if r.err == nil {
+		return msg
+	}
+
+	return msg + fmt.Sprintf(" error: %v", r.err)
+}
+
+func (r ReadResponseError) Unwrap() error {
+	return r.err
+}
+
+// UnmarshalError uses with `json.Unmarshal` or `xml.Unmarshal` when reading response body.
+type UnmarshalError struct {
+	req        *http.Request
+	StatusCode int
+	Body       []byte
+	err        error
+}
+
+// NewUnmarshalError creates a new UnmarshalError.
+func NewUnmarshalError(req *http.Request, statusCode int, body []byte, err error) *UnmarshalError {
+	return &UnmarshalError{req: req, StatusCode: statusCode, Body: bytes.TrimSpace(body), err: err}
+}
+
+func (u UnmarshalError) Error() string {
+	msg := "unable to unmarshal response:"
+
+	if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok {
+		msg += fmt.Sprintf(" [request: %s %s]", u.req.Method, u.req.URL)
+	}
+
+	msg += fmt.Sprintf(" [status code: %d] body: %s", u.StatusCode, string(u.Body))
+
+	if u.err == nil {
+		return msg
+	}
+
+	return msg + fmt.Sprintf(" error: %v", u.err)
+}
+
+func (u UnmarshalError) Unwrap() error {
+	return u.err
+}
+
+// UnexpectedStatusCodeError use when the status of the response is unexpected but there is no API error type.
+type UnexpectedStatusCodeError struct {
+	req        *http.Request
+	StatusCode int
+	Body       []byte
+}
+
+// NewUnexpectedStatusCodeError creates a new UnexpectedStatusCodeError.
+func NewUnexpectedStatusCodeError(req *http.Request, statusCode int, body []byte) *UnexpectedStatusCodeError {
+	return &UnexpectedStatusCodeError{req: req, StatusCode: statusCode, Body: bytes.TrimSpace(body)}
+}
+
+func NewUnexpectedResponseStatusCodeError(req *http.Request, resp *http.Response) *UnexpectedStatusCodeError {
+	raw, _ := io.ReadAll(resp.Body)
+	return &UnexpectedStatusCodeError{req: req, StatusCode: resp.StatusCode, Body: bytes.TrimSpace(raw)}
+}
+
+func (u UnexpectedStatusCodeError) Error() string {
+	msg := "unexpected status code:"
+
+	if ok, _ := strconv.ParseBool(os.Getenv(legoDebugClientVerboseError)); ok {
+		msg += fmt.Sprintf(" [request: %s %s]", u.req.Method, u.req.URL)
+	}
+
+	return msg + fmt.Sprintf(" [status code: %d] body: %s", u.StatusCode, string(u.Body))
+}
diff --git a/providers/dns/internal/rimuhosting/client.go b/providers/dns/internal/rimuhosting/client.go
index c4f67ed5..4976f378 100644
--- a/providers/dns/internal/rimuhosting/client.go
+++ b/providers/dns/internal/rimuhosting/client.go
@@ -1,13 +1,17 @@
 package rimuhosting
 
 import (
+	"context"
 	"encoding/xml"
 	"errors"
+	"fmt"
 	"io"
 	"net/http"
 	"net/url"
 	"regexp"
+	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	querystring "github.com/google/go-querystring/query"
 )
 
@@ -35,9 +39,9 @@ type Client struct {
 // NewClient Creates a RimuHosting/Zonomi client.
 func NewClient(apiKey string) *Client {
 	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    DefaultZonomiBaseURL,
 		apiKey:     apiKey,
+		BaseURL:    DefaultZonomiBaseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
@@ -45,14 +49,14 @@ func NewClient(apiKey string) *Client {
 // ex:
 // - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=example.com&api_key=apikeyvaluehere
 // - https://zonomi.com/app/dns/dyndns.jsp?action=QUERY&name=**.example.com&api_key=apikeyvaluehere
-func (c Client) FindTXTRecords(domain string) ([]Record, error) {
+func (c Client) FindTXTRecords(ctx context.Context, domain string) ([]Record, error) {
 	action := ActionParameter{
 		Action: QueryAction,
 		Name:   domain,
 		Type:   "TXT",
 	}
 
-	resp, err := c.DoActions(action)
+	resp, err := c.DoActions(ctx, action)
 	if err != nil {
 		return nil, err
 	}
@@ -61,7 +65,7 @@ func (c Client) FindTXTRecords(domain string) ([]Record, error) {
 }
 
 // DoActions performs actions.
-func (c Client) DoActions(actions ...ActionParameter) (*DNSAPIResult, error) {
+func (c Client) DoActions(ctx context.Context, actions ...ActionParameter) (*DNSAPIResult, error) {
 	if len(actions) == 0 {
 		return nil, errors.New("no action")
 	}
@@ -74,7 +78,7 @@ func (c Client) DoActions(actions ...ActionParameter) (*DNSAPIResult, error) {
 			APIKey:          c.apiKey,
 		}
 
-		err := c.do(action, resp)
+		err := c.do(ctx, action, resp)
 		if err != nil {
 			return nil, err
 		}
@@ -82,7 +86,7 @@ func (c Client) DoActions(actions ...ActionParameter) (*DNSAPIResult, error) {
 	}
 
 	multi := c.toMultiParameters(actions)
-	err := c.do(multi, resp)
+	err := c.do(ctx, multi, resp)
 	if err != nil {
 		return nil, err
 	}
@@ -105,7 +109,7 @@ func (c Client) toMultiParameters(params []ActionParameter) multiActionParameter
 	return multi
 }
 
-func (c Client) do(params, data interface{}) error {
+func (c Client) do(ctx context.Context, params, result any) error {
 	baseURL, err := url.Parse(c.BaseURL)
 	if err != nil {
 		return err
@@ -117,47 +121,55 @@ func (c Client) do(params, data interface{}) error {
 	}
 
 	exp := regexp.MustCompile(`(%5B)(%5D)(\d+)=`)
-
 	baseURL.RawQuery = exp.ReplaceAllString(v.Encode(), "${1}${3}${2}=")
 
-	req, err := http.NewRequest(http.MethodGet, baseURL.String(), nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), http.NoBody)
 	if err != nil {
-		return err
+		return fmt.Errorf("unable to create request: %w", err)
 	}
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
-	all, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-
 	if resp.StatusCode/100 != 2 {
-		r := APIError{}
-		err = xml.Unmarshal(all, &r)
-		if err != nil {
-			return err
-		}
-		return r
+		return parseError(req, resp)
 	}
 
-	if data != nil {
-		err := xml.Unmarshal(all, data)
-		if err != nil {
-			return err
-		}
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = xml.Unmarshal(raw, result)
+	if err != nil {
+		return fmt.Errorf("unmarshaling %T error: %w: %s", result, err, string(raw))
 	}
 
 	return nil
 }
 
-// AddRecord helper to create an action to add a TXT record.
-func AddRecord(domain, content string, ttl int) ActionParameter {
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errAPI := APIError{}
+	err := xml.Unmarshal(raw, &errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return errAPI
+}
+
+// NewAddRecordAction helper to create an action to add a TXT record.
+func NewAddRecordAction(domain, content string, ttl int) ActionParameter {
 	return ActionParameter{
 		Action: SetAction,
 		Name:   domain,
@@ -167,8 +179,8 @@ func AddRecord(domain, content string, ttl int) ActionParameter {
 	}
 }
 
-// DeleteRecord helper to create an action to delete a TXT record.
-func DeleteRecord(domain, content string) ActionParameter {
+// NewDeleteRecordAction helper to create an action to delete a TXT record.
+func NewDeleteRecordAction(domain, content string) ActionParameter {
 	return ActionParameter{
 		Action: DeleteAction,
 		Name:   domain,
diff --git a/providers/dns/internal/rimuhosting/client_test.go b/providers/dns/internal/rimuhosting/client_test.go
index 76ba18d2..ecd55b0b 100644
--- a/providers/dns/internal/rimuhosting/client_test.go
+++ b/providers/dns/internal/rimuhosting/client_test.go
@@ -1,6 +1,7 @@
 package rimuhosting
 
 import (
+	"context"
 	"encoding/xml"
 	"fmt"
 	"io"
@@ -14,11 +15,23 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_FindTXTRecords(t *testing.T) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
+	client := NewClient("apikeyvaluehere")
+	client.BaseURL = server.URL
+	client.HTTPClient = server.Client()
+
+	return client, mux
+}
+
+func TestClient_FindTXTRecords(t *testing.T) {
+	client, mux := setupTest(t)
+
 	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
 		query := req.URL.Query()
 
@@ -39,9 +52,6 @@ func TestClient_FindTXTRecords(t *testing.T) {
 		}
 	})
 
-	client := NewClient("apikeyvaluehere")
-	client.BaseURL = server.URL
-
 	testCases := []struct {
 		desc     string
 		domain   string
@@ -89,7 +99,7 @@ func TestClient_FindTXTRecords(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			records, err := client.FindTXTRecords(test.domain)
+			records, err := client.FindTXTRecords(context.Background(), test.domain)
 			require.NoError(t, err)
 
 			assert.Equal(t, test.expected, records)
@@ -113,7 +123,7 @@ func TestClient_DoActions(t *testing.T) {
 		{
 			desc: "SET error",
 			actions: []ActionParameter{
-				AddRecord("example.com", "txttxtx", 0),
+				NewAddRecordAction("example.com", "txttxtx", 0),
 			},
 			fixture: "./fixtures/add_record_error.xml",
 			expected: expected{
@@ -124,7 +134,7 @@ func TestClient_DoActions(t *testing.T) {
 		{
 			desc: "SET simple",
 			actions: []ActionParameter{
-				AddRecord("example.org", "txttxtx", 0),
+				NewAddRecordAction("example.org", "txttxtx", 0),
 			},
 			fixture: "./fixtures/add_record.xml",
 			expected: expected{
@@ -153,8 +163,8 @@ func TestClient_DoActions(t *testing.T) {
 		{
 			desc: "SET multiple values",
 			actions: []ActionParameter{
-				AddRecord("example.org", "txttxtx", 0),
-				AddRecord("example.org", "sample", 0),
+				NewAddRecordAction("example.org", "txttxtx", 0),
+				NewAddRecordAction("example.org", "sample", 0),
 			},
 			fixture: "./fixtures/add_record_same_domain.xml",
 			expected: expected{
@@ -192,7 +202,7 @@ func TestClient_DoActions(t *testing.T) {
 		{
 			desc: "DELETE error",
 			actions: []ActionParameter{
-				DeleteRecord("example.com", "txttxtx"),
+				NewDeleteRecordAction("example.com", "txttxtx"),
 			},
 			fixture: "./fixtures/delete_record_error.xml",
 			expected: expected{
@@ -203,7 +213,7 @@ func TestClient_DoActions(t *testing.T) {
 		{
 			desc: "DELETE nothing",
 			actions: []ActionParameter{
-				DeleteRecord("example.org", "nothing"),
+				NewDeleteRecordAction("example.org", "nothing"),
 			},
 			fixture: "./fixtures/delete_record_nothing.xml",
 			expected: expected{
@@ -226,7 +236,7 @@ func TestClient_DoActions(t *testing.T) {
 		{
 			desc: "DELETE simple",
 			actions: []ActionParameter{
-				DeleteRecord("example.org", "txttxtx"),
+				NewDeleteRecordAction("example.org", "txttxtx"),
 			},
 			fixture: "./fixtures/delete_record.xml",
 			expected: expected{
@@ -256,9 +266,7 @@ func TestClient_DoActions(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			mux := http.NewServeMux()
-			server := httptest.NewServer(mux)
-			t.Cleanup(server.Close)
+			client, mux := setupTest(t)
 
 			mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
 				query, err := url.QueryUnescape(req.URL.RawQuery)
@@ -283,10 +291,7 @@ func TestClient_DoActions(t *testing.T) {
 				}
 			})
 
-			client := NewClient("apikeyvaluehere")
-			client.BaseURL = server.URL
-
-			resp, err := client.DoActions(test.actions...)
+			resp, err := client.DoActions(context.Background(), test.actions...)
 			if test.expected.Error != "" {
 				require.EqualError(t, err, test.expected.Error)
 				return
diff --git a/providers/dns/internal/rimuhosting/model.go b/providers/dns/internal/rimuhosting/types.go
similarity index 100%
rename from providers/dns/internal/rimuhosting/model.go
rename to providers/dns/internal/rimuhosting/types.go
diff --git a/providers/dns/internal/selectel/client.go b/providers/dns/internal/selectel/client.go
index 92e4746f..dcefa34b 100644
--- a/providers/dns/internal/selectel/client.go
+++ b/providers/dns/internal/selectel/client.go
@@ -2,11 +2,17 @@ package selectel
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"strconv"
 	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // Base URL for the Selectel/VScale DNS services.
@@ -15,39 +21,43 @@ const (
 	DefaultVScaleBaseURL   = "https://api.vscale.io/v1/domains"
 )
 
+const tokenHeader = "X-Token"
+
 // Client represents DNS client.
 type Client struct {
-	BaseURL    string
+	token string
+
+	BaseURL    *url.URL
 	HTTPClient *http.Client
-	token      string
 }
 
 // NewClient returns a client instance.
 func NewClient(token string) *Client {
+	baseURL, _ := url.Parse(DefaultVScaleBaseURL)
+
 	return &Client{
 		token:      token,
-		BaseURL:    DefaultVScaleBaseURL,
-		HTTPClient: &http.Client{},
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // GetDomainByName gets Domain object by its name. If `domainName` level > 2 and there is
 // no such domain on the account - it'll recursively search for the first
 // which is exists in Selectel Domain API.
-func (c *Client) GetDomainByName(domainName string) (*Domain, error) {
-	uri := fmt.Sprintf("/%s", domainName)
-	req, err := c.newRequest(http.MethodGet, uri, nil)
+func (c *Client) GetDomainByName(ctx context.Context, domainName string) (*Domain, error) {
+	req, err := newJSONRequest(ctx, http.MethodGet, c.BaseURL.JoinPath(domainName), nil)
 	if err != nil {
 		return nil, err
 	}
 
 	domain := &Domain{}
-	resp, err := c.do(req, domain)
+	statusCode, err := c.do(req, domain)
 	if err != nil {
-		if resp != nil && resp.StatusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1 {
+		if statusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1 {
 			// Look up for the next sub domain
 			subIndex := strings.Index(domainName, ".")
-			return c.GetDomainByName(domainName[subIndex+1:])
+			return c.GetDomainByName(ctx, domainName[subIndex+1:])
 		}
 
 		return nil, err
@@ -57,9 +67,8 @@ func (c *Client) GetDomainByName(domainName string) (*Domain, error) {
 }
 
 // AddRecord adds Record for given domain.
-func (c *Client) AddRecord(domainID int, body Record) (*Record, error) {
-	uri := fmt.Sprintf("/%d/records/", domainID)
-	req, err := c.newRequest(http.MethodPost, uri, body)
+func (c *Client) AddRecord(ctx context.Context, domainID int, body Record) (*Record, error) {
+	req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL.JoinPath(strconv.Itoa(domainID), "records", "/"), body)
 	if err != nil {
 		return nil, err
 	}
@@ -74,9 +83,8 @@ func (c *Client) AddRecord(domainID int, body Record) (*Record, error) {
 }
 
 // ListRecords returns list records for specific domain.
-func (c *Client) ListRecords(domainID int) ([]Record, error) {
-	uri := fmt.Sprintf("/%d/records/", domainID)
-	req, err := c.newRequest(http.MethodGet, uri, nil)
+func (c *Client) ListRecords(ctx context.Context, domainID int) ([]Record, error) {
+	req, err := newJSONRequest(ctx, http.MethodGet, c.BaseURL.JoinPath(strconv.Itoa(domainID), "records", "/"), nil)
 	if err != nil {
 		return nil, err
 	}
@@ -86,13 +94,15 @@ func (c *Client) ListRecords(domainID int) ([]Record, error) {
 	if err != nil {
 		return nil, err
 	}
+
 	return records, nil
 }
 
 // DeleteRecord deletes specific record.
-func (c *Client) DeleteRecord(domainID, recordID int) error {
-	uri := fmt.Sprintf("/%d/records/%d", domainID, recordID)
-	req, err := c.newRequest(http.MethodDelete, uri, nil)
+func (c *Client) DeleteRecord(ctx context.Context, domainID, recordID int) error {
+	endpoint := c.BaseURL.JoinPath(strconv.Itoa(domainID), "records", strconv.Itoa(recordID))
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
@@ -101,83 +111,69 @@ func (c *Client) DeleteRecord(domainID, recordID int) error {
 	return err
 }
 
-func (c *Client) newRequest(method, uri string, body interface{}) (*http.Request, error) {
+func (c *Client) do(req *http.Request, result any) (int, error) {
+	req.Header.Set(tokenHeader, c.token)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return 0, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return resp.StatusCode, parseError(req, resp)
+	}
+
+	if result == nil {
+		return resp.StatusCode, nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return resp.StatusCode, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return resp.StatusCode, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return resp.StatusCode, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
 	buf := new(bytes.Buffer)
 
-	if body != nil {
-		err := json.NewEncoder(buf).Encode(body)
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
 		if err != nil {
-			return nil, fmt.Errorf("failed to encode request body with error: %w", err)
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 		}
 	}
 
-	req, err := http.NewRequest(method, c.BaseURL+uri, buf)
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create new http request with error: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
-	req.Header.Set("X-Token", c.token)
-	req.Header.Set("Content-Type", "application/json")
 	req.Header.Set("Accept", "application/json")
 
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
 	return req, nil
 }
 
-func (c *Client) do(req *http.Request, to interface{}) (*http.Response, error) {
-	resp, err := c.HTTPClient.Do(req)
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errAPI := &APIError{}
+	err := json.Unmarshal(raw, errAPI)
 	if err != nil {
-		return nil, fmt.Errorf("request failed with error: %w", err)
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 	}
 
-	err = checkResponse(resp)
-	if err != nil {
-		return resp, err
-	}
-
-	if to != nil {
-		if err = unmarshalBody(resp, to); err != nil {
-			return resp, err
-		}
-	}
-
-	return resp, nil
-}
-
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode >= http.StatusBadRequest {
-		if resp.Body == nil {
-			return fmt.Errorf("request failed with status code %d and empty body", resp.StatusCode)
-		}
-
-		body, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return err
-		}
-		defer resp.Body.Close()
-
-		apiError := APIError{}
-		err = json.Unmarshal(body, &apiError)
-		if err != nil {
-			return fmt.Errorf("request failed with status code %d, response body: %s", resp.StatusCode, string(body))
-		}
-
-		return fmt.Errorf("request failed with status code %d: %w", resp.StatusCode, apiError)
-	}
-
-	return nil
-}
-
-func unmarshalBody(resp *http.Response, to interface{}) error {
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	err = json.Unmarshal(body, to)
-	if err != nil {
-		return fmt.Errorf("unmarshaling error: %w: %s", err, string(body))
-	}
-
-	return nil
+	return fmt.Errorf("request failed with status code %d: %w", resp.StatusCode, errAPI)
 }
diff --git a/providers/dns/internal/selectel/client_test.go b/providers/dns/internal/selectel/client_test.go
index c0bf3007..fd658ae3 100644
--- a/providers/dns/internal/selectel/client_test.go
+++ b/providers/dns/internal/selectel/client_test.go
@@ -1,11 +1,13 @@
 package selectel
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -13,11 +15,23 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_ListRecords(t *testing.T) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
+	client := NewClient("token")
+	client.BaseURL, _ = url.Parse(server.URL)
+	client.HTTPClient = server.Client()
+
+	return client, mux
+}
+
+func TestClient_ListRecords(t *testing.T) {
+	client, mux := setupTest(t)
+
 	mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
@@ -33,10 +47,7 @@ func TestClient_ListRecords(t *testing.T) {
 		}
 	})
 
-	client := NewClient("token")
-	client.BaseURL = server.URL
-
-	records, err := client.ListRecords(123)
+	records, err := client.ListRecords(context.Background(), 123)
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -49,9 +60,7 @@ func TestClient_ListRecords(t *testing.T) {
 }
 
 func TestClient_ListRecords_error(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
@@ -67,19 +76,14 @@ func TestClient_ListRecords_error(t *testing.T) {
 		}
 	})
 
-	client := NewClient("token")
-	client.BaseURL = server.URL
-
-	records, err := client.ListRecords(123)
+	records, err := client.ListRecords(context.Background(), 123)
 
 	assert.EqualError(t, err, "request failed with status code 401: API error: 400 - error description - field that the error occurred in")
 	assert.Nil(t, records)
 }
 
 func TestClient_GetDomainByName(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/sub.sub.example.org", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
@@ -114,10 +118,7 @@ func TestClient_GetDomainByName(t *testing.T) {
 		}
 	})
 
-	client := NewClient("token")
-	client.BaseURL = server.URL
-
-	domain, err := client.GetDomainByName("sub.sub.example.org")
+	domain, err := client.GetDomainByName(context.Background(), "sub.sub.example.org")
 	require.NoError(t, err)
 
 	expected := &Domain{
@@ -129,9 +130,7 @@ func TestClient_GetDomainByName(t *testing.T) {
 }
 
 func TestClient_AddRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
@@ -156,10 +155,7 @@ func TestClient_AddRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient("token")
-	client.BaseURL = server.URL
-
-	record, err := client.AddRecord(123, Record{
+	record, err := client.AddRecord(context.Background(), 123, Record{
 		Name:    "example.org",
 		Type:    "TXT",
 		TTL:     60,
@@ -182,9 +178,7 @@ func TestClient_AddRecord(t *testing.T) {
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodDelete {
@@ -193,10 +187,7 @@ func TestClient_DeleteRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient("token")
-	client.BaseURL = server.URL
-
-	err := client.DeleteRecord(123, 456)
+	err := client.DeleteRecord(context.Background(), 123, 456)
 	require.NoError(t, err)
 }
 
diff --git a/providers/dns/internal/selectel/models.go b/providers/dns/internal/selectel/types.go
similarity index 100%
rename from providers/dns/internal/selectel/models.go
rename to providers/dns/internal/selectel/types.go
diff --git a/providers/dns/internetbs/internal/client.go b/providers/dns/internetbs/internal/client.go
index 9334586f..771408c5 100644
--- a/providers/dns/internetbs/internal/client.go
+++ b/providers/dns/internetbs/internal/client.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -12,6 +13,7 @@ import (
 	"time"
 	"unicode"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	querystring "github.com/google/go-querystring/query"
 )
 
@@ -22,12 +24,13 @@ const statusSuccess = "SUCCESS"
 
 // Client is the API client.
 type Client struct {
-	HTTPClient *http.Client
-	baseURL    *url.URL
-	debug      bool
-
 	apiKey   string
 	password string
+
+	debug bool
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new Client.
@@ -35,17 +38,17 @@ func NewClient(apiKey string, password string) *Client {
 	baseURL, _ := url.Parse(baseURL)
 
 	return &Client{
-		HTTPClient: &http.Client{Timeout: 10 * time.Second},
-		baseURL:    baseURL,
 		apiKey:     apiKey,
 		password:   password,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}
 }
 
 // AddRecord The command is intended to add a new DNS record to a specific zone (domain).
-func (c Client) AddRecord(query RecordQuery) error {
+func (c Client) AddRecord(ctx context.Context, query RecordQuery) error {
 	var r APIResponse
-	err := c.do("Add", query, &r)
+	err := c.doRequest(ctx, "Add", query, &r)
 	if err != nil {
 		return err
 	}
@@ -58,9 +61,9 @@ func (c Client) AddRecord(query RecordQuery) error {
 }
 
 // RemoveRecord The command is intended to remove a DNS record from a specific zone.
-func (c Client) RemoveRecord(query RecordQuery) error {
+func (c Client) RemoveRecord(ctx context.Context, query RecordQuery) error {
 	var r APIResponse
-	err := c.do("Remove", query, &r)
+	err := c.doRequest(ctx, "Remove", query, &r)
 	if err != nil {
 		return err
 	}
@@ -73,9 +76,9 @@ func (c Client) RemoveRecord(query RecordQuery) error {
 }
 
 // ListRecords The command is intended to retrieve the list of DNS records for a specific domain.
-func (c Client) ListRecords(query ListRecordQuery) ([]Record, error) {
+func (c Client) ListRecords(ctx context.Context, query ListRecordQuery) ([]Record, error) {
 	var l ListResponse
-	err := c.do("List", query, &l)
+	err := c.doRequest(ctx, "List", query, &l)
 	if err != nil {
 		return nil, err
 	}
@@ -87,7 +90,7 @@ func (c Client) ListRecords(query ListRecordQuery) ([]Record, error) {
 	return l.Records, nil
 }
 
-func (c Client) do(action string, params interface{}, response interface{}) error {
+func (c Client) doRequest(ctx context.Context, action string, params any, result any) error {
 	endpoint := c.baseURL.JoinPath("Domain", "DnsRecord", action)
 
 	values, err := querystring.Values(params)
@@ -99,27 +102,43 @@ func (c Client) do(action string, params interface{}, response interface{}) erro
 	values.Set("password", c.password)
 	values.Set("ResponseFormat", "JSON")
 
-	resp, err := c.HTTPClient.PostForm(endpoint.String(), values)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(values.Encode()))
 	if err != nil {
-		return fmt.Errorf("post request: %w", err)
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode/100 != 2 {
-		data, _ := io.ReadAll(resp.Body)
-		return fmt.Errorf("status code: %d, %s", resp.StatusCode, string(data))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	if c.debug {
-		return dump(endpoint, resp, response)
+		return dump(endpoint, resp, result)
 	}
 
-	return json.NewDecoder(resp.Body).Decode(response)
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
 }
 
-func dump(endpoint *url.URL, resp *http.Response, response interface{}) error {
-	data, err := io.ReadAll(resp.Body)
+func dump(endpoint *url.URL, resp *http.Response, response any) error {
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
 		return err
 	}
@@ -128,10 +147,10 @@ func dump(endpoint *url.URL, resp *http.Response, response interface{}) error {
 		return !unicode.IsLetter(r) && !unicode.IsNumber(r)
 	})
 
-	err = os.WriteFile(filepath.Join("fixtures", strings.Join(fields, "_")+".json"), data, 0o666)
+	err = os.WriteFile(filepath.Join("fixtures", strings.Join(fields, "_")+".json"), raw, 0o666)
 	if err != nil {
 		return err
 	}
 
-	return json.Unmarshal(data, response)
+	return json.Unmarshal(raw, response)
 }
diff --git a/providers/dns/internetbs/internal/client_test.go b/providers/dns/internetbs/internal/client_test.go
index 0efc6cab..a22f1b12 100644
--- a/providers/dns/internetbs/internal/client_test.go
+++ b/providers/dns/internetbs/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -31,7 +32,7 @@ func TestClient_AddRecord(t *testing.T) {
 		TTL:            36000,
 	}
 
-	err := client.AddRecord(query)
+	err := client.AddRecord(context.Background(), query)
 	require.NoError(t, err)
 }
 
@@ -45,7 +46,7 @@ func TestClient_AddRecord_error(t *testing.T) {
 		TTL:            36000,
 	}
 
-	err := client.AddRecord(query)
+	err := client.AddRecord(context.Background(), query)
 	require.Error(t, err)
 }
 
@@ -66,7 +67,7 @@ func TestClient_AddRecord_integration(t *testing.T) {
 		TTL:            36000,
 	}
 
-	err := client.AddRecord(query)
+	err := client.AddRecord(context.Background(), query)
 	require.NoError(t, err)
 
 	query = RecordQuery{
@@ -76,7 +77,7 @@ func TestClient_AddRecord_integration(t *testing.T) {
 		TTL:            36000,
 	}
 
-	err = client.AddRecord(query)
+	err = client.AddRecord(context.Background(), query)
 	require.NoError(t, err)
 }
 
@@ -88,7 +89,7 @@ func TestClient_RemoveRecord(t *testing.T) {
 		Type:           "TXT",
 		Value:          "",
 	}
-	err := client.RemoveRecord(query)
+	err := client.RemoveRecord(context.Background(), query)
 	require.NoError(t, err)
 }
 
@@ -100,7 +101,7 @@ func TestClient_RemoveRecord_error(t *testing.T) {
 		Type:           "TXT",
 		Value:          "",
 	}
-	err := client.RemoveRecord(query)
+	err := client.RemoveRecord(context.Background(), query)
 	require.Error(t, err)
 }
 
@@ -120,7 +121,7 @@ func TestClient_RemoveRecord_integration(t *testing.T) {
 		Value:          "",
 	}
 
-	err := client.RemoveRecord(query)
+	err := client.RemoveRecord(context.Background(), query)
 	require.NoError(t, err)
 }
 
@@ -131,7 +132,7 @@ func TestClient_ListRecords(t *testing.T) {
 		Domain: "example.com",
 	}
 
-	records, err := client.ListRecords(query)
+	records, err := client.ListRecords(context.Background(), query)
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -183,7 +184,7 @@ func TestClient_ListRecords_error(t *testing.T) {
 		Domain: "www.example.com",
 	}
 
-	_, err := client.ListRecords(query)
+	_, err := client.ListRecords(context.Background(), query)
 	require.Error(t, err)
 }
 
@@ -201,7 +202,7 @@ func TestClient_ListRecords_integration(t *testing.T) {
 		Domain: "example.com",
 	}
 
-	records, err := client.ListRecords(query)
+	records, err := client.ListRecords(context.Background(), query)
 	require.NoError(t, err)
 
 	for _, record := range records {
diff --git a/providers/dns/internetbs/internetbs.go b/providers/dns/internetbs/internetbs.go
index 27b48de0..89b33eae 100644
--- a/providers/dns/internetbs/internetbs.go
+++ b/providers/dns/internetbs/internetbs.go
@@ -2,6 +2,7 @@
 package internetbs
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -107,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:            d.config.TTL,
 	}
 
-	err := d.client.AddRecord(query)
+	err := d.client.AddRecord(context.Background(), query)
 	if err != nil {
 		return fmt.Errorf("internetbs: %w", err)
 	}
@@ -126,7 +127,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		TTL:            d.config.TTL,
 	}
 
-	err := d.client.RemoveRecord(query)
+	err := d.client.RemoveRecord(context.Background(), query)
 	if err != nil {
 		return fmt.Errorf("internetbs: %w", err)
 	}
diff --git a/providers/dns/inwx/inwx.go b/providers/dns/inwx/inwx.go
index 3d593073..fdfa4e54 100644
--- a/providers/dns/inwx/inwx.go
+++ b/providers/dns/inwx/inwx.go
@@ -97,7 +97,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(challengeInfo.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("inwx: %w", err)
+		return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, challengeInfo.EffectiveFQDN, err)
 	}
 
 	info, err := d.client.Account.Login()
@@ -147,7 +147,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(challengeInfo.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("inwx: %w", err)
+		return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, challengeInfo.EffectiveFQDN, err)
 	}
 
 	info, err := d.client.Account.Login()
diff --git a/providers/dns/ionos/internal/client.go b/providers/dns/ionos/internal/client.go
index 3abd5657..8b37d5f1 100644
--- a/providers/dns/ionos/internal/client.go
+++ b/providers/dns/ionos/internal/client.go
@@ -8,7 +8,9 @@ import (
 	"io"
 	"net/http"
 	"net/url"
+	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	querystring "github.com/google/go-querystring/query"
 )
 
@@ -17,10 +19,10 @@ const defaultBaseURL = "https://api.hosting.ionos.com/dns"
 
 // Client Ionos API client.
 type Client struct {
-	HTTPClient *http.Client
-	BaseURL    *url.URL
-
 	apiKey string
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new Client.
@@ -31,9 +33,9 @@ func NewClient(apiKey string) (*Client, error) {
 	}
 
 	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    baseURL,
 		apiKey:     apiKey,
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}, nil
 }
 
@@ -41,28 +43,17 @@ func NewClient(apiKey string) (*Client, error) {
 func (c *Client) ListZones(ctx context.Context) ([]Zone, error) {
 	endpoint := c.BaseURL.JoinPath("v1", "zones")
 
-	req, err := c.makeRequest(ctx, http.MethodGet, endpoint, nil)
+	req, err := makeJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request: %w", err)
 	}
 
-	resp, err := c.HTTPClient.Do(req)
+	var zones []Zone
+	err = c.do(req, &zones)
 	if err != nil {
 		return nil, fmt.Errorf("failed to call API: %w", err)
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, readError(resp.Body, resp.StatusCode)
-	}
-
-	var zones []Zone
-	err = json.NewDecoder(resp.Body).Decode(&zones)
-	if err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
-	}
-
 	return zones, nil
 }
 
@@ -70,27 +61,16 @@ func (c *Client) ListZones(ctx context.Context) ([]Zone, error) {
 func (c *Client) ReplaceRecords(ctx context.Context, zoneID string, records []Record) error {
 	endpoint := c.BaseURL.JoinPath("v1", "zones", zoneID)
 
-	body, err := json.Marshal(records)
-	if err != nil {
-		return fmt.Errorf("failed to marshal request body: %w", err)
-	}
-
-	req, err := c.makeRequest(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
+	req, err := makeJSONRequest(ctx, http.MethodPatch, endpoint, records)
 	if err != nil {
 		return fmt.Errorf("failed to create request: %w", err)
 	}
 
-	resp, err := c.HTTPClient.Do(req)
+	err = c.do(req, nil)
 	if err != nil {
 		return fmt.Errorf("failed to call API: %w", err)
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		return readError(resp.Body, resp.StatusCode)
-	}
-
 	return nil
 }
 
@@ -98,7 +78,7 @@ func (c *Client) ReplaceRecords(ctx context.Context, zoneID string, records []Re
 func (c *Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsFilter) ([]Record, error) {
 	endpoint := c.BaseURL.JoinPath("v1", "zones", zoneID)
 
-	req, err := c.makeRequest(ctx, http.MethodGet, endpoint, nil)
+	req, err := makeJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request: %w", err)
 	}
@@ -112,23 +92,12 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsF
 		req.URL.RawQuery = v.Encode()
 	}
 
-	resp, err := c.HTTPClient.Do(req)
+	var zone CustomerZone
+	err = c.do(req, &zone)
 	if err != nil {
 		return nil, fmt.Errorf("failed to call API: %w", err)
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, readError(resp.Body, resp.StatusCode)
-	}
-
-	var zone CustomerZone
-	err = json.NewDecoder(resp.Body).Decode(&zone)
-	if err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
-	}
-
 	return zone.Records, nil
 }
 
@@ -136,48 +105,82 @@ func (c *Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsF
 func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) error {
 	endpoint := c.BaseURL.JoinPath("v1", "zones", zoneID, "records", recordID)
 
-	req, err := c.makeRequest(ctx, http.MethodDelete, endpoint, nil)
+	req, err := makeJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return fmt.Errorf("failed to create request: %w", err)
 	}
 
-	resp, err := c.HTTPClient.Do(req)
+	err = c.do(req, nil)
 	if err != nil {
 		return fmt.Errorf("failed to call API: %w", err)
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		return readError(resp.Body, resp.StatusCode)
-	}
-
 	return nil
 }
 
-func (c *Client) makeRequest(ctx context.Context, method string, endpoint *url.URL, body io.Reader) (*http.Request, error) {
-	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
+func (c *Client) do(req *http.Request, result any) error {
+	req.Header.Set("X-API-Key", c.apiKey)
+
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func makeJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("X-API-Key", c.apiKey)
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
 
 	return req, nil
 }
 
-func readError(body io.Reader, statusCode int) error {
-	bodyBytes, _ := io.ReadAll(body)
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
 
-	cErr := &ClientError{StatusCode: statusCode}
-
-	err := json.Unmarshal(bodyBytes, &cErr.errors)
+	errClient := &ClientError{StatusCode: resp.StatusCode}
+	err := json.Unmarshal(raw, &errClient.errors)
 	if err != nil {
-		cErr.message = string(bodyBytes)
-		return cErr
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 	}
 
-	return cErr
+	return errClient
 }
diff --git a/providers/dns/ionos/internal/client_test.go b/providers/dns/ionos/internal/client_test.go
index 5d40b73b..21a7a267 100644
--- a/providers/dns/ionos/internal/client_test.go
+++ b/providers/dns/ionos/internal/client_test.go
@@ -17,7 +17,7 @@ import (
 )
 
 func TestClient_ListZones(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusOK, "list_zones.json"))
 
@@ -34,7 +34,7 @@ func TestClient_ListZones(t *testing.T) {
 }
 
 func TestClient_ListZones_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusUnauthorized, "list_zones_error.json"))
 
@@ -49,7 +49,7 @@ func TestClient_ListZones_error(t *testing.T) {
 }
 
 func TestClient_GetRecords(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusOK, "get_records.json"))
 
@@ -67,7 +67,7 @@ func TestClient_GetRecords(t *testing.T) {
 }
 
 func TestClient_GetRecords_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusUnauthorized, "get_records_error.json"))
 
@@ -82,7 +82,7 @@ func TestClient_GetRecords_error(t *testing.T) {
 }
 
 func TestClient_RemoveRecord(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusOK, ""))
 
@@ -91,7 +91,7 @@ func TestClient_RemoveRecord(t *testing.T) {
 }
 
 func TestClient_RemoveRecord_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusInternalServerError, "remove_record_error.json"))
 
@@ -104,7 +104,7 @@ func TestClient_RemoveRecord_error(t *testing.T) {
 }
 
 func TestClient_ReplaceRecords(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusOK, ""))
 
@@ -120,7 +120,7 @@ func TestClient_ReplaceRecords(t *testing.T) {
 }
 
 func TestClient_ReplaceRecords_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusBadRequest, "replace_records_error.json"))
 
@@ -139,7 +139,7 @@ func TestClient_ReplaceRecords_error(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, cErr.StatusCode)
 }
 
-func setupTest(t *testing.T) (*http.ServeMux, *Client) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
@@ -151,7 +151,7 @@ func setupTest(t *testing.T) (*http.ServeMux, *Client) {
 
 	client.BaseURL, _ = url.Parse(server.URL)
 
-	return mux, client
+	return client, mux
 }
 
 func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) {
diff --git a/providers/dns/ionos/ionos.go b/providers/dns/ionos/ionos.go
index b0e64e9f..d6150a70 100644
--- a/providers/dns/ionos/ionos.go
+++ b/providers/dns/ionos/ionos.go
@@ -92,10 +92,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		client.HTTPClient = config.HTTPClient
 	}
 
-	return &DNSProvider{
-		config: config,
-		client: client,
-	}, nil
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
diff --git a/providers/dns/iwantmyname/internal/client.go b/providers/dns/iwantmyname/internal/client.go
index 22fef84c..7a7c50e2 100644
--- a/providers/dns/iwantmyname/internal/client.go
+++ b/providers/dns/iwantmyname/internal/client.go
@@ -3,28 +3,21 @@ package internal
 import (
 	"context"
 	"fmt"
-	"io"
 	"net/http"
 	"net/url"
 	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	querystring "github.com/google/go-querystring/query"
 )
 
 const defaultBaseURL = "https://iwantmyname.com/basicauth/ddns"
 
-// Record represents a record.
-type Record struct {
-	Hostname string `url:"hostname,omitempty"`
-	Type     string `url:"type,omitempty"`
-	Value    string `url:"value,omitempty"`
-	TTL      int    `url:"ttl,omitempty"`
-}
-
 // Client iwantmyname client.
 type Client struct {
-	username   string
-	password   string
+	username string
+	password string
+
 	baseURL    *url.URL
 	HTTPClient *http.Client
 }
@@ -32,6 +25,7 @@ type Client struct {
 // NewClient creates a new Client.
 func NewClient(username string, password string) *Client {
 	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
 		username:   username,
 		password:   password,
@@ -40,8 +34,8 @@ func NewClient(username string, password string) *Client {
 	}
 }
 
-// Do send a request (create/add/delete) to the API.
-func (c Client) Do(ctx context.Context, record Record) error {
+// SendRequest send a request (create/add/delete) to the API.
+func (c Client) SendRequest(ctx context.Context, record Record) error {
 	values, err := querystring.Values(record)
 	if err != nil {
 		return err
@@ -52,19 +46,20 @@ func (c Client) Do(ctx context.Context, record Record) error {
 
 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody)
 	if err != nil {
-		return err
+		return fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.SetBasicAuth(c.username, c.password)
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
+	defer func() { _ = resp.Body.Close() }()
+
 	if resp.StatusCode/100 != 2 {
-		data, _ := io.ReadAll(resp.Body)
-		return fmt.Errorf("status code: %d, %s", resp.StatusCode, string(data))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	return nil
diff --git a/providers/dns/iwantmyname/internal/client_test.go b/providers/dns/iwantmyname/internal/client_test.go
index 76ea2532..b26f7c0f 100644
--- a/providers/dns/iwantmyname/internal/client_test.go
+++ b/providers/dns/iwantmyname/internal/client_test.go
@@ -18,14 +18,23 @@ func checkParameter(query url.Values, key, expected string) error {
 	return nil
 }
 
-func TestClient_Do(t *testing.T) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
 
 	client := NewClient("user", "secret")
 	client.HTTPClient = server.Client()
 	client.baseURL, _ = url.Parse(server.URL)
 
+	return client, mux
+}
+
+func TestClient_Do(t *testing.T) {
+	client, mux := setupTest(t)
+
 	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
@@ -73,6 +82,6 @@ func TestClient_Do(t *testing.T) {
 		TTL:      120,
 	}
 
-	err := client.Do(context.Background(), record)
+	err := client.SendRequest(context.Background(), record)
 	require.NoError(t, err)
 }
diff --git a/providers/dns/iwantmyname/internal/types.go b/providers/dns/iwantmyname/internal/types.go
new file mode 100644
index 00000000..b259235f
--- /dev/null
+++ b/providers/dns/iwantmyname/internal/types.go
@@ -0,0 +1,9 @@
+package internal
+
+// Record represents a record.
+type Record struct {
+	Hostname string `url:"hostname,omitempty"`
+	Type     string `url:"type,omitempty"`
+	Value    string `url:"value,omitempty"`
+	TTL      int    `url:"ttl,omitempty"`
+}
diff --git a/providers/dns/iwantmyname/iwantmyname.go b/providers/dns/iwantmyname/iwantmyname.go
index dfb75e37..e828446a 100644
--- a/providers/dns/iwantmyname/iwantmyname.go
+++ b/providers/dns/iwantmyname/iwantmyname.go
@@ -108,7 +108,7 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error {
 		TTL:      d.config.TTL,
 	}
 
-	err := d.client.Do(context.Background(), record)
+	err := d.client.SendRequest(context.Background(), record)
 	if err != nil {
 		return fmt.Errorf("iwantmyname: %w", err)
 	}
@@ -127,7 +127,7 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 		TTL:      d.config.TTL,
 	}
 
-	err := d.client.Do(context.Background(), record)
+	err := d.client.SendRequest(context.Background(), record)
 	if err != nil {
 		return fmt.Errorf("iwantmyname: %w", err)
 	}
diff --git a/providers/dns/joker/internal/dmapi/client.go b/providers/dns/joker/internal/dmapi/client.go
index 00a84b53..04f4350a 100644
--- a/providers/dns/joker/internal/dmapi/client.go
+++ b/providers/dns/joker/internal/dmapi/client.go
@@ -3,6 +3,7 @@
 package dmapi
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
@@ -10,9 +11,12 @@ import (
 	"net/url"
 	"strconv"
 	"strings"
+	"sync"
+	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/log"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://dmapi.joker.com/request/"
@@ -30,129 +34,90 @@ type AuthInfo struct {
 	APIKey   string
 	Username string
 	Password string
-	authSid  string
 }
 
 // Client a DMAPI Client.
 type Client struct {
-	HTTPClient *http.Client
+	apiKey   string
+	username string
+	password string
+
+	token   *Token
+	muToken sync.Mutex
+
+	Debug      bool
 	BaseURL    string
-
-	Debug bool
-
-	auth AuthInfo
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new DMAPI Client.
-func NewClient(auth AuthInfo) *Client {
+func NewClient(authInfo AuthInfo) *Client {
 	return &Client{
-		HTTPClient: http.DefaultClient,
+		apiKey:     authInfo.APIKey,
+		username:   authInfo.Username,
+		password:   authInfo.Password,
 		BaseURL:    defaultBaseURL,
-		Debug:      false,
-		auth:       auth,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
-// Login performs a login to Joker's DMAPI.
-func (c *Client) Login() (*Response, error) {
-	if c.auth.authSid != "" {
-		// already logged in
-		return nil, nil
-	}
-
-	var values url.Values
-	switch {
-	case c.auth.Username != "" && c.auth.Password != "":
-		values = url.Values{
-			"username": {c.auth.Username},
-			"password": {c.auth.Password},
-		}
-	case c.auth.APIKey != "":
-		values = url.Values{"api-key": {c.auth.APIKey}}
-	default:
-		return nil, errors.New("no username and password or api-key")
-	}
-
-	response, err := c.postRequest("login", values)
-	if err != nil {
-		return response, err
-	}
-
-	if response == nil {
-		return nil, errors.New("login returned nil response")
-	}
-
-	if response.AuthSid == "" {
-		return response, errors.New("login did not return valid Auth-Sid")
-	}
-
-	c.auth.authSid = response.AuthSid
-
-	return response, nil
-}
-
-// Logout closes authenticated session with Joker's DMAPI.
-func (c *Client) Logout() (*Response, error) {
-	if c.auth.authSid == "" {
-		return nil, errors.New("already logged out")
-	}
-
-	response, err := c.postRequest("logout", url.Values{})
-	if err == nil {
-		c.auth.authSid = ""
-	}
-	return response, err
-}
-
 // GetZone returns content of DNS zone for domain.
-func (c *Client) GetZone(domain string) (*Response, error) {
-	if c.auth.authSid == "" {
+func (c *Client) GetZone(ctx context.Context, domain string) (*Response, error) {
+	if getSessionID(ctx) == "" {
 		return nil, errors.New("must be logged in to get zone")
 	}
 
-	return c.postRequest("dns-zone-get", url.Values{"domain": {dns01.UnFqdn(domain)}})
+	return c.postRequest(ctx, "dns-zone-get", url.Values{"domain": {dns01.UnFqdn(domain)}})
 }
 
 // PutZone uploads DNS zone to Joker DMAPI.
-func (c *Client) PutZone(domain, zone string) (*Response, error) {
-	if c.auth.authSid == "" {
+func (c *Client) PutZone(ctx context.Context, domain, zone string) (*Response, error) {
+	if getSessionID(ctx) == "" {
 		return nil, errors.New("must be logged in to put zone")
 	}
 
-	return c.postRequest("dns-zone-put", url.Values{"domain": {dns01.UnFqdn(domain)}, "zone": {strings.TrimSpace(zone)}})
+	return c.postRequest(ctx, "dns-zone-put", url.Values{"domain": {dns01.UnFqdn(domain)}, "zone": {strings.TrimSpace(zone)}})
 }
 
 // postRequest performs actual HTTP request.
-func (c *Client) postRequest(cmd string, data url.Values) (*Response, error) {
+func (c *Client) postRequest(ctx context.Context, cmd string, data url.Values) (*Response, error) {
 	endpoint, err := url.JoinPath(c.BaseURL, cmd)
 	if err != nil {
 		return nil, err
 	}
 
-	if c.auth.authSid != "" {
-		data.Set("auth-sid", c.auth.authSid)
+	if getSessionID(ctx) != "" {
+		data.Set("auth-sid", getSessionID(ctx))
 	}
 
 	if c.Debug {
 		log.Infof("postRequest:\n\tURL: %q\n\tData: %v", endpoint, data)
 	}
 
-	resp, err := c.HTTPClient.PostForm(endpoint, data)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode()))
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
-	defer resp.Body.Close()
 
-	body, err := io.ReadAll(resp.Body)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
+	defer func() { _ = resp.Body.Close() }()
+
 	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("HTTP error %d [%s]: %v", resp.StatusCode, http.StatusText(resp.StatusCode), string(body))
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	return parseResponse(string(body)), nil
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	return parseResponse(string(raw)), nil
 }
 
 // parseResponse parses HTTP response body.
diff --git a/providers/dns/joker/internal/dmapi/client_test.go b/providers/dns/joker/internal/dmapi/client_test.go
index b2835498..7bdb07ed 100644
--- a/providers/dns/joker/internal/dmapi/client_test.go
+++ b/providers/dns/joker/internal/dmapi/client_test.go
@@ -23,223 +23,17 @@ const (
 	serverErrorUsername = "error"
 )
 
-func setup(t *testing.T) (*http.ServeMux, string) {
+func setupTest(t *testing.T) (*http.ServeMux, string) {
 	t.Helper()
 
 	mux := http.NewServeMux()
-
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
 	return mux, server.URL
 }
 
-func TestDNSProvider_login_api_key(t *testing.T) {
-	testCases := []struct {
-		desc               string
-		apiKey             string
-		expectedError      bool
-		expectedStatusCode int
-		expectedAuthSid    string
-	}{
-		{
-			desc:               "correct key",
-			apiKey:             correctAPIKey,
-			expectedStatusCode: 0,
-			expectedAuthSid:    correctAPIKey,
-		},
-		{
-			desc:               "incorrect key",
-			apiKey:             incorrectAPIKey,
-			expectedStatusCode: 2200,
-			expectedError:      true,
-		},
-		{
-			desc:               "server error",
-			apiKey:             serverErrorAPIKey,
-			expectedStatusCode: -500,
-			expectedError:      true,
-		},
-		{
-			desc:               "non-ok status code",
-			apiKey:             "333",
-			expectedStatusCode: 2202,
-			expectedError:      true,
-		},
-	}
-
-	mux, serverURL := setup(t)
-
-	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
-		require.Equal(t, http.MethodPost, r.Method)
-
-		switch r.FormValue("api-key") {
-		case correctAPIKey:
-			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
-		case incorrectAPIKey:
-			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
-		case serverErrorAPIKey:
-			http.NotFound(w, r)
-		default:
-			_, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
-		}
-	})
-
-	for _, test := range testCases {
-		t.Run(test.desc, func(t *testing.T) {
-			client := NewClient(AuthInfo{APIKey: test.apiKey})
-			client.BaseURL = serverURL
-
-			response, err := client.Login()
-			if test.expectedError {
-				require.Error(t, err)
-			} else {
-				require.NoError(t, err)
-				require.NotNil(t, response)
-				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
-				assert.Equal(t, test.expectedAuthSid, response.AuthSid)
-			}
-		})
-	}
-}
-
-func TestDNSProvider_login_username(t *testing.T) {
-	testCases := []struct {
-		desc               string
-		username           string
-		password           string
-		expectedError      bool
-		expectedStatusCode int
-		expectedAuthSid    string
-	}{
-		{
-			desc:               "correct username and password",
-			username:           correctUsername,
-			password:           "go-acme",
-			expectedError:      false,
-			expectedStatusCode: 0,
-			expectedAuthSid:    correctAPIKey,
-		},
-		{
-			desc:               "incorrect username",
-			username:           incorrectUsername,
-			password:           "go-acme",
-			expectedStatusCode: 2200,
-			expectedError:      true,
-		},
-		{
-			desc:               "server error",
-			username:           serverErrorUsername,
-			password:           "go-acme",
-			expectedStatusCode: -500,
-			expectedError:      true,
-		},
-		{
-			desc:               "non-ok status code",
-			username:           "random",
-			password:           "go-acme",
-			expectedStatusCode: 2202,
-			expectedError:      true,
-		},
-	}
-
-	mux, serverURL := setup(t)
-
-	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
-		require.Equal(t, http.MethodPost, r.Method)
-
-		switch r.FormValue("username") {
-		case correctUsername:
-			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
-		case incorrectUsername:
-			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
-		case serverErrorUsername:
-			http.NotFound(w, r)
-		default:
-			_, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
-		}
-	})
-
-	for _, test := range testCases {
-		t.Run(test.desc, func(t *testing.T) {
-			client := NewClient(AuthInfo{Username: test.username, Password: test.password})
-			client.BaseURL = serverURL
-
-			response, err := client.Login()
-			if test.expectedError {
-				require.Error(t, err)
-			} else {
-				require.NoError(t, err)
-				require.NotNil(t, response)
-				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
-				assert.Equal(t, test.expectedAuthSid, response.AuthSid)
-			}
-		})
-	}
-}
-
-func TestDNSProvider_logout(t *testing.T) {
-	testCases := []struct {
-		desc               string
-		authSid            string
-		expectedError      bool
-		expectedStatusCode int
-	}{
-		{
-			desc:               "correct auth-sid",
-			authSid:            correctAPIKey,
-			expectedStatusCode: 0,
-		},
-		{
-			desc:               "incorrect auth-sid",
-			authSid:            incorrectAPIKey,
-			expectedStatusCode: 2200,
-		},
-		{
-			desc:          "already logged out",
-			authSid:       "",
-			expectedError: true,
-		},
-		{
-			desc:          "server error",
-			authSid:       serverErrorAPIKey,
-			expectedError: true,
-		},
-	}
-
-	mux, serverURL := setup(t)
-
-	mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
-		require.Equal(t, http.MethodPost, r.Method)
-
-		switch r.FormValue("auth-sid") {
-		case correctAPIKey:
-			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n")
-		case incorrectAPIKey:
-			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
-		default:
-			http.NotFound(w, r)
-		}
-	})
-
-	for _, test := range testCases {
-		t.Run(test.desc, func(t *testing.T) {
-			client := NewClient(AuthInfo{APIKey: "12345", authSid: test.authSid})
-			client.BaseURL = serverURL
-
-			response, err := client.Logout()
-			if test.expectedError {
-				require.Error(t, err)
-			} else {
-				require.NoError(t, err)
-				require.NotNil(t, response)
-				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
-			}
-		})
-	}
-}
-
-func TestDNSProvider_getZone(t *testing.T) {
+func TestClient_GetZone(t *testing.T) {
 	testZone := "@ A 0 192.0.2.2 3600"
 
 	testCases := []struct {
@@ -276,7 +70,7 @@ func TestDNSProvider_getZone(t *testing.T) {
 		},
 	}
 
-	mux, serverURL := setup(t)
+	mux, serverURL := setupTest(t)
 
 	mux.HandleFunc("/dns-zone-get", func(w http.ResponseWriter, r *http.Request) {
 		require.Equal(t, http.MethodPost, r.Method)
@@ -296,10 +90,10 @@ func TestDNSProvider_getZone(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			client := NewClient(AuthInfo{APIKey: "12345", authSid: test.authSid})
+			client := NewClient(AuthInfo{APIKey: "12345"})
 			client.BaseURL = serverURL
 
-			response, err := client.GetZone(test.domain)
+			response, err := client.GetZone(mockContext(test.authSid), test.domain)
 			if test.expectedError {
 				require.Error(t, err)
 			} else {
@@ -387,7 +181,7 @@ func Test_parseResponse(t *testing.T) {
 	}
 }
 
-func Test_removeTxtEntryFromZone(t *testing.T) {
+func Test_RemoveTxtEntryFromZone(t *testing.T) {
 	testCases := []struct {
 		desc     string
 		input    string
@@ -438,7 +232,7 @@ func Test_removeTxtEntryFromZone(t *testing.T) {
 	}
 }
 
-func Test_addTxtEntryToZone(t *testing.T) {
+func Test_AddTxtEntryToZone(t *testing.T) {
 	testCases := []struct {
 		desc     string
 		input    string
diff --git a/providers/dns/joker/internal/dmapi/identity.go b/providers/dns/joker/internal/dmapi/identity.go
new file mode 100644
index 00000000..351d987e
--- /dev/null
+++ b/providers/dns/joker/internal/dmapi/identity.go
@@ -0,0 +1,110 @@
+package dmapi
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+	"time"
+)
+
+type token string
+
+const sessionIDKey token = "session-id"
+
+// Token session ID.
+// > Every request (except "login") requires the presence of the Auth-Sid variable ("Session ID"),
+// > which is returned by the "login" request (login). An active session will expire after some inactivity period (default: 1 hour).
+// https://joker.com/faq/content/22/12/en/commonalities-for-all-requests.html
+type Token struct {
+	SessionID string
+	ExpireAt  time.Time
+}
+
+// login performs a log in to Joker's DMAPI.
+func (c *Client) login(ctx context.Context) (*Response, error) {
+	var values url.Values
+	switch {
+	case c.username != "" && c.password != "":
+		values = url.Values{
+			"username": {c.username},
+			"password": {c.password},
+		}
+	case c.apiKey != "":
+		values = url.Values{"api-key": {c.apiKey}}
+	default:
+		return nil, errors.New("no username and password or api-key")
+	}
+
+	response, err := c.postRequest(ctx, "login", values)
+	if err != nil {
+		return response, err
+	}
+
+	if response == nil {
+		return nil, errors.New("login returned nil response")
+	}
+
+	if response.AuthSid == "" {
+		return response, errors.New("login did not return valid Auth-Sid")
+	}
+
+	return response, nil
+}
+
+// Logout closes authenticated session with Joker's DMAPI.
+func (c *Client) Logout(ctx context.Context) (*Response, error) {
+	if c.token == nil {
+		return nil, errors.New("already logged out")
+	}
+
+	response, err := c.postRequest(ctx, "logout", url.Values{})
+
+	c.muToken.Lock()
+	c.token = nil
+	c.muToken.Unlock()
+
+	if err != nil {
+		return response, err
+	}
+
+	return response, nil
+}
+
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+	c.muToken.Lock()
+	defer c.muToken.Unlock()
+
+	if c.token != nil && time.Now().UTC().Before(c.token.ExpireAt) {
+		return context.WithValue(ctx, sessionIDKey, c.token.SessionID), nil
+	}
+
+	response, err := c.login(ctx)
+	if err != nil {
+		return nil, formatResponseError(response, err)
+	}
+
+	c.token = &Token{
+		SessionID: response.AuthSid,
+		ExpireAt:  time.Now().UTC().Add(1 * time.Hour),
+	}
+
+	return context.WithValue(ctx, sessionIDKey, response.AuthSid), nil
+}
+
+func getSessionID(ctx context.Context) string {
+	tok, ok := ctx.Value(sessionIDKey).(string)
+	if !ok {
+		return ""
+	}
+
+	return tok
+}
+
+// formatResponseError formats error with optional details from DMAPI response.
+func formatResponseError(response *Response, err error) error {
+	if response != nil {
+		return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers)
+	}
+	return fmt.Errorf("joker: DMAPI error: %w", err)
+}
diff --git a/providers/dns/joker/internal/dmapi/identity_test.go b/providers/dns/joker/internal/dmapi/identity_test.go
new file mode 100644
index 00000000..418deaf4
--- /dev/null
+++ b/providers/dns/joker/internal/dmapi/identity_test.go
@@ -0,0 +1,280 @@
+package dmapi
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func mockContext(sessionID string) context.Context {
+	if sessionID == "" {
+		sessionID = "xxx"
+	}
+
+	return context.WithValue(context.Background(), sessionIDKey, sessionID)
+}
+
+func TestClient_login_apikey(t *testing.T) {
+	testCases := []struct {
+		desc               string
+		apiKey             string
+		expectedError      bool
+		expectedStatusCode int
+		expectedAuthSid    string
+	}{
+		{
+			desc:               "correct key",
+			apiKey:             correctAPIKey,
+			expectedStatusCode: 0,
+			expectedAuthSid:    correctAPIKey,
+		},
+		{
+			desc:               "incorrect key",
+			apiKey:             incorrectAPIKey,
+			expectedStatusCode: 2200,
+			expectedError:      true,
+		},
+		{
+			desc:               "server error",
+			apiKey:             serverErrorAPIKey,
+			expectedStatusCode: -500,
+			expectedError:      true,
+		},
+		{
+			desc:               "non-ok status code",
+			apiKey:             "333",
+			expectedStatusCode: 2202,
+			expectedError:      true,
+		},
+	}
+
+	mux, serverURL := setupTest(t)
+
+	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, http.MethodPost, r.Method)
+
+		switch r.FormValue("api-key") {
+		case correctAPIKey:
+			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
+		case incorrectAPIKey:
+			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
+		case serverErrorAPIKey:
+			http.NotFound(w, r)
+		default:
+			_, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
+		}
+	})
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			client := NewClient(AuthInfo{APIKey: test.apiKey})
+			client.BaseURL = serverURL
+
+			response, err := client.login(context.Background())
+			if test.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, response)
+				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
+				assert.Equal(t, test.expectedAuthSid, response.AuthSid)
+			}
+		})
+	}
+}
+
+func TestClient_login_username(t *testing.T) {
+	testCases := []struct {
+		desc               string
+		username           string
+		password           string
+		expectedError      bool
+		expectedStatusCode int
+		expectedAuthSid    string
+	}{
+		{
+			desc:               "correct username and password",
+			username:           correctUsername,
+			password:           "go-acme",
+			expectedError:      false,
+			expectedStatusCode: 0,
+			expectedAuthSid:    correctAPIKey,
+		},
+		{
+			desc:               "incorrect username",
+			username:           incorrectUsername,
+			password:           "go-acme",
+			expectedStatusCode: 2200,
+			expectedError:      true,
+		},
+		{
+			desc:               "server error",
+			username:           serverErrorUsername,
+			password:           "go-acme",
+			expectedStatusCode: -500,
+			expectedError:      true,
+		},
+		{
+			desc:               "non-ok status code",
+			username:           "random",
+			password:           "go-acme",
+			expectedStatusCode: 2202,
+			expectedError:      true,
+		},
+	}
+
+	mux, serverURL := setupTest(t)
+
+	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, http.MethodPost, r.Method)
+
+		switch r.FormValue("username") {
+		case correctUsername:
+			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet")
+		case incorrectUsername:
+			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
+		case serverErrorUsername:
+			http.NotFound(w, r)
+		default:
+			_, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet")
+		}
+	})
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			client := NewClient(AuthInfo{Username: test.username, Password: test.password})
+			client.BaseURL = serverURL
+
+			response, err := client.login(context.Background())
+			if test.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, response)
+				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
+				assert.Equal(t, test.expectedAuthSid, response.AuthSid)
+			}
+		})
+	}
+}
+
+func TestClient_logout(t *testing.T) {
+	testCases := []struct {
+		desc               string
+		authSid            string
+		expectedError      bool
+		expectedStatusCode int
+	}{
+		{
+			desc:               "correct auth-sid",
+			authSid:            correctAPIKey,
+			expectedStatusCode: 0,
+		},
+		{
+			desc:               "incorrect auth-sid",
+			authSid:            incorrectAPIKey,
+			expectedStatusCode: 2200,
+		},
+		{
+			desc:          "already logged out",
+			authSid:       "",
+			expectedError: true,
+		},
+		{
+			desc:          "server error",
+			authSid:       serverErrorAPIKey,
+			expectedError: true,
+		},
+	}
+
+	mux, serverURL := setupTest(t)
+
+	mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, http.MethodPost, r.Method)
+
+		switch r.FormValue("auth-sid") {
+		case correctAPIKey:
+			_, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n")
+		case incorrectAPIKey:
+			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
+		default:
+			http.NotFound(w, r)
+		}
+	})
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			client := NewClient(AuthInfo{APIKey: "12345"})
+			client.BaseURL = serverURL
+			client.token = &Token{SessionID: test.authSid}
+
+			response, err := client.Logout(mockContext(test.authSid))
+			if test.expectedError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, response)
+				assert.Equal(t, test.expectedStatusCode, response.StatusCode)
+			}
+		})
+	}
+}
+
+func TestClient_CreateAuthenticatedContext(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	id := atomic.Int32{}
+	id.Add(100)
+
+	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, http.MethodPost, r.Method)
+
+		switch r.FormValue("username") {
+		case correctUsername:
+			_, _ = fmt.Fprintf(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load())
+			id.Add(100)
+
+		default:
+			_, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error")
+		}
+	})
+
+	client := NewClient(AuthInfo{Username: correctUsername, Password: "secret"})
+	client.HTTPClient = server.Client()
+	client.BaseURL = server.URL
+
+	ctx, err := client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
+
+	assert.Equal(t, "100", getSessionID(ctx))
+
+	// the token is not expired then we use the "cache".
+	client.muToken.Lock()
+	client.token.SessionID = "cache"
+	client.muToken.Unlock()
+
+	ctx, err = client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
+
+	assert.Equal(t, "cache", getSessionID(ctx))
+
+	// force the expiration of the token
+	client.muToken.Lock()
+	client.token.ExpireAt = time.Now().UTC().Add(-1 * time.Hour)
+	client.muToken.Unlock()
+
+	ctx, err = client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
+
+	assert.Equal(t, "200", getSessionID(ctx))
+}
diff --git a/providers/dns/joker/internal/svc/client.go b/providers/dns/joker/internal/svc/client.go
index 28b98432..6d3a54f9 100644
--- a/providers/dns/joker/internal/svc/client.go
+++ b/providers/dns/joker/internal/svc/client.go
@@ -3,11 +3,14 @@
 package svc
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"strings"
+	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	querystring "github.com/google/go-querystring/query"
 )
 
@@ -23,24 +26,24 @@ type request struct {
 }
 
 type Client struct {
-	HTTPClient *http.Client
-	BaseURL    string
-
 	username string
 	password string
+
+	BaseURL    string
+	HTTPClient *http.Client
 }
 
 func NewClient(username, password string) *Client {
 	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    defaultBaseURL,
 		username:   username,
 		password:   password,
+		BaseURL:    defaultBaseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
-func (c *Client) Send(zone, label, value string) error {
-	req := request{
+func (c *Client) SendRequest(ctx context.Context, zone, label, value string) error {
+	payload := request{
 		Username: c.username,
 		Password: c.password,
 		Zone:     zone,
@@ -49,24 +52,31 @@ func (c *Client) Send(zone, label, value string) error {
 		Value:    value,
 	}
 
-	v, err := querystring.Values(req)
+	v, err := querystring.Values(payload)
 	if err != nil {
 		return err
 	}
 
-	resp, err := c.HTTPClient.PostForm(c.BaseURL, v)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL, strings.NewReader(v.Encode()))
 	if err != nil {
-		return err
+		return fmt.Errorf("unable to create request: %w", err)
 	}
 
-	all, err := io.ReadAll(resp.Body)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	if resp.StatusCode == http.StatusOK && strings.HasPrefix(string(all), "OK") {
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	if resp.StatusCode == http.StatusOK && strings.HasPrefix(string(raw), "OK") {
 		return nil
 	}
 
-	return fmt.Errorf("error: %d: %s", resp.StatusCode, string(all))
+	return fmt.Errorf("error: %d: %s", resp.StatusCode, string(raw))
 }
diff --git a/providers/dns/joker/internal/svc/client_test.go b/providers/dns/joker/internal/svc/client_test.go
index b75139a2..6803ae84 100644
--- a/providers/dns/joker/internal/svc/client_test.go
+++ b/providers/dns/joker/internal/svc/client_test.go
@@ -1,6 +1,7 @@
 package svc
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -10,11 +11,23 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_Send(t *testing.T) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
+	client := NewClient("test", "secret")
+	client.BaseURL = server.URL
+	client.HTTPClient = server.Client()
+
+	return client, mux
+}
+
+func TestClient_Send(t *testing.T) {
+	client, mux := setupTest(t)
+
 	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
 			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed)
@@ -35,21 +48,16 @@ func TestClient_Send(t *testing.T) {
 		}
 	})
 
-	client := NewClient("test", "secret")
-	client.BaseURL = server.URL
-
 	zone := "example.com"
 	label := "_acme-challenge"
 	value := "123"
 
-	err := client.Send(zone, label, value)
+	err := client.SendRequest(context.Background(), zone, label, value)
 	require.NoError(t, err)
 }
 
 func TestClient_Send_empty(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
@@ -71,13 +79,10 @@ func TestClient_Send_empty(t *testing.T) {
 		}
 	})
 
-	client := NewClient("test", "secret")
-	client.BaseURL = server.URL
-
 	zone := "example.com"
 	label := "_acme-challenge"
 	value := ""
 
-	err := client.Send(zone, label, value)
+	err := client.SendRequest(context.Background(), zone, label, value)
 	require.NoError(t, err)
 }
diff --git a/providers/dns/joker/provider_dmapi.go b/providers/dns/joker/provider_dmapi.go
index 35ef43bf..b33d7d48 100644
--- a/providers/dns/joker/provider_dmapi.go
+++ b/providers/dns/joker/provider_dmapi.go
@@ -1,6 +1,7 @@
 package joker
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"time"
@@ -77,7 +78,7 @@ func (d *dmapiProvider) Present(domain, token, keyAuth string) error {
 
 	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("joker: %w", err)
+		return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
@@ -89,19 +90,19 @@ func (d *dmapiProvider) Present(domain, token, keyAuth string) error {
 		log.Infof("[%s] joker: adding TXT record %q to zone %q with value %q", domain, subDomain, zone, info.Value)
 	}
 
-	response, err := d.client.Login()
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
-		return formatResponseError(response, err)
+		return err
 	}
 
-	response, err = d.client.GetZone(zone)
+	response, err := d.client.GetZone(ctx, zone)
 	if err != nil || response.StatusCode != 0 {
 		return formatResponseError(response, err)
 	}
 
 	dnsZone := dmapi.AddTxtEntryToZone(response.Body, subDomain, info.Value, d.config.TTL)
 
-	response, err = d.client.PutZone(zone, dnsZone)
+	response, err = d.client.PutZone(ctx, zone, dnsZone)
 	if err != nil || response.StatusCode != 0 {
 		return formatResponseError(response, err)
 	}
@@ -115,7 +116,7 @@ func (d *dmapiProvider) CleanUp(domain, token, keyAuth string) error {
 
 	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("joker: %w", err)
+		return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
@@ -127,30 +128,30 @@ func (d *dmapiProvider) CleanUp(domain, token, keyAuth string) error {
 		log.Infof("[%s] joker: removing entry %q from zone %q", domain, subDomain, zone)
 	}
 
-	response, err := d.client.Login()
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
-		return formatResponseError(response, err)
+		return err
 	}
 
 	defer func() {
 		// Try to log out in case of errors
-		_, _ = d.client.Logout()
+		_, _ = d.client.Logout(ctx)
 	}()
 
-	response, err = d.client.GetZone(zone)
+	response, err := d.client.GetZone(ctx, zone)
 	if err != nil || response.StatusCode != 0 {
 		return formatResponseError(response, err)
 	}
 
 	dnsZone, modified := dmapi.RemoveTxtEntryFromZone(response.Body, subDomain)
 	if modified {
-		response, err = d.client.PutZone(zone, dnsZone)
+		response, err = d.client.PutZone(ctx, zone, dnsZone)
 		if err != nil || response.StatusCode != 0 {
 			return formatResponseError(response, err)
 		}
 	}
 
-	response, err = d.client.Logout()
+	response, err = d.client.Logout(ctx)
 	if err != nil {
 		return formatResponseError(response, err)
 	}
diff --git a/providers/dns/joker/provider_svc.go b/providers/dns/joker/provider_svc.go
index 68b64404..837a0503 100644
--- a/providers/dns/joker/provider_svc.go
+++ b/providers/dns/joker/provider_svc.go
@@ -1,6 +1,7 @@
 package joker
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"time"
@@ -58,7 +59,7 @@ func (d *svcProvider) Present(domain, token, keyAuth string) error {
 
 	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("joker: %w", err)
+		return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
@@ -66,7 +67,7 @@ func (d *svcProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("joker: %w", err)
 	}
 
-	return d.client.Send(dns01.UnFqdn(zone), subDomain, info.Value)
+	return d.client.SendRequest(context.Background(), dns01.UnFqdn(zone), subDomain, info.Value)
 }
 
 // CleanUp removes the TXT record matching the specified parameters.
@@ -75,7 +76,7 @@ func (d *svcProvider) CleanUp(domain, token, keyAuth string) error {
 
 	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("joker: %w", err)
+		return fmt.Errorf("joker: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
@@ -83,7 +84,7 @@ func (d *svcProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("joker: %w", err)
 	}
 
-	return d.client.Send(dns01.UnFqdn(zone), subDomain, "")
+	return d.client.SendRequest(context.Background(), dns01.UnFqdn(zone), subDomain, "")
 }
 
 // Sequential All DNS challenges for this provider will be resolved sequentially.
diff --git a/providers/dns/liara/internal/client.go b/providers/dns/liara/internal/client.go
index 56fe2e85..89794f04 100644
--- a/providers/dns/liara/internal/client.go
+++ b/providers/dns/liara/internal/client.go
@@ -2,170 +2,208 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/oauth2"
 )
 
 const defaultBaseURL = "https://dns-service.iran.liara.ir"
 
 // Client a Liara DNS API client.
 type Client struct {
-	apiKey     string
 	baseURL    *url.URL
-	HTTPClient *http.Client
+	httpClient *http.Client
 }
 
 // NewClient creates a new Client.
-func NewClient(apiKey string) *Client {
+func NewClient(hc *http.Client) *Client {
 	baseURL, _ := url.Parse(defaultBaseURL)
 
-	return &Client{
-		apiKey:     apiKey,
-		HTTPClient: &http.Client{Timeout: 10 * time.Second},
-		baseURL:    baseURL,
+	if hc == nil {
+		hc = &http.Client{Timeout: 10 * time.Second}
 	}
+
+	return &Client{httpClient: hc, baseURL: baseURL}
 }
 
 // GetRecords gets the records of a domain.
 // https://dns-service.iran.liara.ir/swagger
-func (c Client) GetRecords(domainName string) ([]Record, error) {
+func (c Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) {
 	endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
-	resp, err := c.HTTPClient.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		return nil, readError(resp)
+		return nil, parseError(req, resp)
 	}
 
-	var response RecordsResponse
-	err = json.NewDecoder(resp.Body).Decode(&response)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var response Response[[]Record]
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return response.Data, nil
 }
 
 // CreateRecord creates a record.
-func (c Client) CreateRecord(domainName string, record Record) (*Record, error) {
+func (c Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) {
 	endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records")
 
-	body, err := json.Marshal(record)
-	if err != nil {
-		return nil, fmt.Errorf("marshal request data: %w", err)
-	}
-
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	req.Header.Set("Authorization", "Bearer "+c.apiKey)
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := c.HTTPClient.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusCreated {
-		return nil, readError(resp)
+		return nil, parseError(req, resp)
 	}
 
-	var response RecordResponse
-	err = json.NewDecoder(resp.Body).Decode(&response)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	return &response.Data, nil
+	var response Response[*Record]
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return response.Data, nil
 }
 
 // GetRecord gets a specific record.
-func (c Client) GetRecord(domainName, recordID string) (*Record, error) {
+func (c Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) {
 	endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
-	resp, err := c.HTTPClient.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		return nil, readError(resp)
+		return nil, parseError(req, resp)
 	}
 
-	var response RecordResponse
-	err = json.NewDecoder(resp.Body).Decode(&response)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	return &response.Data, nil
+	var response Response[*Record]
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return response.Data, nil
 }
 
 // DeleteRecord deletes a record.
-func (c Client) DeleteRecord(domainName, recordID string) error {
+func (c Client) DeleteRecord(ctx context.Context, domainName, recordID string) error {
 	endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID)
 
-	req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return fmt.Errorf("create request: %w", err)
 	}
 
-	req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
-	resp, err := c.HTTPClient.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
-		return readError(resp)
+		return parseError(req, resp)
 	}
 
 	return nil
 }
 
-func readError(resp *http.Response) error {
-	all, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("API error (status code: %d)", resp.StatusCode)
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
 	}
 
-	var apiError APIError
-	err = json.Unmarshal(all, &apiError)
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 	if err != nil {
-		return fmt.Errorf("API error (status code: %d): %s", resp.StatusCode, string(all))
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
-	return fmt.Errorf("API error (status code: %d): %w", resp.StatusCode, &apiError)
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errAPI APIError
+	err := json.Unmarshal(raw, &errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("[status code: %d] %w", resp.StatusCode, &errAPI)
+}
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
 }
diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go
index f083af4e..ed6672ab 100644
--- a/providers/dns/liara/internal/client_test.go
+++ b/providers/dns/liara/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -17,11 +18,11 @@ import (
 const apiKey = "key"
 
 func TestClient_GetRecords(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordsResponse.json", http.MethodGet, http.StatusOK))
 
-	records, err := client.GetRecords("example.com")
+	records, err := client.GetRecords(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -41,11 +42,11 @@ func TestClient_GetRecords(t *testing.T) {
 }
 
 func TestClient_GetRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./RecordResponse.json", http.MethodGet, http.StatusOK))
 
-	record, err := client.GetRecord("example.com", "123")
+	record, err := client.GetRecord(context.Background(), "example.com", "123")
 	require.NoError(t, err)
 
 	expected := &Record{
@@ -63,7 +64,7 @@ func TestClient_GetRecord(t *testing.T) {
 }
 
 func TestClient_CreateRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordResponse.json", http.MethodPost, http.StatusCreated))
 
@@ -78,7 +79,7 @@ func TestClient_CreateRecord(t *testing.T) {
 		TTL: 3600,
 	}
 
-	record, err := client.CreateRecord("example.com", data)
+	record, err := client.CreateRecord(context.Background(), "example.com", data)
 	require.NoError(t, err)
 
 	expected := &Record{
@@ -97,33 +98,33 @@ func TestClient_CreateRecord(t *testing.T) {
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) {
 		rw.WriteHeader(http.StatusNoContent)
 	})
 
-	err := client.DeleteRecord("example.com", "123")
+	err := client.DeleteRecord(context.Background(), "example.com", "123")
 	require.NoError(t, err)
 }
 
 func TestClient_DeleteRecord_NotFound_Response(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) {
 		rw.WriteHeader(http.StatusNotFound)
 	})
 
-	err := client.DeleteRecord("example.com", "123")
+	err := client.DeleteRecord(context.Background(), "example.com", "123")
 	require.NoError(t, err)
 }
 
 func TestClient_DeleteRecord_error(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./error.json", http.MethodDelete, http.StatusUnauthorized))
 
-	err := client.DeleteRecord("example.com", "123")
+	err := client.DeleteRecord(context.Background(), "example.com", "123")
 	require.Error(t, err)
 }
 
@@ -158,16 +159,14 @@ func testHandler(filename string, method string, statusCode int) http.HandlerFun
 	}
 }
 
-func setup(t *testing.T) (*Client, *http.ServeMux) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
-
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	client := NewClient(apiKey)
-	client.HTTPClient = server.Client()
+	client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey))
 	client.baseURL, _ = url.Parse(server.URL)
 
 	return client, mux
diff --git a/providers/dns/liara/internal/types.go b/providers/dns/liara/internal/types.go
index 0b817c24..34ae2c2c 100644
--- a/providers/dns/liara/internal/types.go
+++ b/providers/dns/liara/internal/types.go
@@ -14,14 +14,9 @@ type Record struct {
 	Contents []Content `json:"contents"`
 }
 
-type RecordResponse struct {
+type Response[D any] struct {
 	Status string `json:"status"`
-	Data   Record `json:"data"`
-}
-
-type RecordsResponse struct {
-	Status string   `json:"status"`
-	Data   []Record `json:"data"`
+	Data   D      `json:"data"`
 }
 
 type APIError struct {
diff --git a/providers/dns/liara/liara.go b/providers/dns/liara/liara.go
index 053b5a1d..27d3e600 100644
--- a/providers/dns/liara/liara.go
+++ b/providers/dns/liara/liara.go
@@ -2,6 +2,7 @@
 package liara
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -94,8 +95,6 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, fmt.Errorf("liara: invalid TTL, TTL (%d) must be lower than %d", config.TTL, maxTTL)
 	}
 
-	client := internal.NewClient(config.APIKey)
-
 	retryClient := retryablehttp.NewClient()
 	retryClient.RetryMax = 5
 	if config.HTTPClient != nil {
@@ -103,7 +102,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	}
 	retryClient.Logger = log.Logger
 
-	client.HTTPClient = retryClient.StandardClient()
+	client := internal.NewClient(internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey))
 
 	return &DNSProvider{
 		config:    config,
@@ -124,7 +123,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("liara: %w", err)
+		return fmt.Errorf("liara: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -138,7 +137,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Contents: []internal.Content{{Text: info.Value}},
 		TTL:      d.config.TTL,
 	}
-	newRecord, err := d.client.CreateRecord(dns01.UnFqdn(authZone), record)
+	newRecord, err := d.client.CreateRecord(context.Background(), dns01.UnFqdn(authZone), record)
 	if err != nil {
 		return fmt.Errorf("liara: failed to create TXT record, fqdn=%s: %w", info.EffectiveFQDN, err)
 	}
@@ -156,7 +155,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("liara: %w", err)
+		return fmt.Errorf("liara: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// gets the record's unique ID
@@ -167,7 +166,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("liara: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	err = d.client.DeleteRecord(dns01.UnFqdn(authZone), recordID)
+	err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
 	if err != nil {
 		return fmt.Errorf("liara: failed to delete TXT record, id=%s: %w", recordID, err)
 	}
diff --git a/providers/dns/linode/linode.go b/providers/dns/linode/linode.go
index 8569cfac..4143f03e 100644
--- a/providers/dns/linode/linode.go
+++ b/providers/dns/linode/linode.go
@@ -91,21 +91,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, fmt.Errorf("linode: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
 	}
 
-	tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.Token})
 	oauth2Client := &http.Client{
 		Timeout: config.HTTPTimeout,
 		Transport: &oauth2.Transport{
-			Source: tokenSource,
+			Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.Token}),
 		},
 	}
 
 	client := linodego.NewClient(oauth2Client)
-	client.SetUserAgent("lego-dns https://github.com/linode/linodego")
+	client.SetUserAgent("go-acme/lego https://github.com/linode/linodego")
 
-	return &DNSProvider{
-		config: config,
-		client: &client,
-	}, nil
+	return &DNSProvider{config: config, client: &client}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS
@@ -158,7 +154,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	}
 
 	// Get all TXT records for the specified domain.
-	listOpts := linodego.NewListOptions(0, "{\"type\":\"TXT\"}")
+	listOpts := linodego.NewListOptions(0, `{"type":"TXT"}`)
 	resources, err := d.client.ListDomainRecords(context.Background(), zone.domainID, listOpts)
 	if err != nil {
 		return err
@@ -181,16 +177,16 @@ func (d *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) {
 	// Lookup the zone that handles the specified FQDN.
 	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("inwx: could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	// Query the authority zone.
-	data, err := json.Marshal(map[string]string{"domain": dns01.UnFqdn(authZone)})
+	filter, err := json.Marshal(map[string]string{"domain": dns01.UnFqdn(authZone)})
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("failed to create JSON filter: %w", err)
 	}
 
-	listOpts := linodego.NewListOptions(0, string(data))
+	listOpts := linodego.NewListOptions(0, string(filter))
 	domains, err := d.client.ListDomains(context.Background(), listOpts)
 	if err != nil {
 		return nil, err
diff --git a/providers/dns/loopia/internal/client.go b/providers/dns/loopia/internal/client.go
index 013e5a99..d521ffee 100644
--- a/providers/dns/loopia/internal/client.go
+++ b/providers/dns/loopia/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/xml"
 	"errors"
 	"fmt"
@@ -9,6 +10,8 @@ import (
 	"net/http"
 	"strings"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // DefaultBaseURL is url to the XML-RPC api.
@@ -16,29 +19,30 @@ const DefaultBaseURL = "https://api.loopia.se/RPCSERV"
 
 // Client the Loopia client.
 type Client struct {
-	APIUser     string
-	APIPassword string
-	BaseURL     string
-	HTTPClient  *http.Client
+	apiUser     string
+	apiPassword string
+
+	BaseURL    string
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new Loopia Client.
 func NewClient(apiUser, apiPassword string) *Client {
 	return &Client{
-		APIUser:     apiUser,
-		APIPassword: apiPassword,
+		apiUser:     apiUser,
+		apiPassword: apiPassword,
 		BaseURL:     DefaultBaseURL,
 		HTTPClient:  &http.Client{Timeout: 10 * time.Second},
 	}
 }
 
 // AddTXTRecord adds a TXT record.
-func (c *Client) AddTXTRecord(domain string, subdomain string, ttl int, value string) error {
+func (c *Client) AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error {
 	call := &methodCall{
 		MethodName: "addZoneRecord",
 		Params: []param{
-			paramString{Value: c.APIUser},
-			paramString{Value: c.APIPassword},
+			paramString{Value: c.apiUser},
+			paramString{Value: c.apiPassword},
 			paramString{Value: domain},
 			paramString{Value: subdomain},
 			paramStruct{
@@ -54,7 +58,7 @@ func (c *Client) AddTXTRecord(domain string, subdomain string, ttl int, value st
 	}
 	resp := &responseString{}
 
-	err := c.rpcCall(call, resp)
+	err := c.rpcCall(ctx, call, resp)
 	if err != nil {
 		return err
 	}
@@ -63,12 +67,12 @@ func (c *Client) AddTXTRecord(domain string, subdomain string, ttl int, value st
 }
 
 // RemoveTXTRecord removes a TXT record.
-func (c *Client) RemoveTXTRecord(domain string, subdomain string, recordID int) error {
+func (c *Client) RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error {
 	call := &methodCall{
 		MethodName: "removeZoneRecord",
 		Params: []param{
-			paramString{Value: c.APIUser},
-			paramString{Value: c.APIPassword},
+			paramString{Value: c.apiUser},
+			paramString{Value: c.apiPassword},
 			paramString{Value: domain},
 			paramString{Value: subdomain},
 			paramInt{Value: recordID},
@@ -76,7 +80,7 @@ func (c *Client) RemoveTXTRecord(domain string, subdomain string, recordID int)
 	}
 	resp := &responseString{}
 
-	err := c.rpcCall(call, resp)
+	err := c.rpcCall(ctx, call, resp)
 	if err != nil {
 		return err
 	}
@@ -85,37 +89,37 @@ func (c *Client) RemoveTXTRecord(domain string, subdomain string, recordID int)
 }
 
 // GetTXTRecords gets TXT records.
-func (c *Client) GetTXTRecords(domain string, subdomain string) ([]RecordObj, error) {
+func (c *Client) GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]RecordObj, error) {
 	call := &methodCall{
 		MethodName: "getZoneRecords",
 		Params: []param{
-			paramString{Value: c.APIUser},
-			paramString{Value: c.APIPassword},
+			paramString{Value: c.apiUser},
+			paramString{Value: c.apiPassword},
 			paramString{Value: domain},
 			paramString{Value: subdomain},
 		},
 	}
 	resp := &recordObjectsResponse{}
 
-	err := c.rpcCall(call, resp)
+	err := c.rpcCall(ctx, call, resp)
 
 	return resp.Params, err
 }
 
 // RemoveSubdomain remove a sub-domain.
-func (c *Client) RemoveSubdomain(domain, subdomain string) error {
+func (c *Client) RemoveSubdomain(ctx context.Context, domain, subdomain string) error {
 	call := &methodCall{
 		MethodName: "removeSubdomain",
 		Params: []param{
-			paramString{Value: c.APIUser},
-			paramString{Value: c.APIPassword},
+			paramString{Value: c.apiUser},
+			paramString{Value: c.apiPassword},
 			paramString{Value: domain},
 			paramString{Value: subdomain},
 		},
 	}
 	resp := &responseString{}
 
-	err := c.rpcCall(call, resp)
+	err := c.rpcCall(ctx, call, resp)
 	if err != nil {
 		return err
 	}
@@ -123,55 +127,66 @@ func (c *Client) RemoveSubdomain(domain, subdomain string) error {
 	return checkResponse(resp.Value)
 }
 
-// rpcCall makes an XML-RPC call to Loopia's RPC endpoint
-// by marshaling the data given in the call argument to XML and sending that via HTTP Post to Loopia.
+// rpcCall makes an XML-RPC call to Loopia's RPC endpoint by marshaling the data given in the call argument to XML
+// and sending that via HTTP Post to Loopia.
 // The response is then unmarshalled into the resp argument.
-func (c *Client) rpcCall(call *methodCall, resp response) error {
-	body, err := xml.MarshalIndent(call, "", "  ")
-	if err != nil {
-		return fmt.Errorf("error during unmarshalling the request body: %w", err)
-	}
-
-	body = append([]byte(`<?xml version="1.0"?>`+"\n"), body...)
-
-	respBody, err := c.httpPost(c.BaseURL, "text/xml", bytes.NewReader(body))
+func (c *Client) rpcCall(ctx context.Context, call *methodCall, result response) error {
+	req, err := newXMLRequest(ctx, c.BaseURL, call)
 	if err != nil {
 		return err
 	}
 
-	err = xml.Unmarshal(respBody, resp)
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return fmt.Errorf("error during unmarshalling the response body: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	if resp.faultCode() != 0 {
-		return rpcError{
-			faultCode:   resp.faultCode(),
-			faultString: strings.TrimSpace(resp.faultString()),
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = xml.Unmarshal(raw, result)
+	if err != nil {
+		return fmt.Errorf("unmarshal error: %w", err)
+	}
+
+	if result.faultCode() != 0 {
+		return RPCError{
+			FaultCode:   result.faultCode(),
+			FaultString: strings.TrimSpace(result.faultString()),
 		}
 	}
 
 	return nil
 }
 
-func (c *Client) httpPost(url string, bodyType string, body io.Reader) ([]byte, error) {
-	resp, err := c.HTTPClient.Post(url, bodyType, body)
+func newXMLRequest(ctx context.Context, endpoint string, payload any) (*http.Request, error) {
+	body := new(bytes.Buffer)
+	body.WriteString(xml.Header)
+
+	encoder := xml.NewEncoder(body)
+	encoder.Indent("", "  ")
+
+	err := encoder.Encode(payload)
 	if err != nil {
-		return nil, fmt.Errorf("HTTP Post Error: %w", err)
+		return nil, err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("HTTP Post Error: %d", resp.StatusCode)
-	}
-
-	b, err := io.ReadAll(resp.Body)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
 	if err != nil {
-		return nil, fmt.Errorf("HTTP Post Error: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
-	return b, nil
+	req.Header.Set("Content-Type", "text/xml")
+
+	return req, nil
 }
 
 func checkResponse(value string) error {
diff --git a/providers/dns/loopia/internal/client_test.go b/providers/dns/loopia/internal/client_test.go
index 67758177..e62fc2b6 100644
--- a/providers/dns/loopia/internal/client_test.go
+++ b/providers/dns/loopia/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"encoding/xml"
 	"fmt"
 	"io"
@@ -49,7 +50,7 @@ func TestClient_AddZoneRecord(t *testing.T) {
 			desc:     "empty response",
 			password: "goodpassword",
 			domain:   "empty.com",
-			err:      "error during unmarshalling the response body: EOF",
+			err:      "unmarshal error: EOF",
 		},
 	}
 
@@ -58,7 +59,7 @@ func TestClient_AddZoneRecord(t *testing.T) {
 			client := NewClient("apiuser", test.password)
 			client.BaseURL = serverURL + "/"
 
-			err := client.AddTXTRecord(test.domain, exampleSubDomain, 123, "TXTrecord")
+			err := client.AddTXTRecord(context.Background(), test.domain, exampleSubDomain, 123, "TXTrecord")
 			if test.err == "" {
 				require.NoError(t, err)
 			} else {
@@ -106,7 +107,7 @@ func TestClient_RemoveSubdomain(t *testing.T) {
 			desc:     "empty response",
 			password: "goodpassword",
 			domain:   "empty.com",
-			err:      "error during unmarshalling the response body: EOF",
+			err:      "unmarshal error: EOF",
 		},
 	}
 
@@ -115,7 +116,7 @@ func TestClient_RemoveSubdomain(t *testing.T) {
 			client := NewClient("apiuser", test.password)
 			client.BaseURL = serverURL + "/"
 
-			err := client.RemoveSubdomain(test.domain, exampleSubDomain)
+			err := client.RemoveSubdomain(context.Background(), test.domain, exampleSubDomain)
 			if test.err == "" {
 				require.NoError(t, err)
 			} else {
@@ -163,7 +164,7 @@ func TestClient_RemoveZoneRecord(t *testing.T) {
 			desc:     "empty response",
 			password: "goodpassword",
 			domain:   "empty.com",
-			err:      "error during unmarshalling the response body: EOF",
+			err:      "unmarshal error: EOF",
 		},
 	}
 
@@ -172,7 +173,7 @@ func TestClient_RemoveZoneRecord(t *testing.T) {
 			client := NewClient("apiuser", test.password)
 			client.BaseURL = serverURL + "/"
 
-			err := client.RemoveTXTRecord(test.domain, exampleSubDomain, 12345678)
+			err := client.RemoveTXTRecord(context.Background(), test.domain, exampleSubDomain, 12345678)
 			if test.err == "" {
 				require.NoError(t, err)
 			} else {
@@ -193,7 +194,7 @@ func TestClient_GetZoneRecord(t *testing.T) {
 	client := NewClient("apiuser", "goodpassword")
 	client.BaseURL = serverURL + "/"
 
-	recordObjs, err := client.GetTXTRecords(exampleDomain, exampleSubDomain)
+	recordObjs, err := client.GetTXTRecords(context.Background(), exampleDomain, exampleSubDomain)
 	require.NoError(t, err)
 
 	expected := []RecordObj{
@@ -237,8 +238,8 @@ func TestClient_rpcCall_404(t *testing.T) {
 	client := NewClient("apiuser", "apipassword")
 	client.BaseURL = server.URL + "/"
 
-	err := client.rpcCall(call, &responseString{})
-	assert.EqualError(t, err, "HTTP Post Error: 404")
+	err := client.rpcCall(context.Background(), call, &responseString{})
+	assert.EqualError(t, err, "unexpected status code: [status code: 404] body: <?xml version='1.0' encoding='UTF-8'?>")
 }
 
 func TestClient_rpcCall_RPCError(t *testing.T) {
@@ -268,7 +269,7 @@ func TestClient_rpcCall_RPCError(t *testing.T) {
 	client := NewClient("apiuser", "apipassword")
 	client.BaseURL = server.URL + "/"
 
-	err := client.rpcCall(call, &responseString{})
+	err := client.rpcCall(context.Background(), call, &responseString{})
 	assert.EqualError(t, err, "RPC Error: (201) Method signature error: 42")
 }
 
diff --git a/providers/dns/loopia/internal/mock_test.go b/providers/dns/loopia/internal/mock_test.go
index 13b9970b..7896b520 100644
--- a/providers/dns/loopia/internal/mock_test.go
+++ b/providers/dns/loopia/internal/mock_test.go
@@ -6,7 +6,7 @@ const (
 	exampleRdata     = "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"
 )
 
-// Testdata based on real traffic between an xml-rpc client and the api.
+// Testdata based on real traffic between a xml-rpc client and the api.
 const responseOk = `<?xml version="1.0" encoding="UTF-8"?>
 		<methodResponse>
 		  <params>
@@ -76,7 +76,7 @@ const responseRPCError = `<?xml version="1.0" encoding="UTF-8"?>
   </fault>
 </methodResponse>`
 
-const addZoneRecordGoodAuth = `<?xml version="1.0"?>
+const addZoneRecordGoodAuth = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>addZoneRecord</methodName>
   <params>
@@ -139,7 +139,7 @@ const addZoneRecordGoodAuth = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const addZoneRecordBadAuth = `<?xml version="1.0"?>
+const addZoneRecordBadAuth = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>addZoneRecord</methodName>
   <params>
@@ -202,7 +202,7 @@ const addZoneRecordBadAuth = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const addZoneRecordNonValidDomain = `<?xml version="1.0"?>
+const addZoneRecordNonValidDomain = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>addZoneRecord</methodName>
   <params>
@@ -265,7 +265,7 @@ const addZoneRecordNonValidDomain = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const addZoneRecordEmptyResponse = `<?xml version="1.0"?>
+const addZoneRecordEmptyResponse = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>addZoneRecord</methodName>
   <params>
@@ -328,7 +328,7 @@ const addZoneRecordEmptyResponse = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const getZoneRecords = `<?xml version="1.0"?>
+const getZoneRecords = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>getZoneRecords</methodName>
   <params>
@@ -423,7 +423,7 @@ const getZoneRecordsResponse = `<?xml version="1.0" encoding="UTF-8"?>
   </params>
 </methodResponse>`
 
-const removeRecordGoodAuth = `<?xml version="1.0"?>
+const removeRecordGoodAuth = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeZoneRecord</methodName>
   <params>
@@ -455,7 +455,7 @@ const removeRecordGoodAuth = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const removeRecordBadAuth = `<?xml version="1.0"?>
+const removeRecordBadAuth = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeZoneRecord</methodName>
   <params>
@@ -487,7 +487,7 @@ const removeRecordBadAuth = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const removeRecordNonValidDomain = `<?xml version="1.0"?>
+const removeRecordNonValidDomain = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeZoneRecord</methodName>
   <params>
@@ -519,7 +519,7 @@ const removeRecordNonValidDomain = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const removeRecordEmptyResponse = `<?xml version="1.0"?>
+const removeRecordEmptyResponse = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeZoneRecord</methodName>
   <params>
@@ -551,7 +551,7 @@ const removeRecordEmptyResponse = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const removeSubdomainGoodAuth = `<?xml version="1.0"?>
+const removeSubdomainGoodAuth = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeSubdomain</methodName>
   <params>
@@ -578,7 +578,7 @@ const removeSubdomainGoodAuth = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const removeSubdomainBadAuth = `<?xml version="1.0"?>
+const removeSubdomainBadAuth = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeSubdomain</methodName>
   <params>
@@ -605,7 +605,7 @@ const removeSubdomainBadAuth = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const removeSubdomainNonValidDomain = `<?xml version="1.0"?>
+const removeSubdomainNonValidDomain = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeSubdomain</methodName>
   <params>
@@ -632,7 +632,7 @@ const removeSubdomainNonValidDomain = `<?xml version="1.0"?>
   </params>
 </methodCall>`
 
-const removeSubdomainEmptyResponse = `<?xml version="1.0"?>
+const removeSubdomainEmptyResponse = `<?xml version="1.0" encoding="UTF-8"?>
 <methodCall>
   <methodName>removeSubdomain</methodName>
   <params>
diff --git a/providers/dns/loopia/internal/types.go b/providers/dns/loopia/internal/types.go
index 9d96da40..c286c01f 100644
--- a/providers/dns/loopia/internal/types.go
+++ b/providers/dns/loopia/internal/types.go
@@ -77,13 +77,13 @@ type responseFault struct {
 func (r responseFault) faultCode() int      { return r.FaultCode }
 func (r responseFault) faultString() string { return r.FaultString }
 
-type rpcError struct {
-	faultCode   int
-	faultString string
+type RPCError struct {
+	FaultCode   int
+	FaultString string
 }
 
-func (e rpcError) Error() string {
-	return fmt.Sprintf("RPC Error: (%d) %s", e.faultCode, e.faultString)
+func (e RPCError) Error() string {
+	return fmt.Sprintf("RPC Error: (%d) %s", e.FaultCode, e.FaultString)
 }
 
 type recordObjectsResponse struct {
diff --git a/providers/dns/loopia/loopia.go b/providers/dns/loopia/loopia.go
index 579a3efc..ed0fd02d 100644
--- a/providers/dns/loopia/loopia.go
+++ b/providers/dns/loopia/loopia.go
@@ -2,6 +2,7 @@
 package loopia
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -30,10 +31,10 @@ const (
 )
 
 type dnsClient interface {
-	AddTXTRecord(domain string, subdomain string, ttl int, value string) error
-	RemoveTXTRecord(domain string, subdomain string, recordID int) error
-	GetTXTRecords(domain string, subdomain string) ([]internal.RecordObj, error)
-	RemoveSubdomain(domain, subdomain string) error
+	AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error
+	RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error
+	GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]internal.RecordObj, error)
+	RemoveSubdomain(ctx context.Context, domain, subdomain string) error
 }
 
 // Config is used to configure the creation of the DNSProvider.
@@ -67,6 +68,7 @@ type DNSProvider struct {
 	inProgressInfo map[string]int
 	inProgressMu   sync.Mutex
 
+	// only for testing purpose.
 	findZoneByFqdn func(fqdn string) (string, error)
 }
 
@@ -135,12 +137,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("loopia: %w", err)
 	}
 
-	err = d.client.AddTXTRecord(authZone, subDomain, d.config.TTL, info.Value)
+	ctx := context.Background()
+
+	err = d.client.AddTXTRecord(ctx, authZone, subDomain, d.config.TTL, info.Value)
 	if err != nil {
 		return fmt.Errorf("loopia: failed to add TXT record: %w", err)
 	}
 
-	txtRecords, err := d.client.GetTXTRecords(authZone, subDomain)
+	txtRecords, err := d.client.GetTXTRecords(ctx, authZone, subDomain)
 	if err != nil {
 		return fmt.Errorf("loopia: failed to get TXT records: %w", err)
 	}
@@ -170,12 +174,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	d.inProgressMu.Lock()
 	defer d.inProgressMu.Unlock()
 
-	err = d.client.RemoveTXTRecord(authZone, subDomain, d.inProgressInfo[token])
+	ctx := context.Background()
+
+	err = d.client.RemoveTXTRecord(ctx, authZone, subDomain, d.inProgressInfo[token])
 	if err != nil {
 		return fmt.Errorf("loopia: failed to remove TXT record: %w", err)
 	}
 
-	records, err := d.client.GetTXTRecords(authZone, subDomain)
+	records, err := d.client.GetTXTRecords(ctx, authZone, subDomain)
 	if err != nil {
 		return fmt.Errorf("loopia: failed to get TXT records: %w", err)
 	}
@@ -184,7 +190,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return nil
 	}
 
-	err = d.client.RemoveSubdomain(authZone, subDomain)
+	err = d.client.RemoveSubdomain(ctx, authZone, subDomain)
 	if err != nil {
 		return fmt.Errorf("loopia: failed to remove sub-domain: %w", err)
 	}
@@ -193,13 +199,15 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 }
 
 func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) {
-	authZone, _ := d.findZoneByFqdn(fqdn)
-	authZone = dns01.UnFqdn(authZone)
+	authZone, err := d.findZoneByFqdn(fqdn)
+	if err != nil {
+		return "", "", fmt.Errorf("desec: could not find zone for FQDN %q: %w", fqdn, err)
+	}
 
 	subDomain, err := dns01.ExtractSubDomain(fqdn, authZone)
 	if err != nil {
 		return "", "", err
 	}
 
-	return subDomain, authZone, nil
+	return subDomain, dns01.UnFqdn(authZone), nil
 }
diff --git a/providers/dns/loopia/loopia_mock_test.go b/providers/dns/loopia/loopia_mock_test.go
index b8f108fa..79fe2b13 100644
--- a/providers/dns/loopia/loopia_mock_test.go
+++ b/providers/dns/loopia/loopia_mock_test.go
@@ -1,6 +1,7 @@
 package loopia
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"testing"
@@ -215,22 +216,22 @@ type mockedClient struct {
 	mock.Mock
 }
 
-func (c *mockedClient) RemoveTXTRecord(domain string, subdomain string, recordID int) error {
+func (c *mockedClient) RemoveTXTRecord(ctx context.Context, domain string, subdomain string, recordID int) error {
 	args := c.Called(domain, subdomain, recordID)
 	return args.Error(0)
 }
 
-func (c *mockedClient) AddTXTRecord(domain string, subdomain string, ttl int, value string) error {
+func (c *mockedClient) AddTXTRecord(ctx context.Context, domain string, subdomain string, ttl int, value string) error {
 	args := c.Called(domain, subdomain, ttl, value)
 	return args.Error(0)
 }
 
-func (c *mockedClient) GetTXTRecords(domain string, subdomain string) ([]internal.RecordObj, error) {
+func (c *mockedClient) GetTXTRecords(ctx context.Context, domain string, subdomain string) ([]internal.RecordObj, error) {
 	args := c.Called(domain, subdomain)
 	return args.Get(0).([]internal.RecordObj), args.Error(1)
 }
 
-func (c *mockedClient) RemoveSubdomain(domain, subdomain string) error {
+func (c *mockedClient) RemoveSubdomain(ctx context.Context, domain, subdomain string) error {
 	args := c.Called(domain, subdomain)
 	return args.Error(0)
 }
diff --git a/providers/dns/luadns/internal/client.go b/providers/dns/luadns/internal/client.go
index 6f853cc2..8e46418f 100644
--- a/providers/dns/luadns/internal/client.go
+++ b/providers/dns/luadns/internal/client.go
@@ -2,10 +2,16 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // defaultBaseURL represents the API endpoint to call.
@@ -13,49 +19,39 @@ const defaultBaseURL = "https://api.luadns.com"
 
 // Client Lua DNS API client.
 type Client struct {
-	HTTPClient *http.Client
-	BaseURL    string
-
 	apiUsername string
 	apiToken    string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new Client.
 func NewClient(apiUsername, apiToken string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
-		HTTPClient:  http.DefaultClient,
-		BaseURL:     defaultBaseURL,
 		apiUsername: apiUsername,
 		apiToken:    apiToken,
+		baseURL:     baseURL,
+		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // ListZones gets all the hosted zones.
 // https://luadns.com/api.html#list-zones
-func (d *Client) ListZones() ([]DNSZone, error) {
-	resp, err := d.do(http.MethodGet, "/v1/zones", nil)
+func (c *Client) ListZones(ctx context.Context) ([]DNSZone, error) {
+	endpoint := c.baseURL.JoinPath("v1", "zones")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		bodyBytes, _ := io.ReadAll(resp.Body)
-
-		var errResp errorResponse
-		err = json.Unmarshal(bodyBytes, &errResp)
-		if err == nil {
-			return nil, fmt.Errorf("api call error: Status=%v: %w", resp.StatusCode, errResp)
-		}
-
-		return nil, fmt.Errorf("api call error: Status=%d: %s", resp.StatusCode, string(bodyBytes))
-	}
-
 	var zones []DNSZone
-	err = json.NewDecoder(resp.Body).Decode(&zones)
+	err = c.do(req, &zones)
 	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
+		return nil, fmt.Errorf("could not list zones: %w", err)
 	}
 
 	return zones, nil
@@ -63,39 +59,18 @@ func (d *Client) ListZones() ([]DNSZone, error) {
 
 // CreateRecord creates a new record in a zone.
 // https://luadns.com/api.html#create-a-record
-func (d *Client) CreateRecord(zone DNSZone, newRecord DNSRecord) (*DNSRecord, error) {
-	body, err := json.Marshal(newRecord)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshal request body: %w", err)
-	}
+func (c *Client) CreateRecord(ctx context.Context, zone DNSZone, newRecord DNSRecord) (*DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("v1", "zones", strconv.Itoa(zone.ID), "records")
 
-	resource := fmt.Sprintf("/v1/zones/%d/records", zone.ID)
-
-	resp, err := d.do(http.MethodPost, resource, bytes.NewReader(body))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, newRecord)
 	if err != nil {
 		return nil, err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		bodyBytes, _ := io.ReadAll(resp.Body)
-
-		var errResp errorResponse
-		err = json.Unmarshal(bodyBytes, &errResp)
-		if err == nil {
-			return nil, fmt.Errorf("could not create record %v: Status=%d: %w",
-				string(body), resp.StatusCode, errResp)
-		}
-
-		return nil, fmt.Errorf("could not create record %v: Status=%d: %s",
-			string(body), resp.StatusCode, string(bodyBytes))
-	}
-
 	var record *DNSRecord
-	err = json.NewDecoder(resp.Body).Decode(&record)
+	err = c.do(req, &record)
 	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
+		return nil, fmt.Errorf("could not create record %#v: %w", record, err)
 	}
 
 	return record, nil
@@ -103,47 +78,85 @@ func (d *Client) CreateRecord(zone DNSZone, newRecord DNSRecord) (*DNSRecord, er
 
 // DeleteRecord deletes a record.
 // https://luadns.com/api.html#delete-a-record
-func (d *Client) DeleteRecord(record *DNSRecord) error {
-	body, err := json.Marshal(record)
-	if err != nil {
-		return fmt.Errorf("failed to marshal request body: %w", err)
-	}
+func (c *Client) DeleteRecord(ctx context.Context, record *DNSRecord) error {
+	endpoint := c.baseURL.JoinPath("v1", "zones", strconv.Itoa(record.ZoneID), "records", strconv.Itoa(record.ID))
 
-	resource := fmt.Sprintf("/v1/zones/%d/records/%d", record.ZoneID, record.ID)
-
-	resp, err := d.do(http.MethodDelete, resource, bytes.NewReader(body))
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, record)
 	if err != nil {
 		return err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		bodyBytes, _ := io.ReadAll(resp.Body)
-
-		var errResp errorResponse
-		err = json.Unmarshal(bodyBytes, &errResp)
-		if err == nil {
-			return fmt.Errorf("could not delete record %v: Status=%d: %w",
-				string(body), resp.StatusCode, errResp)
-		}
-
-		return fmt.Errorf("could not delete record %v: Status=%d: %s",
-			string(body), resp.StatusCode, string(bodyBytes))
+	err = c.do(req, nil)
+	if err != nil {
+		return fmt.Errorf("could not delete record %#v: %w", record, err)
 	}
 
 	return nil
 }
 
-func (d *Client) do(method, uri string, body io.Reader) (*http.Response, error) {
-	req, err := http.NewRequest(method, fmt.Sprintf("%s%s", d.BaseURL, uri), body)
+func (c *Client) do(req *http.Request, result any) error {
+	req.SetBasicAuth(c.apiUsername, c.apiToken)
+
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-	req.SetBasicAuth(d.apiUsername, d.apiToken)
 
-	return d.HTTPClient.Do(req)
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errResp errorResponse
+	err := json.Unmarshal(raw, &errResp)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("status=%d: %w", resp.StatusCode, errResp)
 }
diff --git a/providers/dns/luadns/internal/client_test.go b/providers/dns/luadns/internal/client_test.go
index f3cc4642..1fd3efd7 100644
--- a/providers/dns/luadns/internal/client_test.go
+++ b/providers/dns/luadns/internal/client_test.go
@@ -1,10 +1,12 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -12,13 +14,22 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_ListZones(t *testing.T) {
+func setupTest(t *testing.T, apiToken string) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	client := NewClient("me", "secretA")
-	client.BaseURL = server.URL
+	client := NewClient("me", apiToken)
+	client.baseURL, _ = url.Parse(server.URL)
+	client.HTTPClient = server.Client()
+
+	return client, mux
+}
+
+func TestClient_ListZones(t *testing.T) {
+	client, mux := setupTest(t, "secretA")
 
 	mux.HandleFunc("/v1/zones", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
@@ -46,7 +57,7 @@ func TestClient_ListZones(t *testing.T) {
 		}
 	})
 
-	zones, err := client.ListZones()
+	zones, err := client.ListZones(context.Background())
 	require.NoError(t, err)
 
 	expected := []DNSZone{
@@ -78,12 +89,7 @@ func TestClient_ListZones(t *testing.T) {
 }
 
 func TestClient_CreateRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
-	client := NewClient("me", "secretB")
-	client.BaseURL = server.URL
+	client, mux := setupTest(t, "secretB")
 
 	mux.HandleFunc("/v1/zones/1/records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
@@ -120,7 +126,7 @@ func TestClient_CreateRecord(t *testing.T) {
 		TTL:     300,
 	}
 
-	newRecord, err := client.CreateRecord(zone, record)
+	newRecord, err := client.CreateRecord(context.Background(), zone, record)
 	require.NoError(t, err)
 
 	expected := &DNSRecord{
@@ -136,12 +142,7 @@ func TestClient_CreateRecord(t *testing.T) {
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
-	client := NewClient("me", "secretC")
-	client.BaseURL = server.URL
+	client, mux := setupTest(t, "secretC")
 
 	mux.HandleFunc("/v1/zones/1/records/2", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodDelete {
@@ -178,6 +179,6 @@ func TestClient_DeleteRecord(t *testing.T) {
 		ZoneID:  1,
 	}
 
-	err := client.DeleteRecord(record)
+	err := client.DeleteRecord(context.Background(), record)
 	require.NoError(t, err)
 }
diff --git a/providers/dns/luadns/internal/model.go b/providers/dns/luadns/internal/types.go
similarity index 100%
rename from providers/dns/luadns/internal/model.go
rename to providers/dns/luadns/internal/types.go
diff --git a/providers/dns/luadns/luadns.go b/providers/dns/luadns/luadns.go
index 089c6be6..5f6f6cc2 100644
--- a/providers/dns/luadns/luadns.go
+++ b/providers/dns/luadns/luadns.go
@@ -2,6 +2,7 @@
 package luadns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -114,14 +115,16 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zones, err := d.client.ListZones()
+	ctx := context.Background()
+
+	zones, err := d.client.ListZones(ctx)
 	if err != nil {
 		return fmt.Errorf("luadns: failed to get zones: %w", err)
 	}
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("luadns: failed to find zone: %w", err)
+		return fmt.Errorf("luadns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	zone := findZone(zones, dns01.UnFqdn(authZone))
@@ -136,7 +139,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:     d.config.TTL,
 	}
 
-	record, err := d.client.CreateRecord(*zone, newRecord)
+	record, err := d.client.CreateRecord(ctx, *zone, newRecord)
 	if err != nil {
 		return fmt.Errorf("luadns: failed to create record: %w", err)
 	}
@@ -160,7 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("luadns: unknown record ID for '%s'", info.EffectiveFQDN)
 	}
 
-	err := d.client.DeleteRecord(record)
+	err := d.client.DeleteRecord(context.Background(), record)
 	if err != nil {
 		return fmt.Errorf("luadns: failed to delete record: %w", err)
 	}
diff --git a/providers/dns/mydnsjp/client.go b/providers/dns/mydnsjp/client.go
deleted file mode 100644
index 16bfa734..00000000
--- a/providers/dns/mydnsjp/client.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package mydnsjp
-
-import (
-	"fmt"
-	"io"
-	"net/http"
-	"net/url"
-	"strings"
-)
-
-func (d *DNSProvider) doRequest(domain, value, cmd string) error {
-	req, err := d.buildRequest(domain, value, cmd)
-	if err != nil {
-		return err
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return fmt.Errorf("error querying API: %w", err)
-	}
-
-	defer resp.Body.Close()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		var content []byte
-		content, err = io.ReadAll(resp.Body)
-		if err != nil {
-			return err
-		}
-
-		return fmt.Errorf("request %s failed [status code %d]: %s", req.URL, resp.StatusCode, string(content))
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) buildRequest(domain, value, cmd string) (*http.Request, error) {
-	params := url.Values{}
-	params.Set("CERTBOT_DOMAIN", domain)
-	params.Set("CERTBOT_VALIDATION", value)
-	params.Set("EDIT_CMD", cmd)
-
-	req, err := http.NewRequest(http.MethodPost, defaultBaseURL, strings.NewReader(params.Encode()))
-	if err != nil {
-		return nil, fmt.Errorf("invalid request: %w", err)
-	}
-
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	req.SetBasicAuth(d.config.MasterID, d.config.Password)
-
-	return req, nil
-}
diff --git a/providers/dns/mydnsjp/internal/client.go b/providers/dns/mydnsjp/internal/client.go
new file mode 100644
index 00000000..9859ed68
--- /dev/null
+++ b/providers/dns/mydnsjp/internal/client.go
@@ -0,0 +1,81 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://www.mydns.jp/directedit.html"
+
+// Client the MyDNS.jp client.
+type Client struct {
+	masterID string
+	password string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient Creates a new Client.
+func NewClient(masterID string, password string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		masterID:   masterID,
+		password:   password,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c Client) AddTXTRecord(ctx context.Context, domain, value string) error {
+	return c.doRequest(ctx, domain, value, "REGIST")
+}
+
+func (c Client) DeleteTXTRecord(ctx context.Context, domain, value string) error {
+	return c.doRequest(ctx, domain, value, "DELETE")
+}
+
+func (c Client) buildRequest(ctx context.Context, domain, value, cmd string) (*http.Request, error) {
+	params := url.Values{}
+	params.Set("CERTBOT_DOMAIN", domain)
+	params.Set("CERTBOT_VALIDATION", value)
+	params.Set("EDIT_CMD", cmd)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String(), strings.NewReader(params.Encode()))
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	return req, nil
+}
+
+func (c Client) doRequest(ctx context.Context, domain, value, cmd string) error {
+	req, err := c.buildRequest(ctx, domain, value, cmd)
+	if err != nil {
+		return err
+	}
+
+	req.SetBasicAuth(c.masterID, c.password)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	return nil
+}
diff --git a/providers/dns/mydnsjp/internal/client_test.go b/providers/dns/mydnsjp/internal/client_test.go
new file mode 100644
index 00000000..a68f6888
--- /dev/null
+++ b/providers/dns/mydnsjp/internal/client_test.go
@@ -0,0 +1,92 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, cmdName string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed)
+			return
+		}
+
+		username, password, ok := req.BasicAuth()
+		if !ok {
+			http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return
+		}
+
+		if username != "xxx" {
+			http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "xxx"), http.StatusUnauthorized)
+			return
+		}
+
+		if password != "secret" {
+			http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized)
+			return
+		}
+
+		if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
+			http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest)
+			return
+		}
+
+		err := req.ParseForm()
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		domain := req.Form.Get("CERTBOT_DOMAIN")
+		if domain != "example.com" {
+			http.Error(rw, fmt.Sprintf("unexpected CERTBOT_DOMAIN: %s", domain), http.StatusBadRequest)
+			return
+		}
+
+		validation := req.Form.Get("CERTBOT_VALIDATION")
+		if validation != "txt" {
+			http.Error(rw, fmt.Sprintf("unexpected CERTBOT_VALIDATION: %s", validation), http.StatusBadRequest)
+			return
+		}
+
+		cmd := req.Form.Get("EDIT_CMD")
+		if cmd != cmdName {
+			http.Error(rw, fmt.Sprintf("unexpected EDIT_CMD: %s", cmd), http.StatusBadRequest)
+			return
+		}
+	})
+
+	client := NewClient("xxx", "secret")
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func TestClient_AddTXTRecord(t *testing.T) {
+	client := setupTest(t, "REGIST")
+
+	err := client.AddTXTRecord(context.Background(), "example.com", "txt")
+	require.NoError(t, err)
+}
+
+func TestClient_DeleteTXTRecord(t *testing.T) {
+	client := setupTest(t, "DELETE")
+
+	err := client.DeleteTXTRecord(context.Background(), "example.com", "txt")
+	require.NoError(t, err)
+}
diff --git a/providers/dns/mydnsjp/mydnsjp.go b/providers/dns/mydnsjp/mydnsjp.go
index da52c8a8..beaaf49a 100644
--- a/providers/dns/mydnsjp/mydnsjp.go
+++ b/providers/dns/mydnsjp/mydnsjp.go
@@ -2,6 +2,7 @@
 package mydnsjp
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -9,10 +10,9 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/mydnsjp/internal"
 )
 
-const defaultBaseURL = "https://www.mydns.jp/directedit.html"
-
 // Environment variables names.
 const (
 	envNamespace = "MYDNSJP_"
@@ -48,6 +48,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for MyDNS.jp.
@@ -75,7 +76,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("mydnsjp: some credentials information are missing")
 	}
 
-	return &DNSProvider{config: config}, nil
+	return &DNSProvider{
+		config: config,
+		client: internal.NewClient(config.MasterID, config.Password),
+	}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -89,7 +93,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	err := d.doRequest(domain, info.Value, "REGIST")
+	err := d.client.AddTXTRecord(context.Background(), domain, info.Value)
 	if err != nil {
 		return fmt.Errorf("mydnsjp: %w", err)
 	}
@@ -101,7 +105,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	err := d.doRequest(domain, info.Value, "DELETE")
+	err := d.client.DeleteTXTRecord(context.Background(), domain, info.Value)
 	if err != nil {
 		return fmt.Errorf("mydnsjp: %w", err)
 	}
diff --git a/providers/dns/mythicbeasts/client.go b/providers/dns/mythicbeasts/client.go
deleted file mode 100644
index 473e4e77..00000000
--- a/providers/dns/mythicbeasts/client.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package mythicbeasts
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"time"
-)
-
-const (
-	apiBaseURL  = "https://api.mythic-beasts.com/dns/v2"
-	authBaseURL = "https://auth.mythic-beasts.com/login"
-)
-
-type authResponse struct {
-	// The bearer token for use in API requests
-	Token string `json:"access_token"`
-
-	// The maximum lifetime of the token in seconds
-	Lifetime int `json:"expires_in"`
-
-	// The token type (must be 'bearer')
-	TokenType string `json:"token_type"`
-
-	Deadline time.Time `json:"-"`
-}
-
-type authResponseError struct {
-	ErrorMsg         string `json:"error"`
-	ErrorDescription string `json:"error_description"`
-}
-
-func (a authResponseError) Error() string {
-	return fmt.Sprintf("%s: %s", a.ErrorMsg, a.ErrorDescription)
-}
-
-type createTXTRequest struct {
-	Records []createTXTRecord `json:"records"`
-}
-
-type createTXTRecord struct {
-	Host string `json:"host"`
-	TTL  int    `json:"ttl"`
-	Type string `json:"type"`
-	Data string `json:"data"`
-}
-
-type createTXTResponse struct {
-	Added   int    `json:"records_added"`
-	Removed int    `json:"records_removed"`
-	Message string `json:"message"`
-}
-
-type deleteTXTResponse struct {
-	Removed int    `json:"records_removed"`
-	Message string `json:"message"`
-}
-
-// Logs into mythic beasts and acquires a bearer token for use in future API calls.
-// https://www.mythic-beasts.com/support/api/auth#sec-obtaining-a-token
-func (d *DNSProvider) login() error {
-	d.muToken.Lock()
-	defer d.muToken.Unlock()
-
-	if d.token != nil && time.Now().Before(d.token.Deadline) {
-		// Already authenticated, stop now
-		return nil
-	}
-
-	req, err := http.NewRequest(http.MethodPost, d.config.AuthAPIEndpoint.String(), strings.NewReader("grant_type=client_credentials"))
-	if err != nil {
-		return err
-	}
-
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	req.SetBasicAuth(d.config.UserName, d.config.Password)
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("login: %w", err)
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		if resp.StatusCode < 400 || resp.StatusCode > 499 {
-			return fmt.Errorf("login: unknown error in auth API: %d", resp.StatusCode)
-		}
-
-		// Returned body should be a JSON thing
-		errResp := &authResponseError{}
-		err = json.Unmarshal(body, errResp)
-		if err != nil {
-			return fmt.Errorf("login: error parsing error: %w", err)
-		}
-
-		return fmt.Errorf("login: %d: %w", resp.StatusCode, errResp)
-	}
-
-	authResp := authResponse{}
-	err = json.Unmarshal(body, &authResp)
-	if err != nil {
-		return fmt.Errorf("login: error parsing response: %w", err)
-	}
-
-	if authResp.TokenType != "bearer" {
-		return fmt.Errorf("login: received unexpected token type: %s", authResp.TokenType)
-	}
-
-	authResp.Deadline = time.Now().Add(time.Duration(authResp.Lifetime) * time.Second)
-	d.token = &authResp
-
-	// Success
-	return nil
-}
-
-// https://www.mythic-beasts.com/support/api/dnsv2#ep-get-zoneszonerecords
-func (d *DNSProvider) createTXTRecord(zone, leaf, value string) error {
-	if d.token == nil {
-		return fmt.Errorf("createTXTRecord: not logged in")
-	}
-
-	createReq := createTXTRequest{
-		Records: []createTXTRecord{{
-			Host: leaf,
-			TTL:  d.config.TTL,
-			Type: "TXT",
-			Data: value,
-		}},
-	}
-
-	reqBody, err := json.Marshal(createReq)
-	if err != nil {
-		return fmt.Errorf("createTXTRecord: marshaling request body failed: %w", err)
-	}
-
-	endpoint := d.config.APIEndpoint.JoinPath("zones", zone, "records", leaf, "TXT")
-
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(reqBody))
-	if err != nil {
-		return fmt.Errorf("createTXTRecord: %w", err)
-	}
-
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.token.Token))
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return fmt.Errorf("createTXTRecord: unable to perform HTTP request: %w", err)
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("createTXTRecord: %w", err)
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("createTXTRecord: error in API: %d", resp.StatusCode)
-	}
-
-	createResp := createTXTResponse{}
-	err = json.Unmarshal(body, &createResp)
-	if err != nil {
-		return fmt.Errorf("createTXTRecord: error parsing response: %w", err)
-	}
-
-	if createResp.Added != 1 {
-		return errors.New("createTXTRecord: did not add TXT record for some reason")
-	}
-
-	// Success
-	return nil
-}
-
-// https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords
-func (d *DNSProvider) removeTXTRecord(zone, leaf, value string) error {
-	if d.token == nil {
-		return fmt.Errorf("removeTXTRecord: not logged in")
-	}
-
-	endpoint := d.config.APIEndpoint.JoinPath("zones", zone, "records", leaf, "TXT")
-
-	query := endpoint.Query()
-	query.Add("data", value)
-	endpoint.RawQuery = query.Encode()
-
-	req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil)
-	if err != nil {
-		return fmt.Errorf("removeTXTRecord: %w", err)
-	}
-
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.token.Token))
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return fmt.Errorf("removeTXTRecord: unable to perform HTTP request: %w", err)
-	}
-
-	defer func() { _ = resp.Body.Close() }()
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("removeTXTRecord: %w", err)
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("removeTXTRecord: error in API: %d", resp.StatusCode)
-	}
-
-	deleteResp := deleteTXTResponse{}
-	err = json.Unmarshal(body, &deleteResp)
-	if err != nil {
-		return fmt.Errorf("removeTXTRecord: error parsing response: %w", err)
-	}
-
-	if deleteResp.Removed != 1 {
-		return errors.New("removeTXTRecord: did not add TXT record for some reason")
-	}
-
-	// Success
-	return nil
-}
diff --git a/providers/dns/mythicbeasts/internal/client.go b/providers/dns/mythicbeasts/internal/client.go
new file mode 100644
index 00000000..7f7b0446
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/client.go
@@ -0,0 +1,186 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"sync"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// Default API endpoints.
+const (
+	APIBaseURL  = "https://api.mythic-beasts.com/dns/v2"
+	AuthBaseURL = "https://auth.mythic-beasts.com/login"
+)
+
+// Client the EasyDNS API client.
+type Client struct {
+	username string
+	password string
+
+	APIEndpoint  *url.URL
+	AuthEndpoint *url.URL
+	HTTPClient   *http.Client
+
+	token   *Token
+	muToken sync.Mutex
+}
+
+// NewClient Creates a new Client.
+func NewClient(username string, password string) *Client {
+	apiEndpoint, _ := url.Parse(APIBaseURL)
+	authEndpoint, _ := url.Parse(AuthBaseURL)
+
+	return &Client{
+		username:     username,
+		password:     password,
+		APIEndpoint:  apiEndpoint,
+		AuthEndpoint: authEndpoint,
+		HTTPClient:   &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// CreateTXTRecord creates a TXT record.
+// https://www.mythic-beasts.com/support/api/dnsv2#ep-get-zoneszonerecords
+func (c *Client) CreateTXTRecord(ctx context.Context, zone, leaf, value string, ttl int) error {
+	resp, err := c.createTXTRecord(ctx, zone, leaf, "TXT", value, ttl)
+	if err != nil {
+		return err
+	}
+
+	if resp.Added != 1 {
+		return fmt.Errorf("did not add TXT record for some reason: %s", resp.Message)
+	}
+
+	// Success
+	return nil
+}
+
+// RemoveTXTRecord removes a TXT records.
+// https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords
+func (c *Client) RemoveTXTRecord(ctx context.Context, zone, leaf, value string) error {
+	resp, err := c.removeTXTRecord(ctx, zone, leaf, "TXT", value)
+	if err != nil {
+		return err
+	}
+
+	if resp.Removed != 1 {
+		return fmt.Errorf("did not remove TXT record for some reason: %s", resp.Message)
+	}
+
+	// Success
+	return nil
+}
+
+// https://www.mythic-beasts.com/support/api/dnsv2#ep-post-zoneszonerecords
+func (c *Client) createTXTRecord(ctx context.Context, zone, leaf, recordType, value string, ttl int) (*createTXTResponse, error) {
+	endpoint := c.APIEndpoint.JoinPath("zones", zone, "records", leaf, recordType)
+
+	createReq := createTXTRequest{
+		Records: []createTXTRecord{{
+			Host: leaf,
+			TTL:  ttl,
+			Type: "TXT",
+			Data: value,
+		}},
+	}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, createReq)
+	if err != nil {
+		return nil, err
+	}
+
+	resp := &createTXTResponse{}
+	err = c.do(req, resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
+// https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords
+func (c *Client) removeTXTRecord(ctx context.Context, zone, leaf, recordType, value string) (*deleteTXTResponse, error) {
+	endpoint := c.APIEndpoint.JoinPath("zones", zone, "records", leaf, recordType)
+
+	query := endpoint.Query()
+	query.Add("data", value)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	resp := &deleteTXTResponse{}
+
+	err = c.do(req, resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	tok := getToken(req.Context())
+	if tok != nil {
+		req.Header.Set("Authorization", "Bearer "+tok.Token)
+	} else {
+		return fmt.Errorf("not logged in")
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/mythicbeasts/internal/client_test.go b/providers/dns/mythicbeasts/internal/client_test.go
new file mode 100644
index 00000000..7e385798
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/client_test.go
@@ -0,0 +1,69 @@
+package internal
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, handler)
+
+	client := NewClient("user", "secret")
+	client.HTTPClient = server.Client()
+	client.APIEndpoint, _ = url.Parse(server.URL)
+	client.token = &Token{
+		Token:     "secret",
+		Lifetime:  60,
+		TokenType: "bearer",
+		Deadline:  time.Now().Add(1 * time.Minute),
+	}
+
+	return client
+}
+
+func writeFixtureHandler(method, filename string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		file, err := os.Open(filepath.Join("fixtures", filename))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		defer func() { _ = file.Close() }()
+
+		_, _ = io.Copy(rw, file)
+	}
+}
+
+func TestClient_CreateTXTRecord(t *testing.T) {
+	client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodPost, "post-zoneszonerecords.json"))
+
+	err := client.CreateTXTRecord(mockContext(), "example.com", "foo", "txt", 120)
+	require.NoError(t, err)
+}
+
+func TestClient_RemoveTXTRecord(t *testing.T) {
+	client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodDelete, "delete-zoneszonerecords.json"))
+
+	err := client.RemoveTXTRecord(mockContext(), "example.com", "foo", "txt")
+	require.NoError(t, err)
+}
diff --git a/providers/dns/mythicbeasts/internal/fixtures/delete-zoneszonerecords.json b/providers/dns/mythicbeasts/internal/fixtures/delete-zoneszonerecords.json
new file mode 100644
index 00000000..5bb325af
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/fixtures/delete-zoneszonerecords.json
@@ -0,0 +1,4 @@
+{
+  "records_removed": 1,
+  "message": "1 record removed"
+}
diff --git a/providers/dns/mythicbeasts/internal/fixtures/post-zoneszonerecords.json b/providers/dns/mythicbeasts/internal/fixtures/post-zoneszonerecords.json
new file mode 100644
index 00000000..96c7ab11
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/fixtures/post-zoneszonerecords.json
@@ -0,0 +1,4 @@
+{
+  "records_added": 1,
+  "message": "1 record added"
+}
diff --git a/providers/dns/mythicbeasts/internal/identity.go b/providers/dns/mythicbeasts/internal/identity.go
new file mode 100644
index 00000000..417f1c75
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/identity.go
@@ -0,0 +1,101 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+type token string
+
+const tokenKey token = "token"
+
+// obtainToken Logs into mythic beasts and acquires a bearer token for use in future API calls.
+// https://www.mythic-beasts.com/support/api/auth#sec-obtaining-a-token
+func (c *Client) obtainToken(ctx context.Context) (*Token, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.AuthEndpoint.String(), strings.NewReader("grant_type=client_credentials"))
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.SetBasicAuth(c.username, c.password)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, parseError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	tok := Token{}
+	err = json.Unmarshal(raw, &tok)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	if tok.TokenType != "bearer" {
+		return nil, fmt.Errorf("received unexpected token type: %s", tok.TokenType)
+	}
+
+	tok.Deadline = time.Now().Add(time.Duration(tok.Lifetime) * time.Second)
+
+	return &tok, nil
+}
+
+func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) {
+	c.muToken.Lock()
+	defer c.muToken.Unlock()
+
+	if c.token != nil && time.Now().Before(c.token.Deadline) {
+		// Already authenticated, stop now
+		return context.WithValue(ctx, tokenKey, c.token), nil
+	}
+
+	tok, err := c.obtainToken(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return context.WithValue(ctx, tokenKey, tok), nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	if resp.StatusCode < 400 || resp.StatusCode > 499 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, _ := io.ReadAll(resp.Body)
+
+	errResp := &authResponseError{}
+	err := json.Unmarshal(raw, errResp)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("%d: %w", resp.StatusCode, errResp)
+}
+
+func getToken(ctx context.Context) *Token {
+	tok, ok := ctx.Value(tokenKey).(*Token)
+	if !ok {
+		return nil
+	}
+
+	return tok
+}
diff --git a/providers/dns/mythicbeasts/internal/identity_test.go b/providers/dns/mythicbeasts/internal/identity_test.go
new file mode 100644
index 00000000..9d8daf82
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/identity_test.go
@@ -0,0 +1,81 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func mockContext() context.Context {
+	return context.WithValue(context.Background(), tokenKey, &Token{Token: "xxx"})
+}
+
+func tokenHandler(rw http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, http.MethodPost), http.StatusMethodNotAllowed)
+		return
+	}
+
+	username, password, ok := req.BasicAuth()
+	if !ok || username != "user" || password != "secret" {
+		http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+		return
+	}
+
+	_ = json.NewEncoder(rw).Encode(Token{
+		Token:     "xxx",
+		Lifetime:  666,
+		TokenType: "bearer",
+	})
+}
+
+func TestClient_obtainToken(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc("/", tokenHandler)
+
+	client := NewClient("user", "secret")
+	client.HTTPClient = server.Client()
+	client.AuthEndpoint, _ = url.Parse(server.URL)
+
+	assert.Nil(t, client.token)
+
+	tok, err := client.obtainToken(context.Background())
+	require.NoError(t, err)
+
+	assert.NotNil(t, tok)
+	assert.NotZero(t, tok.Deadline)
+	assert.Equal(t, "xxx", tok.Token)
+}
+
+func TestClient_CreateAuthenticatedContext(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc("/", tokenHandler)
+
+	client := NewClient("user", "secret")
+	client.HTTPClient = server.Client()
+	client.AuthEndpoint, _ = url.Parse(server.URL)
+
+	assert.Nil(t, client.token)
+
+	ctx, err := client.CreateAuthenticatedContext(context.Background())
+	require.NoError(t, err)
+
+	tok := getToken(ctx)
+
+	assert.NotNil(t, tok)
+	assert.NotZero(t, tok.Deadline)
+	assert.Equal(t, "xxx", tok.Token)
+}
diff --git a/providers/dns/mythicbeasts/internal/types.go b/providers/dns/mythicbeasts/internal/types.go
new file mode 100644
index 00000000..c68d0dc0
--- /dev/null
+++ b/providers/dns/mythicbeasts/internal/types.go
@@ -0,0 +1,50 @@
+package internal
+
+import (
+	"fmt"
+	"time"
+)
+
+type Token struct {
+	// The bearer token for use in API requests
+	Token string `json:"access_token"`
+
+	// The maximum lifetime of the token in seconds
+	Lifetime int `json:"expires_in"`
+
+	// The token type (must be 'bearer')
+	TokenType string `json:"token_type"`
+
+	Deadline time.Time `json:"-"`
+}
+
+type authResponseError struct {
+	ErrorMsg         string `json:"error"`
+	ErrorDescription string `json:"error_description"`
+}
+
+func (a authResponseError) Error() string {
+	return fmt.Sprintf("%s: %s", a.ErrorMsg, a.ErrorDescription)
+}
+
+type createTXTRequest struct {
+	Records []createTXTRecord `json:"records"`
+}
+
+type createTXTRecord struct {
+	Host string `json:"host"`
+	TTL  int    `json:"ttl"`
+	Type string `json:"type"`
+	Data string `json:"data"`
+}
+
+type createTXTResponse struct {
+	Added   int    `json:"records_added"`
+	Removed int    `json:"records_removed"`
+	Message string `json:"message"`
+}
+
+type deleteTXTResponse struct {
+	Removed int    `json:"records_removed"`
+	Message string `json:"message"`
+}
diff --git a/providers/dns/mythicbeasts/mythicbeasts.go b/providers/dns/mythicbeasts/mythicbeasts.go
index 0c787cce..7545b3fb 100644
--- a/providers/dns/mythicbeasts/mythicbeasts.go
+++ b/providers/dns/mythicbeasts/mythicbeasts.go
@@ -2,15 +2,16 @@
 package mythicbeasts
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
-	"sync"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/mythicbeasts/internal"
 )
 
 // Environment variables names.
@@ -42,12 +43,12 @@ type Config struct {
 
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() (*Config, error) {
-	apiEndpoint, err := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, apiBaseURL))
+	apiEndpoint, err := url.Parse(env.GetOrDefaultString(EnvAPIEndpoint, internal.APIBaseURL))
 	if err != nil {
 		return nil, fmt.Errorf("mythicbeasts: Unable to parse API URL: %w", err)
 	}
 
-	authEndpoint, err := url.Parse(env.GetOrDefaultString(EnvAuthAPIEndpoint, authBaseURL))
+	authEndpoint, err := url.Parse(env.GetOrDefaultString(EnvAuthAPIEndpoint, internal.AuthBaseURL))
 	if err != nil {
 		return nil, fmt.Errorf("mythicbeasts: Unable to parse AUTH API URL: %w", err)
 	}
@@ -67,10 +68,7 @@ func NewDefaultConfig() (*Config, error) {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
-
-	// token  string
-	token   *authResponse
-	muToken sync.Mutex
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for mythicbeasts DNSv2 API.
@@ -102,7 +100,21 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("mythicbeasts: incomplete credentials, missing username and/or password")
 	}
 
-	return &DNSProvider{config: config}, nil
+	client := internal.NewClient(config.UserName, config.Password)
+
+	if config.APIEndpoint != nil {
+		client.APIEndpoint = config.APIEndpoint
+	}
+
+	if config.AuthAPIEndpoint != nil {
+		client.AuthEndpoint = config.AuthAPIEndpoint
+	}
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record using the specified parameters.
@@ -111,7 +123,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("mythicbeasts: %w", err)
+		return fmt.Errorf("mythicbeasts: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -121,14 +133,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone = dns01.UnFqdn(authZone)
 
-	err = d.login()
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
-		return fmt.Errorf("mythicbeasts: %w", err)
+		return fmt.Errorf("mythicbeasts: login: %w", err)
 	}
 
-	err = d.createTXTRecord(authZone, subDomain, info.Value)
+	err = d.client.CreateTXTRecord(ctx, authZone, subDomain, info.Value, d.config.TTL)
 	if err != nil {
-		return fmt.Errorf("mythicbeasts: %w", err)
+		return fmt.Errorf("mythicbeasts: CreateTXTRecord: %w", err)
 	}
 
 	return nil
@@ -140,7 +152,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("mythicbeasts: %w", err)
+		return fmt.Errorf("mythicbeasts: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -150,14 +162,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone = dns01.UnFqdn(authZone)
 
-	err = d.login()
+	ctx, err := d.client.CreateAuthenticatedContext(context.Background())
 	if err != nil {
-		return fmt.Errorf("mythicbeasts: %w", err)
+		return fmt.Errorf("mythicbeasts: login: %w", err)
 	}
 
-	err = d.removeTXTRecord(authZone, subDomain, info.Value)
+	err = d.client.RemoveTXTRecord(ctx, authZone, subDomain, info.Value)
 	if err != nil {
-		return fmt.Errorf("mythicbeasts: %w", err)
+		return fmt.Errorf("mythicbeasts: RemoveTXTRecord: %w", err)
 	}
 
 	return nil
diff --git a/providers/dns/namecheap/client.go b/providers/dns/namecheap/client.go
deleted file mode 100644
index 6d62df8b..00000000
--- a/providers/dns/namecheap/client.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package namecheap
-
-import (
-	"encoding/xml"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"net/url"
-	"strings"
-)
-
-// Record describes a DNS record returned by the Namecheap DNS gethosts API.
-// Namecheap uses the term "host" to refer to all DNS records that include
-// a host field (A, AAAA, CNAME, NS, TXT, URL).
-type Record struct {
-	Type    string `xml:",attr"`
-	Name    string `xml:",attr"`
-	Address string `xml:",attr"`
-	MXPref  string `xml:",attr"`
-	TTL     string `xml:",attr"`
-}
-
-// apiError describes an error record in a namecheap API response.
-type apiError struct {
-	Number      int    `xml:",attr"`
-	Description string `xml:",innerxml"`
-}
-
-type setHostsResponse struct {
-	XMLName xml.Name   `xml:"ApiResponse"`
-	Status  string     `xml:"Status,attr"`
-	Errors  []apiError `xml:"Errors>Error"`
-	Result  struct {
-		IsSuccess string `xml:",attr"`
-	} `xml:"CommandResponse>DomainDNSSetHostsResult"`
-}
-
-type getHostsResponse struct {
-	XMLName xml.Name   `xml:"ApiResponse"`
-	Status  string     `xml:"Status,attr"`
-	Errors  []apiError `xml:"Errors>Error"`
-	Hosts   []Record   `xml:"CommandResponse>DomainDNSGetHostsResult>host"`
-}
-
-// getHosts reads the full list of DNS host records.
-// https://www.namecheap.com/support/api/methods/domains-dns/get-hosts.aspx
-func (d *DNSProvider) getHosts(sld, tld string) ([]Record, error) {
-	request, err := d.newRequestGet("namecheap.domains.dns.getHosts",
-		addParam("SLD", sld),
-		addParam("TLD", tld),
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	var ghr getHostsResponse
-	err = d.do(request, &ghr)
-	if err != nil {
-		return nil, err
-	}
-
-	if len(ghr.Errors) > 0 {
-		return nil, fmt.Errorf("%s [%d]", ghr.Errors[0].Description, ghr.Errors[0].Number)
-	}
-
-	return ghr.Hosts, nil
-}
-
-// setHosts writes the full list of DNS host records .
-// https://www.namecheap.com/support/api/methods/domains-dns/set-hosts.aspx
-func (d *DNSProvider) setHosts(sld, tld string, hosts []Record) error {
-	req, err := d.newRequestPost("namecheap.domains.dns.setHosts",
-		addParam("SLD", sld),
-		addParam("TLD", tld),
-		func(values url.Values) {
-			for i, h := range hosts {
-				ind := fmt.Sprintf("%d", i+1)
-				values.Add("HostName"+ind, h.Name)
-				values.Add("RecordType"+ind, h.Type)
-				values.Add("Address"+ind, h.Address)
-				values.Add("MXPref"+ind, h.MXPref)
-				values.Add("TTL"+ind, h.TTL)
-			}
-		},
-	)
-	if err != nil {
-		return err
-	}
-
-	var shr setHostsResponse
-	err = d.do(req, &shr)
-	if err != nil {
-		return err
-	}
-
-	if len(shr.Errors) > 0 {
-		return fmt.Errorf("%s [%d]", shr.Errors[0].Description, shr.Errors[0].Number)
-	}
-	if shr.Result.IsSuccess != "true" {
-		return errors.New("setHosts failed")
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) do(req *http.Request, out interface{}) error {
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		var body []byte
-		body, err = readBody(resp)
-		if err != nil {
-			return fmt.Errorf("HTTP error %d [%s]: %w", resp.StatusCode, http.StatusText(resp.StatusCode), err)
-		}
-		return fmt.Errorf("HTTP error %d [%s]: %s", resp.StatusCode, http.StatusText(resp.StatusCode), string(body))
-	}
-
-	body, err := readBody(resp)
-	if err != nil {
-		return err
-	}
-
-	return xml.Unmarshal(body, out)
-}
-
-func (d *DNSProvider) newRequestGet(cmd string, params ...func(url.Values)) (*http.Request, error) {
-	query := d.makeQuery(cmd, params...)
-
-	reqURL, err := url.Parse(d.config.BaseURL)
-	if err != nil {
-		return nil, err
-	}
-
-	reqURL.RawQuery = query.Encode()
-
-	return http.NewRequest(http.MethodGet, reqURL.String(), nil)
-}
-
-func (d *DNSProvider) newRequestPost(cmd string, params ...func(url.Values)) (*http.Request, error) {
-	query := d.makeQuery(cmd, params...)
-
-	req, err := http.NewRequest(http.MethodPost, d.config.BaseURL, strings.NewReader(query.Encode()))
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
-	return req, nil
-}
-
-func (d *DNSProvider) makeQuery(cmd string, params ...func(url.Values)) url.Values {
-	queryParams := make(url.Values)
-	queryParams.Set("ApiUser", d.config.APIUser)
-	queryParams.Set("ApiKey", d.config.APIKey)
-	queryParams.Set("UserName", d.config.APIUser)
-	queryParams.Set("Command", cmd)
-	queryParams.Set("ClientIp", d.config.ClientIP)
-
-	for _, param := range params {
-		param(queryParams)
-	}
-
-	return queryParams
-}
-
-func addParam(key, value string) func(url.Values) {
-	return func(values url.Values) {
-		values.Set(key, value)
-	}
-}
-
-func readBody(resp *http.Response) ([]byte, error) {
-	if resp.Body == nil {
-		return nil, errors.New("response body is nil")
-	}
-
-	defer resp.Body.Close()
-
-	rawBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	return rawBody, nil
-}
diff --git a/providers/dns/namecheap/internal/client.go b/providers/dns/namecheap/internal/client.go
new file mode 100644
index 00000000..f2124f83
--- /dev/null
+++ b/providers/dns/namecheap/internal/client.go
@@ -0,0 +1,175 @@
+package internal
+
+import (
+	"context"
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// Default API endpoints.
+const (
+	DefaultBaseURL = "https://api.namecheap.com/xml.response"
+	SandboxBaseURL = "https://api.sandbox.namecheap.com/xml.response"
+)
+
+// Client the API client for Namecheap.
+type Client struct {
+	apiUser  string
+	apiKey   string
+	clientIP string
+
+	BaseURL    string
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiUser string, apiKey string, clientIP string) *Client {
+	return &Client{
+		apiUser:    apiUser,
+		apiKey:     apiKey,
+		clientIP:   clientIP,
+		BaseURL:    DefaultBaseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// GetHosts reads the full list of DNS host records.
+// https://www.namecheap.com/support/api/methods/domains-dns/get-hosts.aspx
+func (c *Client) GetHosts(ctx context.Context, sld, tld string) ([]Record, error) {
+	request, err := c.newRequestGet(ctx, "namecheap.domains.dns.getHosts",
+		addParam("SLD", sld),
+		addParam("TLD", tld),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	var ghr getHostsResponse
+	err = c.do(request, &ghr)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(ghr.Errors) > 0 {
+		return nil, ghr.Errors[0]
+	}
+
+	return ghr.Hosts, nil
+}
+
+// SetHosts writes the full list of DNS host records .
+// https://www.namecheap.com/support/api/methods/domains-dns/set-hosts.aspx
+func (c *Client) SetHosts(ctx context.Context, sld, tld string, hosts []Record) error {
+	req, err := c.newRequestPost(ctx, "namecheap.domains.dns.setHosts",
+		addParam("SLD", sld),
+		addParam("TLD", tld),
+		func(values url.Values) {
+			for i, h := range hosts {
+				ind := fmt.Sprintf("%d", i+1)
+				values.Add("HostName"+ind, h.Name)
+				values.Add("RecordType"+ind, h.Type)
+				values.Add("Address"+ind, h.Address)
+				values.Add("MXPref"+ind, h.MXPref)
+				values.Add("TTL"+ind, h.TTL)
+			}
+		},
+	)
+	if err != nil {
+		return err
+	}
+
+	var shr setHostsResponse
+	err = c.do(req, &shr)
+	if err != nil {
+		return err
+	}
+
+	if len(shr.Errors) > 0 {
+		return shr.Errors[0]
+	}
+	if shr.Result.IsSuccess != "true" {
+		return errors.New("setHosts failed")
+	}
+
+	return nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode >= http.StatusBadRequest {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	return xml.Unmarshal(raw, result)
+}
+
+func (c *Client) newRequestGet(ctx context.Context, cmd string, params ...func(url.Values)) (*http.Request, error) {
+	query := c.makeQuery(cmd, params...)
+
+	endpoint, err := url.Parse(c.BaseURL)
+	if err != nil {
+		return nil, err
+	}
+
+	endpoint.RawQuery = query.Encode()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	return req, nil
+}
+
+func (c *Client) newRequestPost(ctx context.Context, cmd string, params ...func(url.Values)) (*http.Request, error) {
+	query := c.makeQuery(cmd, params...)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL, strings.NewReader(query.Encode()))
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	return req, nil
+}
+
+func (c *Client) makeQuery(cmd string, params ...func(url.Values)) url.Values {
+	queryParams := make(url.Values)
+	queryParams.Set("ApiUser", c.apiUser)
+	queryParams.Set("ApiKey", c.apiKey)
+	queryParams.Set("UserName", c.apiUser)
+	queryParams.Set("Command", cmd)
+	queryParams.Set("ClientIp", c.clientIP)
+
+	for _, param := range params {
+		param(queryParams)
+	}
+
+	return queryParams
+}
+
+func addParam(key, value string) func(url.Values) {
+	return func(values url.Values) {
+		values.Set(key, value)
+	}
+}
diff --git a/providers/dns/namecheap/internal/client_test.go b/providers/dns/namecheap/internal/client_test.go
new file mode 100644
index 00000000..9d78ee21
--- /dev/null
+++ b/providers/dns/namecheap/internal/client_test.go
@@ -0,0 +1,173 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, handler http.HandlerFunc) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc("/", handler)
+
+	client := NewClient("user", "secret", "127.0.0.1")
+	client.HTTPClient = server.Client()
+	client.BaseURL = server.URL
+
+	return client
+}
+
+func writeFixture(rw http.ResponseWriter, filename string) {
+	file, err := os.Open(filepath.Join("fixtures", filename))
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer func() { _ = file.Close() }()
+
+	_, _ = io.Copy(rw, file)
+}
+
+func TestClient_GetHosts(t *testing.T) {
+	client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodGet {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		expectedParams := map[string]string{
+			"ApiKey":   "secret",
+			"ApiUser":  "user",
+			"ClientIp": "127.0.0.1",
+			"Command":  "namecheap.domains.dns.getHosts",
+			"SLD":      "foo",
+			"TLD":      "example.com",
+			"UserName": "user",
+		}
+
+		query := req.URL.Query()
+		for k, v := range expectedParams {
+			if query.Get(k) != v {
+				http.Error(rw, fmt.Sprintf("invalid query parameter %s value: %s", k, query.Get(k)), http.StatusBadRequest)
+				return
+			}
+		}
+
+		writeFixture(rw, "getHosts.xml")
+	})
+
+	hosts, err := client.GetHosts(context.Background(), "foo", "example.com")
+	require.NoError(t, err)
+
+	expected := []Record{
+		{Type: "A", Name: "@", Address: "1.2.3.4", MXPref: "10", TTL: "1800"},
+		{Type: "A", Name: "www", Address: "122.23.3.7", MXPref: "10", TTL: "1800"},
+	}
+
+	assert.Equal(t, expected, hosts)
+}
+
+func TestClient_GetHosts_error(t *testing.T) {
+	client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodGet {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		writeFixture(rw, "getHosts_errorBadAPIKey1.xml")
+	})
+
+	_, err := client.GetHosts(context.Background(), "foo", "example.com")
+	require.ErrorAs(t, err, &apiError{})
+}
+
+func TestClient_SetHosts(t *testing.T) {
+	client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
+			http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest)
+			return
+		}
+
+		err := req.ParseForm()
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		expectedParams := map[string]string{
+			"HostName1":   "_acme-challenge.test.example.com",
+			"RecordType1": "TXT",
+			"Address1":    "txtTXTtxt",
+			"MXPref1":     "10",
+			"TTL1":        "120",
+
+			"HostName2":   "_acme-challenge.test.example.org",
+			"RecordType2": "TXT",
+			"Address2":    "txtTXTtxt",
+			"MXPref2":     "10",
+			"TTL2":        "120",
+
+			"ApiKey":   "secret",
+			"ApiUser":  "user",
+			"ClientIp": "127.0.0.1",
+			"Command":  "namecheap.domains.dns.setHosts",
+			"SLD":      "foo",
+			"TLD":      "example.com",
+			"UserName": "user",
+		}
+
+		for k, v := range expectedParams {
+			if req.Form.Get(k) != v {
+				http.Error(rw, fmt.Sprintf("invalid form data %s value: %q", k, req.Form.Get(k)), http.StatusBadRequest)
+				return
+			}
+		}
+
+		writeFixture(rw, "setHosts.xml")
+	})
+
+	records := []Record{
+		{Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
+		{Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
+	}
+
+	err := client.SetHosts(context.Background(), "foo", "example.com", records)
+	require.NoError(t, err)
+}
+
+func TestClient_SetHosts_error(t *testing.T) {
+	client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		writeFixture(rw, "setHosts_errorBadAPIKey1.xml")
+	})
+
+	records := []Record{
+		{Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
+		{Name: "_acme-challenge.test.example.org", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"},
+	}
+
+	err := client.SetHosts(context.Background(), "foo", "example.com", records)
+	require.ErrorAs(t, err, &apiError{})
+}
diff --git a/providers/dns/namecheap/internal/fixtures/getHosts.xml b/providers/dns/namecheap/internal/fixtures/getHosts.xml
new file mode 100644
index 00000000..ee2fdcca
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/getHosts.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ApiResponse xmlns="http://api.namecheap.com/xml.response" Status="OK">
+    <Errors />
+    <RequestedCommand>namecheap.domains.dns.getHosts</RequestedCommand>
+    <CommandResponse Type="namecheap.domains.dns.getHosts">
+        <DomainDNSGetHostsResult Domain="domain.com" IsUsingOurDNS="true">
+            <host HostId="12" Name="@" Type="A" Address="1.2.3.4" MXPref="10" TTL="1800" />
+            <host HostId="14" Name="www" Type="A" Address="122.23.3.7" MXPref="10" TTL="1800" />
+        </DomainDNSGetHostsResult>
+    </CommandResponse>
+    <Server>SERVER-NAME</Server>
+    <GMTTimeDifference>+5</GMTTimeDifference>
+    <ExecutionTime>32.76</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/fixtures/getHosts_errorBadAPIKey1.xml b/providers/dns/namecheap/internal/fixtures/getHosts_errorBadAPIKey1.xml
new file mode 100644
index 00000000..3f73a678
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/getHosts_errorBadAPIKey1.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ApiResponse Status="ERROR" xmlns="http://api.namecheap.com/xml.response">
+    <Errors>
+        <Error Number="1011102">API Key is invalid or API access has not been enabled</Error>
+    </Errors>
+    <Warnings />
+    <RequestedCommand />
+    <Server>PHX01SBAPI01</Server>
+    <GMTTimeDifference>--5:00</GMTTimeDifference>
+    <ExecutionTime>0</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/fixtures/getHosts_success1.xml b/providers/dns/namecheap/internal/fixtures/getHosts_success1.xml
new file mode 100644
index 00000000..7a53f6d4
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/getHosts_success1.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
+    <Errors />
+    <Warnings />
+    <RequestedCommand>namecheap.domains.dns.getHosts</RequestedCommand>
+    <CommandResponse Type="namecheap.domains.dns.getHosts">
+        <DomainDNSGetHostsResult Domain="example.com" EmailType="MXE" IsUsingOurDNS="true">
+            <host HostId="217076" Name="www" Type="A" Address="10.0.0.2" MXPref="10" TTL="1200" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
+            <host HostId="217069" Name="home" Type="A" Address="10.0.0.1" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
+            <host HostId="217071" Name="a" Type="AAAA" Address="::0" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
+            <host HostId="217075" Name="*" Type="CNAME" Address="example.com." MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
+            <host HostId="217073" Name="example.com" Type="MXE" Address="10.0.0.5" MXPref="10" TTL="1800" AssociatedAppTitle="MXE" FriendlyName="MXE1" IsActive="true" IsDDNSEnabled="false" />
+            <host HostId="217077" Name="xyz" Type="URL" Address="https://google.com" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
+        </DomainDNSGetHostsResult>
+    </CommandResponse>
+    <Server>PHX01SBAPI01</Server>
+    <GMTTimeDifference>--5:00</GMTTimeDifference>
+    <ExecutionTime>3.338</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/fixtures/getHosts_success2.xml b/providers/dns/namecheap/internal/fixtures/getHosts_success2.xml
new file mode 100644
index 00000000..b382674a
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/getHosts_success2.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
+    <Errors />
+    <Warnings />
+    <RequestedCommand>namecheap.domains.dns.getHosts</RequestedCommand>
+    <CommandResponse Type="namecheap.domains.dns.getHosts">
+        <DomainDNSGetHostsResult Domain="example.com" EmailType="MXE" IsUsingOurDNS="true">
+            <host HostId="217076" Name="@" Type="A" Address="10.0.0.2" MXPref="10" TTL="1200" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
+            <host HostId="217069" Name="www" Type="A" Address="10.0.0.3" MXPref="10" TTL="60" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
+        </DomainDNSGetHostsResult>
+    </CommandResponse>
+    <Server>PHX01SBAPI01</Server>
+    <GMTTimeDifference>--5:00</GMTTimeDifference>
+    <ExecutionTime>3.338</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/fixtures/setHosts.xml b/providers/dns/namecheap/internal/fixtures/setHosts.xml
new file mode 100644
index 00000000..11366dff
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/setHosts.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ApiResponse xmlns="https://api.namecheap.com/xml.response" Status="OK">
+    <Errors />
+    <RequestedCommand>namecheap.domains.dns.setHosts</RequestedCommand>
+    <CommandResponse Type="namecheap.domains.dns.setHosts">
+        <DomainDNSSetHostsResult Domain="domain51.com" IsSuccess="true" />
+    </CommandResponse>
+    <Server>SERVER-NAME</Server>
+    <GMTTimeDifference>+5</GMTTimeDifference>
+    <ExecutionTime>32.76</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/fixtures/setHosts_errorBadAPIKey1.xml b/providers/dns/namecheap/internal/fixtures/setHosts_errorBadAPIKey1.xml
new file mode 100644
index 00000000..3f73a678
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/setHosts_errorBadAPIKey1.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ApiResponse Status="ERROR" xmlns="http://api.namecheap.com/xml.response">
+    <Errors>
+        <Error Number="1011102">API Key is invalid or API access has not been enabled</Error>
+    </Errors>
+    <Warnings />
+    <RequestedCommand />
+    <Server>PHX01SBAPI01</Server>
+    <GMTTimeDifference>--5:00</GMTTimeDifference>
+    <ExecutionTime>0</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/fixtures/setHosts_success1.xml b/providers/dns/namecheap/internal/fixtures/setHosts_success1.xml
new file mode 100644
index 00000000..e428e787
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/setHosts_success1.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
+    <Errors />
+    <Warnings />
+    <RequestedCommand>namecheap.domains.dns.setHosts</RequestedCommand>
+    <CommandResponse Type="namecheap.domains.dns.setHosts">
+        <DomainDNSSetHostsResult Domain="example.com" IsSuccess="true">
+            <Warnings />
+        </DomainDNSSetHostsResult>
+    </CommandResponse>
+    <Server>PHX01SBAPI01</Server>
+    <GMTTimeDifference>--5:00</GMTTimeDifference>
+    <ExecutionTime>2.347</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/fixtures/setHosts_success2.xml b/providers/dns/namecheap/internal/fixtures/setHosts_success2.xml
new file mode 100644
index 00000000..e428e787
--- /dev/null
+++ b/providers/dns/namecheap/internal/fixtures/setHosts_success2.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
+    <Errors />
+    <Warnings />
+    <RequestedCommand>namecheap.domains.dns.setHosts</RequestedCommand>
+    <CommandResponse Type="namecheap.domains.dns.setHosts">
+        <DomainDNSSetHostsResult Domain="example.com" IsSuccess="true">
+            <Warnings />
+        </DomainDNSSetHostsResult>
+    </CommandResponse>
+    <Server>PHX01SBAPI01</Server>
+    <GMTTimeDifference>--5:00</GMTTimeDifference>
+    <ExecutionTime>2.347</ExecutionTime>
+</ApiResponse>
diff --git a/providers/dns/namecheap/internal/ip.go b/providers/dns/namecheap/internal/ip.go
new file mode 100644
index 00000000..5823212d
--- /dev/null
+++ b/providers/dns/namecheap/internal/ip.go
@@ -0,0 +1,45 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/go-acme/lego/v4/log"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const getIPURL = "https://dynamicdns.park-your-domain.com/getip"
+
+// GetClientIP returns the client's public IP address.
+// It uses namecheap's IP discovery service to perform the lookup.
+func GetClientIP(ctx context.Context, client *http.Client, debug bool) (addr string, err error) {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, getIPURL, http.NoBody)
+	if err != nil {
+		return "", fmt.Errorf("unable to create request: %w", err)
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	clientIP, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	if debug {
+		log.Println("Client IP:", string(clientIP))
+	}
+
+	return string(clientIP), nil
+}
diff --git a/providers/dns/namecheap/internal/types.go b/providers/dns/namecheap/internal/types.go
new file mode 100644
index 00000000..336776d8
--- /dev/null
+++ b/providers/dns/namecheap/internal/types.go
@@ -0,0 +1,43 @@
+package internal
+
+import (
+	"encoding/xml"
+	"fmt"
+)
+
+// Record describes a DNS record returned by the Namecheap DNS gethosts API.
+// Namecheap uses the term "host" to refer to all DNS records that include
+// a host field (A, AAAA, CNAME, NS, TXT, URL).
+type Record struct {
+	Type    string `xml:",attr"`
+	Name    string `xml:",attr"`
+	Address string `xml:",attr"`
+	MXPref  string `xml:",attr"`
+	TTL     string `xml:",attr"`
+}
+
+// apiError describes an error record in a namecheap API response.
+type apiError struct {
+	Number      int    `xml:",attr"`
+	Description string `xml:",innerxml"`
+}
+
+func (a apiError) Error() string {
+	return fmt.Sprintf("%s [%d]", a.Description, a.Number)
+}
+
+type setHostsResponse struct {
+	XMLName xml.Name   `xml:"ApiResponse"`
+	Status  string     `xml:"Status,attr"`
+	Errors  []apiError `xml:"Errors>Error"`
+	Result  struct {
+		IsSuccess string `xml:",attr"`
+	} `xml:"CommandResponse>DomainDNSSetHostsResult"`
+}
+
+type getHostsResponse struct {
+	XMLName xml.Name   `xml:"ApiResponse"`
+	Status  string     `xml:"Status,attr"`
+	Errors  []apiError `xml:"Errors>Error"`
+	Hosts   []Record   `xml:"CommandResponse>DomainDNSGetHostsResult>host"`
+}
diff --git a/providers/dns/namecheap/namecheap.go b/providers/dns/namecheap/namecheap.go
index 4b0c8fb3..eb94c9ce 100644
--- a/providers/dns/namecheap/namecheap.go
+++ b/providers/dns/namecheap/namecheap.go
@@ -2,9 +2,9 @@
 package namecheap
 
 import (
+	"context"
 	"errors"
 	"fmt"
-	"io"
 	"net/http"
 	"strconv"
 	"strings"
@@ -13,6 +13,7 @@ import (
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/log"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/namecheap/internal"
 	"golang.org/x/net/publicsuffix"
 )
 
@@ -29,12 +30,6 @@ import (
 //    address as a form or query string value. This code uses a namecheap
 //    service to query the client's IP address.
 
-const (
-	defaultBaseURL = "https://api.namecheap.com/xml.response"
-	sandboxBaseURL = "https://api.sandbox.namecheap.com/xml.response"
-	getIPURL       = "https://dynamicdns.park-your-domain.com/getip"
-)
-
 // Environment variables names.
 const (
 	envNamespace = "NAMECHEAP_"
@@ -62,177 +57,6 @@ type challenge struct {
 	host     string
 }
 
-// Config is used to configure the creation of the DNSProvider.
-type Config struct {
-	Debug              bool
-	BaseURL            string
-	APIUser            string
-	APIKey             string
-	ClientIP           string
-	PropagationTimeout time.Duration
-	PollingInterval    time.Duration
-	TTL                int
-	HTTPClient         *http.Client
-}
-
-// NewDefaultConfig returns a default configuration for the DNSProvider.
-func NewDefaultConfig() *Config {
-	baseURL := defaultBaseURL
-	if env.GetOrDefaultBool(EnvSandbox, false) {
-		baseURL = sandboxBaseURL
-	}
-
-	return &Config{
-		BaseURL:            baseURL,
-		Debug:              env.GetOrDefaultBool(EnvDebug, false),
-		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
-		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Minute),
-		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second),
-		HTTPClient: &http.Client{
-			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
-		},
-	}
-}
-
-// DNSProvider implements the challenge.Provider interface.
-type DNSProvider struct {
-	config *Config
-}
-
-// NewDNSProvider returns a DNSProvider instance configured for namecheap.
-// Credentials must be passed in the environment variables:
-// NAMECHEAP_API_USER and NAMECHEAP_API_KEY.
-func NewDNSProvider() (*DNSProvider, error) {
-	values, err := env.Get(EnvAPIUser, EnvAPIKey)
-	if err != nil {
-		return nil, fmt.Errorf("namecheap: %w", err)
-	}
-
-	config := NewDefaultConfig()
-	config.APIUser = values[EnvAPIUser]
-	config.APIKey = values[EnvAPIKey]
-
-	return NewDNSProviderConfig(config)
-}
-
-// NewDNSProviderConfig return a DNSProvider instance configured for Namecheap.
-func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
-	if config == nil {
-		return nil, errors.New("namecheap: the configuration of the DNS provider is nil")
-	}
-
-	if config.APIUser == "" || config.APIKey == "" {
-		return nil, errors.New("namecheap: credentials missing")
-	}
-
-	if config.ClientIP == "" {
-		clientIP, err := getClientIP(config.HTTPClient, config.Debug)
-		if err != nil {
-			return nil, fmt.Errorf("namecheap: %w", err)
-		}
-		config.ClientIP = clientIP
-	}
-
-	return &DNSProvider{config: config}, nil
-}
-
-// Timeout returns the timeout and interval to use when checking for DNS propagation.
-// Namecheap can sometimes take a long time to complete an update, so wait up to 60 minutes for the update to propagate.
-func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
-	return d.config.PropagationTimeout, d.config.PollingInterval
-}
-
-// Present installs a TXT record for the DNS challenge.
-func (d *DNSProvider) Present(domain, token, keyAuth string) error {
-	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	ch, err := newChallenge(domain, keyAuth)
-	if err != nil {
-		return fmt.Errorf("namecheap: %w", err)
-	}
-
-	records, err := d.getHosts(ch.sld, ch.tld)
-	if err != nil {
-		return fmt.Errorf("namecheap: %w", err)
-	}
-
-	record := Record{
-		Name:    ch.key,
-		Type:    "TXT",
-		Address: ch.keyValue,
-		MXPref:  "10",
-		TTL:     strconv.Itoa(d.config.TTL),
-	}
-
-	records = append(records, record)
-
-	if d.config.Debug {
-		for _, h := range records {
-			log.Printf("%-5.5s %-30.30s %-6s %-70.70s", h.Type, h.Name, h.TTL, h.Address)
-		}
-	}
-
-	err = d.setHosts(ch.sld, ch.tld, records)
-	if err != nil {
-		return fmt.Errorf("namecheap: %w", err)
-	}
-	return nil
-}
-
-// CleanUp removes a TXT record used for a previous DNS challenge.
-func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
-	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	ch, err := newChallenge(domain, keyAuth)
-	if err != nil {
-		return fmt.Errorf("namecheap: %w", err)
-	}
-
-	records, err := d.getHosts(ch.sld, ch.tld)
-	if err != nil {
-		return fmt.Errorf("namecheap: %w", err)
-	}
-
-	// Find the challenge TXT record and remove it if found.
-	var found bool
-	var newRecords []Record
-	for _, h := range records {
-		if h.Name == ch.key && h.Type == "TXT" {
-			found = true
-		} else {
-			newRecords = append(newRecords, h)
-		}
-	}
-
-	if !found {
-		return nil
-	}
-
-	err = d.setHosts(ch.sld, ch.tld, newRecords)
-	if err != nil {
-		return fmt.Errorf("namecheap: %w", err)
-	}
-	return nil
-}
-
-// getClientIP returns the client's public IP address.
-// It uses namecheap's IP discovery service to perform the lookup.
-func getClientIP(client *http.Client, debug bool) (addr string, err error) {
-	resp, err := client.Get(getIPURL)
-	if err != nil {
-		return "", err
-	}
-	defer resp.Body.Close()
-
-	clientIP, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return "", err
-	}
-
-	if debug {
-		log.Println("Client IP:", string(clientIP))
-	}
-	return string(clientIP), nil
-}
-
 // newChallenge builds a challenge record from a domain name and a challenge authentication key.
 func newChallenge(domain, keyAuth string) (*challenge, error) {
 	domain = dns01.UnFqdn(domain)
@@ -263,3 +87,166 @@ func newChallenge(domain, keyAuth string) (*challenge, error) {
 		host:     host,
 	}, nil
 }
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+	Debug              bool
+	BaseURL            string
+	APIUser            string
+	APIKey             string
+	ClientIP           string
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	TTL                int
+	HTTPClient         *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+	baseURL := internal.DefaultBaseURL
+	if env.GetOrDefaultBool(EnvSandbox, false) {
+		baseURL = internal.SandboxBaseURL
+	}
+
+	return &Config{
+		BaseURL:            baseURL,
+		Debug:              env.GetOrDefaultBool(EnvDebug, false),
+		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Minute),
+		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, 15*time.Second),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
+		},
+	}
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+	config *Config
+	client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for namecheap.
+// Credentials must be passed in the environment variables:
+// NAMECHEAP_API_USER and NAMECHEAP_API_KEY.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get(EnvAPIUser, EnvAPIKey)
+	if err != nil {
+		return nil, fmt.Errorf("namecheap: %w", err)
+	}
+
+	config := NewDefaultConfig()
+	config.APIUser = values[EnvAPIUser]
+	config.APIKey = values[EnvAPIKey]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Namecheap.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("namecheap: the configuration of the DNS provider is nil")
+	}
+
+	if config.APIUser == "" || config.APIKey == "" {
+		return nil, errors.New("namecheap: credentials missing")
+	}
+
+	if config.ClientIP == "" {
+		clientIP, err := internal.GetClientIP(context.Background(), config.HTTPClient, config.Debug)
+		if err != nil {
+			return nil, fmt.Errorf("namecheap: %w", err)
+		}
+		config.ClientIP = clientIP
+	}
+
+	client := internal.NewClient(config.APIUser, config.APIKey, config.ClientIP)
+	client.BaseURL = config.BaseURL
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Namecheap can sometimes take a long time to complete an update, so wait up to 60 minutes for the update to propagate.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+	return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+// Present installs a TXT record for the DNS challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	// TODO(ldez) replace domain by FQDN to follow CNAME.
+	ch, err := newChallenge(domain, keyAuth)
+	if err != nil {
+		return fmt.Errorf("namecheap: %w", err)
+	}
+
+	ctx := context.Background()
+
+	records, err := d.client.GetHosts(ctx, ch.sld, ch.tld)
+	if err != nil {
+		return fmt.Errorf("namecheap: %w", err)
+	}
+
+	record := internal.Record{
+		Name:    ch.key,
+		Type:    "TXT",
+		Address: ch.keyValue,
+		MXPref:  "10",
+		TTL:     strconv.Itoa(d.config.TTL),
+	}
+
+	records = append(records, record)
+
+	if d.config.Debug {
+		for _, h := range records {
+			log.Printf("%-5.5s %-30.30s %-6s %-70.70s", h.Type, h.Name, h.TTL, h.Address)
+		}
+	}
+
+	err = d.client.SetHosts(ctx, ch.sld, ch.tld, records)
+	if err != nil {
+		return fmt.Errorf("namecheap: %w", err)
+	}
+	return nil
+}
+
+// CleanUp removes a TXT record used for a previous DNS challenge.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	// TODO(ldez) replace domain by FQDN to follow CNAME.
+	ch, err := newChallenge(domain, keyAuth)
+	if err != nil {
+		return fmt.Errorf("namecheap: %w", err)
+	}
+
+	ctx := context.Background()
+
+	records, err := d.client.GetHosts(ctx, ch.sld, ch.tld)
+	if err != nil {
+		return fmt.Errorf("namecheap: %w", err)
+	}
+
+	// Find the challenge TXT record and remove it if found.
+	var found bool
+	var newRecords []internal.Record
+	for _, h := range records {
+		if h.Name == ch.key && h.Type == "TXT" {
+			found = true
+		} else {
+			newRecords = append(newRecords, h)
+		}
+	}
+
+	if !found {
+		return nil
+	}
+
+	err = d.client.SetHosts(ctx, ch.sld, ch.tld, newRecords)
+	if err != nil {
+		return fmt.Errorf("namecheap: %w", err)
+	}
+	return nil
+}
diff --git a/providers/dns/namecheap/namecheap_test.go b/providers/dns/namecheap/namecheap_test.go
index 6b2afb5b..e3cbb78b 100644
--- a/providers/dns/namecheap/namecheap_test.go
+++ b/providers/dns/namecheap/namecheap_test.go
@@ -1,13 +1,16 @@
 package namecheap
 
 import (
-	"fmt"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"os"
+	"path/filepath"
 	"testing"
 	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/namecheap/internal"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -18,66 +21,130 @@ const (
 	envTestClientIP = "10.0.0.1"
 )
 
-func TestDNSProvider_getHosts(t *testing.T) {
-	for _, test := range testCases {
-		t.Run(test.name, func(t *testing.T) {
-			p := setupTest(t, &test)
-
-			ch, err := newChallenge(test.domain, "")
-			require.NoError(t, err)
-
-			hosts, err := p.getHosts(ch.sld, ch.tld)
-			if test.errString != "" {
-				assert.EqualError(t, err, test.errString)
-			} else {
-				assert.NoError(t, err)
-			}
-
-		next1:
-			for _, h := range hosts {
-				for _, th := range test.hosts {
-					if h == th {
-						continue next1
-					}
-				}
-				t.Errorf("getHosts case %s unexpected record [%s:%s:%s]", test.name, h.Type, h.Name, h.Address)
-			}
-
-		next2:
-			for _, th := range test.hosts {
-				for _, h := range hosts {
-					if h == th {
-						continue next2
-					}
-				}
-				t.Errorf("getHosts case %s missing record [%s:%s:%s]", test.name, th.Type, th.Name, th.Address)
-			}
-		})
-	}
+type testCase struct {
+	name             string
+	domain           string
+	hosts            []internal.Record
+	errString        string
+	getHostsResponse string
+	setHostsResponse string
 }
 
-func TestDNSProvider_setHosts(t *testing.T) {
-	for _, test := range testCases {
-		t.Run(test.name, func(t *testing.T) {
-			p := setupTest(t, &test)
+var testCases = []testCase{
+	{
+		name:   "Test:Success:1",
+		domain: "test.example.com",
+		hosts: []internal.Record{
+			{Type: "A", Name: "home", Address: "10.0.0.1", MXPref: "10", TTL: "1799"},
+			{Type: "A", Name: "www", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
+			{Type: "AAAA", Name: "a", Address: "::0", MXPref: "10", TTL: "1799"},
+			{Type: "CNAME", Name: "*", Address: "example.com.", MXPref: "10", TTL: "1799"},
+			{Type: "MXE", Name: "example.com", Address: "10.0.0.5", MXPref: "10", TTL: "1800"},
+			{Type: "URL", Name: "xyz", Address: "https://google.com", MXPref: "10", TTL: "1799"},
+		},
+		getHostsResponse: "getHosts_success1.xml",
+		setHostsResponse: "setHosts_success1.xml",
+	},
+	{
+		name:   "Test:Success:2",
+		domain: "example.com",
+		hosts: []internal.Record{
+			{Type: "A", Name: "@", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
+			{Type: "A", Name: "www", Address: "10.0.0.3", MXPref: "10", TTL: "60"},
+		},
+		getHostsResponse: "getHosts_success2.xml",
+		setHostsResponse: "setHosts_success2.xml",
+	},
+	{
+		name:             "Test:Error:BadApiKey:1",
+		domain:           "test.example.com",
+		errString:        "API Key is invalid or API access has not been enabled [1011102]",
+		getHostsResponse: "getHosts_errorBadAPIKey1.xml",
+	},
+}
 
-			ch, err := newChallenge(test.domain, "")
-			require.NoError(t, err)
+func setupTest(t *testing.T, tc *testCase) *DNSProvider {
+	t.Helper()
 
-			hosts, err := p.getHosts(ch.sld, ch.tld)
-			if test.errString != "" {
-				assert.EqualError(t, err, test.errString)
-			} else {
-				require.NoError(t, err)
+	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.Method {
+		case http.MethodGet:
+			values := r.URL.Query()
+			cmd := values.Get("Command")
+			switch cmd {
+			case "namecheap.domains.dns.getHosts":
+				assertHdr(t, tc, &values)
+				w.WriteHeader(http.StatusOK)
+				writeFixture(w, tc.getHostsResponse)
+			default:
+				t.Errorf("Unexpected GET command: %s", cmd)
 			}
+
+		case http.MethodPost:
+			err := r.ParseForm()
 			if err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
 				return
 			}
+			values := r.Form
+			cmd := values.Get("Command")
+			switch cmd {
+			case "namecheap.domains.dns.setHosts":
+				assertHdr(t, tc, &values)
+				w.WriteHeader(http.StatusOK)
+				writeFixture(w, tc.setHostsResponse)
+			default:
+				t.Errorf("Unexpected POST command: %s", cmd)
+			}
 
-			err = p.setHosts(ch.sld, ch.tld, hosts)
-			require.NoError(t, err)
-		})
+		default:
+			t.Errorf("Unexpected http method: %s", r.Method)
+		}
+	})
+
+	server := httptest.NewServer(handler)
+	t.Cleanup(server.Close)
+
+	return mockDNSProvider(t, server.URL)
+}
+
+func mockDNSProvider(t *testing.T, baseURL string) *DNSProvider {
+	t.Helper()
+
+	config := NewDefaultConfig()
+	config.BaseURL = baseURL
+	config.APIUser = envTestUser
+	config.APIKey = envTestKey
+	config.ClientIP = envTestClientIP
+	config.HTTPClient = &http.Client{Timeout: 60 * time.Second}
+
+	provider, err := NewDNSProviderConfig(config)
+	require.NoError(t, err)
+
+	return provider
+}
+
+func assertHdr(t *testing.T, tc *testCase, values *url.Values) {
+	t.Helper()
+
+	ch, _ := newChallenge(tc.domain, "")
+	assert.Equal(t, envTestUser, values.Get("ApiUser"), "ApiUser")
+	assert.Equal(t, envTestKey, values.Get("ApiKey"), "ApiKey")
+	assert.Equal(t, envTestUser, values.Get("UserName"), "UserName")
+	assert.Equal(t, envTestClientIP, values.Get("ClientIp"), "ClientIp")
+	assert.Equal(t, ch.sld, values.Get("SLD"), "SLD")
+	assert.Equal(t, ch.tld, values.Get("TLD"), "TLD")
+}
+
+func writeFixture(rw http.ResponseWriter, filename string) {
+	file, err := os.Open(filepath.Join("internal", "fixtures", filename))
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
 	}
+	defer func() { _ = file.Close() }()
+
+	_, _ = io.Copy(rw, file)
 }
 
 func TestDNSProvider_Present(t *testing.T) {
@@ -160,196 +227,3 @@ func TestDomainSplit(t *testing.T) {
 		})
 	}
 }
-
-func assertHdr(t *testing.T, tc *testCase, values *url.Values) {
-	t.Helper()
-
-	ch, _ := newChallenge(tc.domain, "")
-	assert.Equal(t, envTestUser, values.Get("ApiUser"), "ApiUser")
-	assert.Equal(t, envTestKey, values.Get("ApiKey"), "ApiKey")
-	assert.Equal(t, envTestUser, values.Get("UserName"), "UserName")
-	assert.Equal(t, envTestClientIP, values.Get("ClientIp"), "ClientIp")
-	assert.Equal(t, ch.sld, values.Get("SLD"), "SLD")
-	assert.Equal(t, ch.tld, values.Get("TLD"), "TLD")
-}
-
-func setupTest(t *testing.T, tc *testCase) *DNSProvider {
-	t.Helper()
-
-	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		switch r.Method {
-		case http.MethodGet:
-			values := r.URL.Query()
-			cmd := values.Get("Command")
-			switch cmd {
-			case "namecheap.domains.dns.getHosts":
-				assertHdr(t, tc, &values)
-				w.WriteHeader(http.StatusOK)
-				fmt.Fprint(w, tc.getHostsResponse)
-			default:
-				t.Errorf("Unexpected GET command: %s", cmd)
-			}
-
-		case http.MethodPost:
-			err := r.ParseForm()
-			if err != nil {
-				http.Error(w, err.Error(), http.StatusInternalServerError)
-				return
-			}
-			values := r.Form
-			cmd := values.Get("Command")
-			switch cmd {
-			case "namecheap.domains.dns.setHosts":
-				assertHdr(t, tc, &values)
-				w.WriteHeader(http.StatusOK)
-				fmt.Fprint(w, tc.setHostsResponse)
-			default:
-				t.Errorf("Unexpected POST command: %s", cmd)
-			}
-
-		default:
-			t.Errorf("Unexpected http method: %s", r.Method)
-		}
-	})
-
-	server := httptest.NewServer(handler)
-	t.Cleanup(server.Close)
-
-	return mockDNSProvider(t, server.URL)
-}
-
-func mockDNSProvider(t *testing.T, baseURL string) *DNSProvider {
-	t.Helper()
-
-	config := NewDefaultConfig()
-	config.BaseURL = baseURL
-	config.APIUser = envTestUser
-	config.APIKey = envTestKey
-	config.ClientIP = envTestClientIP
-	config.HTTPClient = &http.Client{Timeout: 60 * time.Second}
-
-	provider, err := NewDNSProviderConfig(config)
-	require.NoError(t, err)
-
-	return provider
-}
-
-type testCase struct {
-	name             string
-	domain           string
-	hosts            []Record
-	errString        string
-	getHostsResponse string
-	setHostsResponse string
-}
-
-var testCases = []testCase{
-	{
-		name:   "Test:Success:1",
-		domain: "test.example.com",
-		hosts: []Record{
-			{Type: "A", Name: "home", Address: "10.0.0.1", MXPref: "10", TTL: "1799"},
-			{Type: "A", Name: "www", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
-			{Type: "AAAA", Name: "a", Address: "::0", MXPref: "10", TTL: "1799"},
-			{Type: "CNAME", Name: "*", Address: "example.com.", MXPref: "10", TTL: "1799"},
-			{Type: "MXE", Name: "example.com", Address: "10.0.0.5", MXPref: "10", TTL: "1800"},
-			{Type: "URL", Name: "xyz", Address: "https://google.com", MXPref: "10", TTL: "1799"},
-		},
-		getHostsResponse: responseGetHostsSuccess1,
-		setHostsResponse: responseSetHostsSuccess1,
-	},
-	{
-		name:   "Test:Success:2",
-		domain: "example.com",
-		hosts: []Record{
-			{Type: "A", Name: "@", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
-			{Type: "A", Name: "www", Address: "10.0.0.3", MXPref: "10", TTL: "60"},
-		},
-		getHostsResponse: responseGetHostsSuccess2,
-		setHostsResponse: responseSetHostsSuccess2,
-	},
-	{
-		name:             "Test:Error:BadApiKey:1",
-		domain:           "test.example.com",
-		errString:        "API Key is invalid or API access has not been enabled [1011102]",
-		getHostsResponse: responseGetHostsErrorBadAPIKey1,
-	},
-}
-
-const responseGetHostsSuccess1 = `<?xml version="1.0" encoding="utf-8"?>
-<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
-  <Errors />
-  <Warnings />
-  <RequestedCommand>namecheap.domains.dns.getHosts</RequestedCommand>
-  <CommandResponse Type="namecheap.domains.dns.getHosts">
-    <DomainDNSGetHostsResult Domain="example.com" EmailType="MXE" IsUsingOurDNS="true">
-      <host HostId="217076" Name="www" Type="A" Address="10.0.0.2" MXPref="10" TTL="1200" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
-      <host HostId="217069" Name="home" Type="A" Address="10.0.0.1" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
-      <host HostId="217071" Name="a" Type="AAAA" Address="::0" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
-      <host HostId="217075" Name="*" Type="CNAME" Address="example.com." MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
-      <host HostId="217073" Name="example.com" Type="MXE" Address="10.0.0.5" MXPref="10" TTL="1800" AssociatedAppTitle="MXE" FriendlyName="MXE1" IsActive="true" IsDDNSEnabled="false" />
-      <host HostId="217077" Name="xyz" Type="URL" Address="https://google.com" MXPref="10" TTL="1799" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
-    </DomainDNSGetHostsResult>
-  </CommandResponse>
-  <Server>PHX01SBAPI01</Server>
-  <GMTTimeDifference>--5:00</GMTTimeDifference>
-  <ExecutionTime>3.338</ExecutionTime>
-</ApiResponse>`
-
-const responseSetHostsSuccess1 = `<?xml version="1.0" encoding="utf-8"?>
-<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
-  <Errors />
-  <Warnings />
-  <RequestedCommand>namecheap.domains.dns.setHosts</RequestedCommand>
-  <CommandResponse Type="namecheap.domains.dns.setHosts">
-    <DomainDNSSetHostsResult Domain="example.com" IsSuccess="true">
-      <Warnings />
-    </DomainDNSSetHostsResult>
-  </CommandResponse>
-  <Server>PHX01SBAPI01</Server>
-  <GMTTimeDifference>--5:00</GMTTimeDifference>
-  <ExecutionTime>2.347</ExecutionTime>
-</ApiResponse>`
-
-const responseGetHostsSuccess2 = `<?xml version="1.0" encoding="utf-8"?>
-<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
-  <Errors />
-  <Warnings />
-  <RequestedCommand>namecheap.domains.dns.getHosts</RequestedCommand>
-  <CommandResponse Type="namecheap.domains.dns.getHosts">
-    <DomainDNSGetHostsResult Domain="example.com" EmailType="MXE" IsUsingOurDNS="true">
-      <host HostId="217076" Name="@" Type="A" Address="10.0.0.2" MXPref="10" TTL="1200" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
-      <host HostId="217069" Name="www" Type="A" Address="10.0.0.3" MXPref="10" TTL="60" AssociatedAppTitle="" FriendlyName="" IsActive="true" IsDDNSEnabled="false" />
-    </DomainDNSGetHostsResult>
-  </CommandResponse>
-  <Server>PHX01SBAPI01</Server>
-  <GMTTimeDifference>--5:00</GMTTimeDifference>
-  <ExecutionTime>3.338</ExecutionTime>
-</ApiResponse>`
-
-const responseSetHostsSuccess2 = `<?xml version="1.0" encoding="utf-8"?>
-<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
-  <Errors />
-  <Warnings />
-  <RequestedCommand>namecheap.domains.dns.setHosts</RequestedCommand>
-  <CommandResponse Type="namecheap.domains.dns.setHosts">
-    <DomainDNSSetHostsResult Domain="example.com" IsSuccess="true">
-      <Warnings />
-    </DomainDNSSetHostsResult>
-  </CommandResponse>
-  <Server>PHX01SBAPI01</Server>
-  <GMTTimeDifference>--5:00</GMTTimeDifference>
-  <ExecutionTime>2.347</ExecutionTime>
-</ApiResponse>`
-
-const responseGetHostsErrorBadAPIKey1 = `<?xml version="1.0" encoding="utf-8"?>
-<ApiResponse Status="ERROR" xmlns="http://api.namecheap.com/xml.response">
-  <Errors>
-    <Error Number="1011102">API Key is invalid or API access has not been enabled</Error>
-  </Errors>
-  <Warnings />
-  <RequestedCommand />
-  <Server>PHX01SBAPI01</Server>
-  <GMTTimeDifference>--5:00</GMTTimeDifference>
-  <ExecutionTime>0</ExecutionTime>
-</ApiResponse>`
diff --git a/providers/dns/namesilo/namesilo.go b/providers/dns/namesilo/namesilo.go
index 1f59911e..bd1a3553 100644
--- a/providers/dns/namesilo/namesilo.go
+++ b/providers/dns/namesilo/namesilo.go
@@ -88,11 +88,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zoneName, err := getZoneNameByDomain(info.EffectiveFQDN)
+	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("namesilo: %w", err)
+		return fmt.Errorf("namesilo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
+	zoneName := dns01.UnFqdn(zone)
+
 	subdomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zoneName)
 	if err != nil {
 		return fmt.Errorf("namesilo: %w", err)
@@ -120,11 +122,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zoneName, err := getZoneNameByDomain(info.EffectiveFQDN)
+	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("namesilo: %w", err)
+		return fmt.Errorf("namesilo: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
+	zoneName := dns01.UnFqdn(zone)
+
 	resp, err := d.client.DnsListRecords(&namesilo.DnsListRecordsParams{Domain: zoneName})
 	if err != nil {
 		return fmt.Errorf("namesilo: %w", err)
@@ -152,11 +156,3 @@ func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 	return d.config.PropagationTimeout, d.config.PollingInterval
 }
-
-func getZoneNameByDomain(domain string) (string, error) {
-	zone, err := dns01.FindZoneByFqdn(domain)
-	if err != nil {
-		return "", fmt.Errorf("failed to find zone for domain: %s, %w", domain, err)
-	}
-	return dns01.UnFqdn(zone), nil
-}
diff --git a/providers/dns/nearlyfreespeech/internal/client.go b/providers/dns/nearlyfreespeech/internal/client.go
index a59636ae..1242c6ad 100644
--- a/providers/dns/nearlyfreespeech/internal/client.go
+++ b/providers/dns/nearlyfreespeech/internal/client.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"crypto/sha1"
 	"encoding/json"
 	"fmt"
@@ -13,6 +14,7 @@ import (
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	querystring "github.com/google/go-querystring/query"
 )
 
@@ -23,24 +25,25 @@ const authenticationHeader = "X-NFSN-Authentication"
 const saltBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
 
 type Client struct {
-	HTTPClient *http.Client
-	baseURL    *url.URL
-
 	login  string
 	apiKey string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 func NewClient(login string, apiKey string) *Client {
 	baseURL, _ := url.Parse(apiURL)
+
 	return &Client{
-		HTTPClient: &http.Client{Timeout: 10 * time.Second},
-		baseURL:    baseURL,
 		login:      login,
 		apiKey:     apiKey,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}
 }
 
-func (c Client) AddRecord(domain string, record Record) error {
+func (c Client) AddRecord(ctx context.Context, domain string, record Record) error {
 	endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "addRR")
 
 	params, err := querystring.Values(record)
@@ -48,10 +51,10 @@ func (c Client) AddRecord(domain string, record Record) error {
 		return err
 	}
 
-	return c.do(endpoint, params)
+	return c.doRequest(ctx, endpoint, params)
 }
 
-func (c Client) RemoveRecord(domain string, record Record) error {
+func (c Client) RemoveRecord(ctx context.Context, domain string, record Record) error {
 	endpoint := c.baseURL.JoinPath("dns", dns01.UnFqdn(domain), "removeRR")
 
 	params, err := querystring.Values(record)
@@ -59,15 +62,15 @@ func (c Client) RemoveRecord(domain string, record Record) error {
 		return err
 	}
 
-	return c.do(endpoint, params)
+	return c.doRequest(ctx, endpoint, params)
 }
 
-func (c Client) do(endpoint *url.URL, params url.Values) error {
+func (c Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Values) error {
 	payload := params.Encode()
 
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), strings.NewReader(payload))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(payload))
 	if err != nil {
-		return err
+		return fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -75,21 +78,13 @@ func (c Client) do(endpoint *url.URL, params url.Values) error {
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		data, _ := io.ReadAll(resp.Body)
-
-		apiErr := &APIError{}
-		err := json.Unmarshal(data, apiErr)
-		if err != nil {
-			return fmt.Errorf("%s: %s", resp.Status, data)
-		}
-
-		return apiErr
+		return parseError(req, resp)
 	}
 
 	return nil
@@ -113,3 +108,15 @@ func (c Client) createSignature(uri string, body string) string {
 
 	return fmt.Sprintf("%s;%s;%s;%02x", c.login, timestamp, salt, sha1.Sum([]byte(hashInput)))
 }
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errAPI := &APIError{}
+	err := json.Unmarshal(raw, errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return errAPI
+}
diff --git a/providers/dns/nearlyfreespeech/internal/client_test.go b/providers/dns/nearlyfreespeech/internal/client_test.go
index b5bf30a9..05d7d676 100644
--- a/providers/dns/nearlyfreespeech/internal/client_test.go
+++ b/providers/dns/nearlyfreespeech/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -16,8 +17,8 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
-
 	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
 
 	client := NewClient("user", "secret")
 	client.HTTPClient = server.Client()
@@ -91,7 +92,7 @@ func TestClient_AddRecord(t *testing.T) {
 		TTL:  30,
 	}
 
-	err := client.AddRecord("example.com", record)
+	err := client.AddRecord(context.Background(), "example.com", record)
 	require.NoError(t, err)
 }
 
@@ -107,7 +108,7 @@ func TestClient_AddRecord_error(t *testing.T) {
 		TTL:  30,
 	}
 
-	err := client.AddRecord("example.com", record)
+	err := client.AddRecord(context.Background(), "example.com", record)
 	require.Error(t, err)
 }
 
@@ -128,7 +129,7 @@ func TestClient_RemoveRecord(t *testing.T) {
 		Data: "txtTXTtxt",
 	}
 
-	err := client.RemoveRecord("example.com", record)
+	err := client.RemoveRecord(context.Background(), "example.com", record)
 	require.NoError(t, err)
 }
 
@@ -143,6 +144,6 @@ func TestClient_RemoveRecord_error(t *testing.T) {
 		Data: "txtTXTtxt",
 	}
 
-	err := client.RemoveRecord("example.com", record)
+	err := client.RemoveRecord(context.Background(), "example.com", record)
 	require.Error(t, err)
 }
diff --git a/providers/dns/nearlyfreespeech/nearlyfreespeech.go b/providers/dns/nearlyfreespeech/nearlyfreespeech.go
index b6c9e810..eb001da8 100644
--- a/providers/dns/nearlyfreespeech/nearlyfreespeech.go
+++ b/providers/dns/nearlyfreespeech/nearlyfreespeech.go
@@ -2,6 +2,7 @@
 package nearlyfreespeech
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -112,7 +113,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("nearlyfreespeech: could not determine zone for domain %q: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -127,7 +128,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:  d.config.TTL,
 	}
 
-	err = d.client.AddRecord(authZone, record)
+	err = d.client.AddRecord(context.Background(), authZone, record)
 	if err != nil {
 		return fmt.Errorf("nearlyfreespeech: %w", err)
 	}
@@ -141,7 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("nearlyfreespeech: could not determine zone for domain %q: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("nearlyfreespeech: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -155,7 +156,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		Data: info.Value,
 	}
 
-	err = d.client.RemoveRecord(domain, record)
+	err = d.client.RemoveRecord(context.Background(), domain, record)
 	if err != nil {
 		return fmt.Errorf("nearlyfreespeech: %w", err)
 	}
diff --git a/providers/dns/netcup/internal/client.go b/providers/dns/netcup/internal/client.go
index bba0250d..9573c09c 100644
--- a/providers/dns/netcup/internal/client.go
+++ b/providers/dns/netcup/internal/client.go
@@ -2,123 +2,28 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // defaultBaseURL for reaching the jSON-based API-Endpoint of netcup.
 const defaultBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON"
 
-// success response status.
-const success = "success"
-
-// Request wrapper as specified in netcup wiki
-// needed for every request to netcup API around *Msg.
-// https://www.netcup-wiki.de/wiki/CCP_API#Anmerkungen_zu_JSON-Requests
-type Request struct {
-	Action string      `json:"action"`
-	Param  interface{} `json:"param"`
-}
-
-// LoginRequest as specified in netcup WSDL.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php#login
-type LoginRequest struct {
-	CustomerNumber  string `json:"customernumber"`
-	APIKey          string `json:"apikey"`
-	APIPassword     string `json:"apipassword"`
-	ClientRequestID string `json:"clientrequestid,omitempty"`
-}
-
-// LogoutRequest as specified in netcup WSDL.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php#logout
-type LogoutRequest struct {
-	CustomerNumber  string `json:"customernumber"`
-	APIKey          string `json:"apikey"`
-	APISessionID    string `json:"apisessionid"`
-	ClientRequestID string `json:"clientrequestid,omitempty"`
-}
-
-// UpdateDNSRecordsRequest as specified in netcup WSDL.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php#updateDnsRecords
-type UpdateDNSRecordsRequest struct {
-	DomainName      string       `json:"domainname"`
-	CustomerNumber  string       `json:"customernumber"`
-	APIKey          string       `json:"apikey"`
-	APISessionID    string       `json:"apisessionid"`
-	ClientRequestID string       `json:"clientrequestid,omitempty"`
-	DNSRecordSet    DNSRecordSet `json:"dnsrecordset"`
-}
-
-// DNSRecordSet as specified in netcup WSDL.
-// needed in UpdateDNSRecordsRequest.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecordset
-type DNSRecordSet struct {
-	DNSRecords []DNSRecord `json:"dnsrecords"`
-}
-
-// InfoDNSRecordsRequest as specified in netcup WSDL.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php#infoDnsRecords
-type InfoDNSRecordsRequest struct {
-	DomainName      string `json:"domainname"`
-	CustomerNumber  string `json:"customernumber"`
-	APIKey          string `json:"apikey"`
-	APISessionID    string `json:"apisessionid"`
-	ClientRequestID string `json:"clientrequestid,omitempty"`
-}
-
-// DNSRecord as specified in netcup WSDL.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecord
-type DNSRecord struct {
-	ID           int    `json:"id,string,omitempty"`
-	Hostname     string `json:"hostname"`
-	RecordType   string `json:"type"`
-	Priority     string `json:"priority,omitempty"`
-	Destination  string `json:"destination"`
-	DeleteRecord bool   `json:"deleterecord,omitempty"`
-	State        string `json:"state,omitempty"`
-	TTL          int    `json:"ttl,omitempty"`
-}
-
-// ResponseMsg as specified in netcup WSDL.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Responsemessage
-type ResponseMsg struct {
-	ServerRequestID string          `json:"serverrequestid"`
-	ClientRequestID string          `json:"clientrequestid,omitempty"`
-	Action          string          `json:"action"`
-	Status          string          `json:"status"`
-	StatusCode      int             `json:"statuscode"`
-	ShortMessage    string          `json:"shortmessage"`
-	LongMessage     string          `json:"longmessage"`
-	ResponseData    json.RawMessage `json:"responsedata,omitempty"`
-}
-
-func (r *ResponseMsg) Error() string {
-	return fmt.Sprintf("an error occurred during the action %s: [Status=%s, StatusCode=%d, ShortMessage=%s, LongMessage=%s]",
-		r.Action, r.Status, r.StatusCode, r.ShortMessage, r.LongMessage)
-}
-
-// LoginResponse response to login action.
-type LoginResponse struct {
-	APISessionID string `json:"apisessionid"`
-}
-
-// InfoDNSRecordsResponse response to infoDnsRecords action.
-type InfoDNSRecordsResponse struct {
-	APISessionID string      `json:"apisessionid"`
-	DNSRecords   []DNSRecord `json:"dnsrecords,omitempty"`
-}
-
 // Client netcup DNS client.
 type Client struct {
 	customerNumber string
 	apiKey         string
 	apiPassword    string
-	HTTPClient     *http.Client
-	BaseURL        string
+
+	baseURL    string
+	HTTPClient *http.Client
 }
 
 // NewClient creates a netcup DNS client.
@@ -131,73 +36,27 @@ func NewClient(customerNumber, apiKey, apiPassword string) (*Client, error) {
 		customerNumber: customerNumber,
 		apiKey:         apiKey,
 		apiPassword:    apiPassword,
-		BaseURL:        defaultBaseURL,
-		HTTPClient: &http.Client{
-			Timeout: 10 * time.Second,
-		},
+		baseURL:        defaultBaseURL,
+		HTTPClient:     &http.Client{Timeout: 10 * time.Second},
 	}, nil
 }
 
-// Login performs the login as specified by the netcup WSDL
-// returns sessionID needed to perform remaining actions.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php
-func (c *Client) Login() (string, error) {
-	payload := &Request{
-		Action: "login",
-		Param: &LoginRequest{
-			CustomerNumber:  c.customerNumber,
-			APIKey:          c.apiKey,
-			APIPassword:     c.apiPassword,
-			ClientRequestID: "",
-		},
-	}
-
-	var responseData LoginResponse
-	err := c.doRequest(payload, &responseData)
-	if err != nil {
-		return "", fmt.Errorf("loging error: %w", err)
-	}
-
-	return responseData.APISessionID, nil
-}
-
-// Logout performs the logout with the supplied sessionID as specified by the netcup WSDL.
-// https://ccp.netcup.net/run/webservice/servers/endpoint.php
-func (c *Client) Logout(sessionID string) error {
-	payload := &Request{
-		Action: "logout",
-		Param: &LogoutRequest{
-			CustomerNumber:  c.customerNumber,
-			APIKey:          c.apiKey,
-			APISessionID:    sessionID,
-			ClientRequestID: "",
-		},
-	}
-
-	err := c.doRequest(payload, nil)
-	if err != nil {
-		return fmt.Errorf("logout error: %w", err)
-	}
-
-	return nil
-}
-
 // UpdateDNSRecord performs an update of the DNSRecords as specified by the netcup WSDL.
 // https://ccp.netcup.net/run/webservice/servers/endpoint.php
-func (c *Client) UpdateDNSRecord(sessionID, domainName string, records []DNSRecord) error {
+func (c *Client) UpdateDNSRecord(ctx context.Context, domainName string, records []DNSRecord) error {
 	payload := &Request{
 		Action: "updateDnsRecords",
 		Param: UpdateDNSRecordsRequest{
 			DomainName:      domainName,
 			CustomerNumber:  c.customerNumber,
 			APIKey:          c.apiKey,
-			APISessionID:    sessionID,
+			APISessionID:    getSessionID(ctx),
 			ClientRequestID: "",
 			DNSRecordSet:    DNSRecordSet{DNSRecords: records},
 		},
 	}
 
-	err := c.doRequest(payload, nil)
+	err := c.doRequest(ctx, payload, nil)
 	if err != nil {
 		return fmt.Errorf("error when sending the request: %w", err)
 	}
@@ -208,20 +67,20 @@ func (c *Client) UpdateDNSRecord(sessionID, domainName string, records []DNSReco
 // GetDNSRecords retrieves all dns records of an DNS-Zone as specified by the netcup WSDL
 // returns an array of DNSRecords.
 // https://ccp.netcup.net/run/webservice/servers/endpoint.php
-func (c *Client) GetDNSRecords(hostname, apiSessionID string) ([]DNSRecord, error) {
+func (c *Client) GetDNSRecords(ctx context.Context, hostname string) ([]DNSRecord, error) {
 	payload := &Request{
 		Action: "infoDnsRecords",
 		Param: InfoDNSRecordsRequest{
 			DomainName:      hostname,
 			CustomerNumber:  c.customerNumber,
 			APIKey:          c.apiKey,
-			APISessionID:    apiSessionID,
+			APISessionID:    getSessionID(ctx),
 			ClientRequestID: "",
 		},
 	}
 
 	var responseData InfoDNSRecordsResponse
-	err := c.doRequest(payload, &responseData)
+	err := c.doRequest(ctx, payload, &responseData)
 	if err != nil {
 		return nil, fmt.Errorf("error when sending the request: %w", err)
 	}
@@ -231,30 +90,26 @@ func (c *Client) GetDNSRecords(hostname, apiSessionID string) ([]DNSRecord, erro
 
 // doRequest marshals given body to JSON, send the request to netcup API
 // and returns body of response.
-func (c *Client) doRequest(payload, responseData interface{}) error {
-	body, err := json.Marshal(payload)
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest(http.MethodPost, c.BaseURL, bytes.NewReader(body))
+func (c *Client) doRequest(ctx context.Context, payload, result any) error {
+	req, err := newJSONRequest(ctx, http.MethodPost, c.baseURL, payload)
 	if err != nil {
 		return err
 	}
 
 	req.Close = true
-	req.Header.Set("content-type", "application/json")
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	if err = checkResponse(resp); err != nil {
-		return err
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode >= http.StatusMultipleChoices {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	respMsg, err := decodeResponseMsg(resp)
+	respMsg, err := unmarshalResponseMsg(req, resp)
 	if err != nil {
 		return err
 	}
@@ -263,58 +118,18 @@ func (c *Client) doRequest(payload, responseData interface{}) error {
 		return respMsg
 	}
 
-	if responseData != nil {
-		err = json.Unmarshal(respMsg.ResponseData, responseData)
-		if err != nil {
-			//nolint:errorlint // in this context respMsg is not an error.
-			return fmt.Errorf("%v: unmarshaling %T error: %w: %s",
-				respMsg, responseData, err, string(respMsg.ResponseData))
-		}
+	if result == nil {
+		return nil
+	}
+
+	err = json.Unmarshal(respMsg.ResponseData, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, respMsg.ResponseData, err)
 	}
 
 	return nil
 }
 
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode > 299 {
-		if resp.Body == nil {
-			return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode)
-		}
-
-		defer resp.Body.Close()
-
-		raw, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err)
-		}
-
-		return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw))
-	}
-
-	return nil
-}
-
-func decodeResponseMsg(resp *http.Response) (*ResponseMsg, error) {
-	if resp.Body == nil {
-		return nil, fmt.Errorf("response body is nil, status code=%d", resp.StatusCode)
-	}
-
-	defer resp.Body.Close()
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err)
-	}
-
-	var respMsg ResponseMsg
-	err = json.Unmarshal(raw, &respMsg)
-	if err != nil {
-		return nil, fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", respMsg, resp.StatusCode, err, string(raw))
-	}
-
-	return &respMsg, nil
-}
-
 // GetDNSRecordIdx searches a given array of DNSRecords for a given DNSRecord
 // equivalence is determined by Destination and RecortType attributes
 // returns index of given DNSRecord in given array of DNSRecords.
@@ -326,3 +141,42 @@ func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) {
 	}
 	return -1, errors.New("no DNS Record found")
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint string, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint, buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func unmarshalResponseMsg(req *http.Request, resp *http.Response) (*ResponseMsg, error) {
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var respMsg ResponseMsg
+	err = json.Unmarshal(raw, &respMsg)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return &respMsg, nil
+}
diff --git a/providers/dns/netcup/internal/client_test.go b/providers/dns/netcup/internal/client_test.go
index d62e5b2c..80923fee 100644
--- a/providers/dns/netcup/internal/client_test.go
+++ b/providers/dns/netcup/internal/client_test.go
@@ -1,11 +1,12 @@
 package internal
 
 import (
+	"bytes"
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
-	"strconv"
 	"strings"
 	"testing"
 
@@ -31,8 +32,8 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	client, err := NewClient("a", "b", "c")
 	require.NoError(t, err)
 
+	client.baseURL = server.URL
 	client.HTTPClient = server.Client()
-	client.BaseURL = server.URL
 
 	return client, mux
 }
@@ -139,205 +140,6 @@ func TestGetDNSRecordIdx(t *testing.T) {
 	}
 }
 
-func TestClient_Login(t *testing.T) {
-	client, mux := setupTest(t)
-
-	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
-		raw, err := io.ReadAll(req.Body)
-		if err != nil {
-			http.Error(rw, err.Error(), http.StatusInternalServerError)
-			return
-		}
-
-		if string(raw) != `{"action":"login","param":{"customernumber":"a","apikey":"b","apipassword":"c"}}` {
-			http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
-			return
-		}
-
-		response := `
-		{
-		    "serverrequestid": "srv-request-id",
-		    "clientrequestid": "",
-		    "action": "login",
-		    "status": "success",
-		    "statuscode": 2000,
-		    "shortmessage": "Login successful",
-		    "longmessage": "Session has been created successful.",
-		    "responsedata": {
-		        "apisessionid": "api-session-id"
-		    }
-		}
-		`
-		_, err = rw.Write([]byte(response))
-		if err != nil {
-			http.Error(rw, err.Error(), http.StatusInternalServerError)
-			return
-		}
-	})
-
-	sessionID, err := client.Login()
-	require.NoError(t, err)
-
-	assert.Equal(t, "api-session-id", sessionID)
-}
-
-func TestClient_Login_errors(t *testing.T) {
-	testCases := []struct {
-		desc    string
-		handler func(rw http.ResponseWriter, req *http.Request)
-	}{
-		{
-			desc: "HTTP error",
-			handler: func(rw http.ResponseWriter, _ *http.Request) {
-				http.Error(rw, "error message", http.StatusInternalServerError)
-			},
-		},
-		{
-			desc: "API error",
-			handler: func(rw http.ResponseWriter, _ *http.Request) {
-				response := `
-					{
-						"serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
-						"clientrequestid":"",
-						"action":"login",
-						"status":"error",
-						"statuscode":4013,
-						"shortmessage":"Validation Error.",
-						"longmessage":"Message is empty.",
-						"responsedata":""
-					}`
-				_, err := rw.Write([]byte(response))
-				if err != nil {
-					http.Error(rw, err.Error(), http.StatusInternalServerError)
-					return
-				}
-			},
-		},
-		{
-			desc: "responsedata marshaling error",
-			handler: func(rw http.ResponseWriter, _ *http.Request) {
-				response := `
-							{
-								"serverrequestid": "srv-request-id",
-								"clientrequestid": "",
-								"action": "login",
-								"status": "success",
-								"statuscode": 2000,
-								"shortmessage": "Login successful",
-								"longmessage": "Session has been created successful.",
-								"responsedata": ""
-							}`
-				_, err := rw.Write([]byte(response))
-				if err != nil {
-					http.Error(rw, err.Error(), http.StatusInternalServerError)
-					return
-				}
-			},
-		},
-	}
-
-	for _, test := range testCases {
-		test := test
-		t.Run(test.desc, func(t *testing.T) {
-			t.Parallel()
-
-			client, mux := setupTest(t)
-
-			mux.HandleFunc("/", test.handler)
-
-			sessionID, err := client.Login()
-			assert.Error(t, err)
-			assert.Equal(t, "", sessionID)
-		})
-	}
-}
-
-func TestClient_Logout(t *testing.T) {
-	client, mux := setupTest(t)
-
-	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
-		raw, err := io.ReadAll(req.Body)
-		if err != nil {
-			http.Error(rw, err.Error(), http.StatusInternalServerError)
-			return
-		}
-
-		if string(raw) != `{"action":"logout","param":{"customernumber":"a","apikey":"b","apisessionid":"session-id"}}` {
-			http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
-			return
-		}
-
-		response := `
-			{
-				"serverrequestid": "request-id",
-				"clientrequestid": "",
-				"action": "logout",
-				"status": "success",
-				"statuscode": 2000,
-				"shortmessage": "Logout successful",
-				"longmessage": "Session has been terminated successful.",
-				"responsedata": ""
-			}`
-		_, err = rw.Write([]byte(response))
-		if err != nil {
-			http.Error(rw, err.Error(), http.StatusInternalServerError)
-			return
-		}
-	})
-
-	err := client.Logout("session-id")
-	require.NoError(t, err)
-}
-
-func TestClient_Logout_errors(t *testing.T) {
-	testCases := []struct {
-		desc    string
-		handler func(rw http.ResponseWriter, req *http.Request)
-	}{
-		{
-			desc: "HTTP error",
-			handler: func(rw http.ResponseWriter, _ *http.Request) {
-				http.Error(rw, "error message", http.StatusInternalServerError)
-			},
-		},
-		{
-			desc: "API error",
-			handler: func(rw http.ResponseWriter, _ *http.Request) {
-				response := `
-					{
-						"serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
-						"clientrequestid":"",
-						"action":"logout",
-						"status":"error",
-						"statuscode":4013,
-						"shortmessage":"Validation Error.",
-						"longmessage":"Message is empty.",
-						"responsedata":""
-					}`
-				_, err := rw.Write([]byte(response))
-				if err != nil {
-					http.Error(rw, err.Error(), http.StatusInternalServerError)
-					return
-				}
-			},
-		},
-	}
-
-	for _, test := range testCases {
-		test := test
-		t.Run(test.desc, func(t *testing.T) {
-			t.Parallel()
-
-			client, mux := setupTest(t)
-
-			mux.HandleFunc("/", test.handler)
-
-			err := client.Logout("session-id")
-			require.Error(t, err)
-		})
-	}
-}
-
 func TestClient_GetDNSRecords(t *testing.T) {
 	client, mux := setupTest(t)
 
@@ -348,7 +150,7 @@ func TestClient_GetDNSRecords(t *testing.T) {
 			return
 		}
 
-		if string(raw) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":"api-session-id"}}` {
+		if string(bytes.TrimSpace(raw)) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":""}}` {
 			http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
 			return
 		}
@@ -413,7 +215,7 @@ func TestClient_GetDNSRecords(t *testing.T) {
 		TTL:          300,
 	}}
 
-	records, err := client.GetDNSRecords("example.com", "api-session-id")
+	records, err := client.GetDNSRecords(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	assert.Equal(t, expected, records)
@@ -494,14 +296,14 @@ func TestClient_GetDNSRecords_errors(t *testing.T) {
 
 			mux.HandleFunc("/", test.handler)
 
-			records, err := client.GetDNSRecords("example.com", "api-session-id")
+			records, err := client.GetDNSRecords(context.Background(), "example.com")
 			require.Error(t, err)
 			assert.Empty(t, records)
 		})
 	}
 }
 
-func TestLiveClientAuth(t *testing.T) {
+func TestClient_GetDNSRecords_Live(t *testing.T) {
 	if !envTest.IsLiveTest() {
 		t.Skip("skipping live test")
 	}
@@ -515,35 +317,7 @@ func TestLiveClientAuth(t *testing.T) {
 		envTest.GetValue("NETCUP_API_PASSWORD"))
 	require.NoError(t, err)
 
-	for i := 1; i < 4; i++ {
-		i := i
-		t.Run("Test_"+strconv.Itoa(i), func(t *testing.T) {
-			t.Parallel()
-
-			sessionID, err := client.Login()
-			require.NoError(t, err)
-
-			err = client.Logout(sessionID)
-			require.NoError(t, err)
-		})
-	}
-}
-
-func TestLiveClientGetDnsRecords(t *testing.T) {
-	if !envTest.IsLiveTest() {
-		t.Skip("skipping live test")
-	}
-
-	// Setup
-	envTest.RestoreEnv()
-
-	client, err := NewClient(
-		envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
-		envTest.GetValue("NETCUP_API_KEY"),
-		envTest.GetValue("NETCUP_API_PASSWORD"))
-	require.NoError(t, err)
-
-	sessionID, err := client.Login()
+	ctx, err := client.CreateSessionContext(context.Background())
 	require.NoError(t, err)
 
 	info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==")
@@ -554,15 +328,15 @@ func TestLiveClientGetDnsRecords(t *testing.T) {
 	zone = dns01.UnFqdn(zone)
 
 	// TestMethod
-	_, err = client.GetDNSRecords(zone, sessionID)
+	_, err = client.GetDNSRecords(ctx, zone)
 	require.NoError(t, err)
 
 	// Tear down
-	err = client.Logout(sessionID)
+	err = client.Logout(ctx)
 	require.NoError(t, err)
 }
 
-func TestLiveClientUpdateDnsRecord(t *testing.T) {
+func TestClient_UpdateDNSRecord_Live(t *testing.T) {
 	if !envTest.IsLiveTest() {
 		t.Skip("skipping live test")
 	}
@@ -576,7 +350,7 @@ func TestLiveClientUpdateDnsRecord(t *testing.T) {
 		envTest.GetValue("NETCUP_API_PASSWORD"))
 	require.NoError(t, err)
 
-	sessionID, err := client.Login()
+	ctx, err := client.CreateSessionContext(context.Background())
 	require.NoError(t, err)
 
 	info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==")
@@ -597,10 +371,10 @@ func TestLiveClientUpdateDnsRecord(t *testing.T) {
 	// test
 	zone = dns01.UnFqdn(zone)
 
-	err = client.UpdateDNSRecord(sessionID, zone, []DNSRecord{record})
+	err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record})
 	require.NoError(t, err)
 
-	records, err := client.GetDNSRecords(zone, sessionID)
+	records, err := client.GetDNSRecords(ctx, zone)
 	require.NoError(t, err)
 
 	recordIdx, err := GetDNSRecordIdx(records, record)
@@ -614,9 +388,9 @@ func TestLiveClientUpdateDnsRecord(t *testing.T) {
 	records[recordIdx].DeleteRecord = true
 
 	// Tear down
-	err = client.UpdateDNSRecord(sessionID, envTest.GetDomain(), []DNSRecord{records[recordIdx]})
+	err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]})
 	require.NoError(t, err, "Did not remove record! Please do so yourself.")
 
-	err = client.Logout(sessionID)
+	err = client.Logout(ctx)
 	require.NoError(t, err)
 }
diff --git a/providers/dns/netcup/internal/session.go b/providers/dns/netcup/internal/session.go
new file mode 100644
index 00000000..6627d74e
--- /dev/null
+++ b/providers/dns/netcup/internal/session.go
@@ -0,0 +1,72 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+)
+
+type sessionKey string
+
+const sessionIDKey sessionKey = "sessionID"
+
+// login performs the login as specified by the netcup WSDL
+// returns sessionID needed to perform remaining actions.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php
+func (c *Client) login(ctx context.Context) (string, error) {
+	payload := &Request{
+		Action: "login",
+		Param: &LoginRequest{
+			CustomerNumber:  c.customerNumber,
+			APIKey:          c.apiKey,
+			APIPassword:     c.apiPassword,
+			ClientRequestID: "",
+		},
+	}
+
+	var responseData LoginResponse
+	err := c.doRequest(ctx, payload, &responseData)
+	if err != nil {
+		return "", fmt.Errorf("loging error: %w", err)
+	}
+
+	return responseData.APISessionID, nil
+}
+
+// Logout performs the logout with the supplied sessionID as specified by the netcup WSDL.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php
+func (c *Client) Logout(ctx context.Context) error {
+	payload := &Request{
+		Action: "logout",
+		Param: &LogoutRequest{
+			CustomerNumber:  c.customerNumber,
+			APIKey:          c.apiKey,
+			APISessionID:    getSessionID(ctx),
+			ClientRequestID: "",
+		},
+	}
+
+	err := c.doRequest(ctx, payload, nil)
+	if err != nil {
+		return fmt.Errorf("logout error: %w", err)
+	}
+
+	return nil
+}
+
+func (c *Client) CreateSessionContext(ctx context.Context) (context.Context, error) {
+	sessID, err := c.login(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return context.WithValue(ctx, sessionIDKey, sessID), nil
+}
+
+func getSessionID(ctx context.Context) string {
+	sessID, ok := ctx.Value(sessionIDKey).(string)
+	if !ok {
+		return ""
+	}
+
+	return sessID
+}
diff --git a/providers/dns/netcup/internal/session_test.go b/providers/dns/netcup/internal/session_test.go
new file mode 100644
index 00000000..ab605779
--- /dev/null
+++ b/providers/dns/netcup/internal/session_test.go
@@ -0,0 +1,245 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func mockContext() context.Context {
+	return context.WithValue(context.Background(), sessionIDKey, "session-id")
+}
+
+func TestClient_Login(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
+		raw, err := io.ReadAll(req.Body)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		if string(bytes.TrimSpace(raw)) != `{"action":"login","param":{"customernumber":"a","apikey":"b","apipassword":"c"}}` {
+			http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
+			return
+		}
+
+		response := `
+		{
+		    "serverrequestid": "srv-request-id",
+		    "clientrequestid": "",
+		    "action": "login",
+		    "status": "success",
+		    "statuscode": 2000,
+		    "shortmessage": "Login successful",
+		    "longmessage": "Session has been created successful.",
+		    "responsedata": {
+		        "apisessionid": "api-session-id"
+		    }
+		}
+		`
+		_, err = rw.Write([]byte(response))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	sessionID, err := client.login(context.Background())
+	require.NoError(t, err)
+
+	assert.Equal(t, "api-session-id", sessionID)
+}
+
+func TestClient_Login_errors(t *testing.T) {
+	testCases := []struct {
+		desc    string
+		handler func(rw http.ResponseWriter, req *http.Request)
+	}{
+		{
+			desc: "HTTP error",
+			handler: func(rw http.ResponseWriter, _ *http.Request) {
+				http.Error(rw, "error message", http.StatusInternalServerError)
+			},
+		},
+		{
+			desc: "API error",
+			handler: func(rw http.ResponseWriter, _ *http.Request) {
+				response := `
+					{
+						"serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
+						"clientrequestid":"",
+						"action":"login",
+						"status":"error",
+						"statuscode":4013,
+						"shortmessage":"Validation Error.",
+						"longmessage":"Message is empty.",
+						"responsedata":""
+					}`
+				_, err := rw.Write([]byte(response))
+				if err != nil {
+					http.Error(rw, err.Error(), http.StatusInternalServerError)
+					return
+				}
+			},
+		},
+		{
+			desc: "responsedata marshaling error",
+			handler: func(rw http.ResponseWriter, _ *http.Request) {
+				response := `
+							{
+								"serverrequestid": "srv-request-id",
+								"clientrequestid": "",
+								"action": "login",
+								"status": "success",
+								"statuscode": 2000,
+								"shortmessage": "Login successful",
+								"longmessage": "Session has been created successful.",
+								"responsedata": ""
+							}`
+				_, err := rw.Write([]byte(response))
+				if err != nil {
+					http.Error(rw, err.Error(), http.StatusInternalServerError)
+					return
+				}
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			client, mux := setupTest(t)
+
+			mux.HandleFunc("/", test.handler)
+
+			sessionID, err := client.login(context.Background())
+			assert.Error(t, err)
+			assert.Equal(t, "", sessionID)
+		})
+	}
+}
+
+func TestClient_Logout(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
+		raw, err := io.ReadAll(req.Body)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		if string(bytes.TrimSpace(raw)) != `{"action":"logout","param":{"customernumber":"a","apikey":"b","apisessionid":"session-id"}}` {
+			http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
+			return
+		}
+
+		response := `
+			{
+				"serverrequestid": "request-id",
+				"clientrequestid": "",
+				"action": "logout",
+				"status": "success",
+				"statuscode": 2000,
+				"shortmessage": "Logout successful",
+				"longmessage": "Session has been terminated successful.",
+				"responsedata": ""
+			}`
+		_, err = rw.Write([]byte(response))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	err := client.Logout(mockContext())
+	require.NoError(t, err)
+}
+
+func TestClient_Logout_errors(t *testing.T) {
+	testCases := []struct {
+		desc    string
+		handler func(rw http.ResponseWriter, req *http.Request)
+	}{
+		{
+			desc: "HTTP error",
+			handler: func(rw http.ResponseWriter, _ *http.Request) {
+				http.Error(rw, "error message", http.StatusInternalServerError)
+			},
+		},
+		{
+			desc: "API error",
+			handler: func(rw http.ResponseWriter, _ *http.Request) {
+				response := `
+					{
+						"serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE",
+						"clientrequestid":"",
+						"action":"logout",
+						"status":"error",
+						"statuscode":4013,
+						"shortmessage":"Validation Error.",
+						"longmessage":"Message is empty.",
+						"responsedata":""
+					}`
+				_, err := rw.Write([]byte(response))
+				if err != nil {
+					http.Error(rw, err.Error(), http.StatusInternalServerError)
+					return
+				}
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			client, mux := setupTest(t)
+
+			mux.HandleFunc("/", test.handler)
+
+			err := client.Logout(context.Background())
+			require.Error(t, err)
+		})
+	}
+}
+
+func TestLiveClientAuth(t *testing.T) {
+	if !envTest.IsLiveTest() {
+		t.Skip("skipping live test")
+	}
+
+	// Setup
+	envTest.RestoreEnv()
+
+	client, err := NewClient(
+		envTest.GetValue("NETCUP_CUSTOMER_NUMBER"),
+		envTest.GetValue("NETCUP_API_KEY"),
+		envTest.GetValue("NETCUP_API_PASSWORD"))
+	require.NoError(t, err)
+
+	for i := 1; i < 4; i++ {
+		i := i
+		t.Run("Test_"+strconv.Itoa(i), func(t *testing.T) {
+			t.Parallel()
+
+			ctx, err := client.CreateSessionContext(context.Background())
+			require.NoError(t, err)
+
+			err = client.Logout(ctx)
+			require.NoError(t, err)
+		})
+	}
+}
diff --git a/providers/dns/netcup/internal/types.go b/providers/dns/netcup/internal/types.go
new file mode 100644
index 00000000..55212f90
--- /dev/null
+++ b/providers/dns/netcup/internal/types.go
@@ -0,0 +1,105 @@
+package internal
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// success response status.
+const success = "success"
+
+// Request wrapper as specified in netcup wiki
+// needed for every request to netcup API around *Msg.
+// https://www.netcup-wiki.de/wiki/CCP_API#Anmerkungen_zu_JSON-Requests
+type Request struct {
+	Action string `json:"action"`
+	Param  any    `json:"param"`
+}
+
+// LoginRequest as specified in netcup WSDL.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php#login
+type LoginRequest struct {
+	CustomerNumber  string `json:"customernumber"`
+	APIKey          string `json:"apikey"`
+	APIPassword     string `json:"apipassword"`
+	ClientRequestID string `json:"clientrequestid,omitempty"`
+}
+
+// LogoutRequest as specified in netcup WSDL.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php#logout
+type LogoutRequest struct {
+	CustomerNumber  string `json:"customernumber"`
+	APIKey          string `json:"apikey"`
+	APISessionID    string `json:"apisessionid"`
+	ClientRequestID string `json:"clientrequestid,omitempty"`
+}
+
+// UpdateDNSRecordsRequest as specified in netcup WSDL.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php#updateDnsRecords
+type UpdateDNSRecordsRequest struct {
+	DomainName      string       `json:"domainname"`
+	CustomerNumber  string       `json:"customernumber"`
+	APIKey          string       `json:"apikey"`
+	APISessionID    string       `json:"apisessionid"`
+	ClientRequestID string       `json:"clientrequestid,omitempty"`
+	DNSRecordSet    DNSRecordSet `json:"dnsrecordset"`
+}
+
+// DNSRecordSet as specified in netcup WSDL.
+// needed in UpdateDNSRecordsRequest.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecordset
+type DNSRecordSet struct {
+	DNSRecords []DNSRecord `json:"dnsrecords"`
+}
+
+// InfoDNSRecordsRequest as specified in netcup WSDL.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php#infoDnsRecords
+type InfoDNSRecordsRequest struct {
+	DomainName      string `json:"domainname"`
+	CustomerNumber  string `json:"customernumber"`
+	APIKey          string `json:"apikey"`
+	APISessionID    string `json:"apisessionid"`
+	ClientRequestID string `json:"clientrequestid,omitempty"`
+}
+
+// DNSRecord as specified in netcup WSDL.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecord
+type DNSRecord struct {
+	ID           int    `json:"id,string,omitempty"`
+	Hostname     string `json:"hostname"`
+	RecordType   string `json:"type"`
+	Priority     string `json:"priority,omitempty"`
+	Destination  string `json:"destination"`
+	DeleteRecord bool   `json:"deleterecord,omitempty"`
+	State        string `json:"state,omitempty"`
+	TTL          int    `json:"ttl,omitempty"`
+}
+
+// ResponseMsg as specified in netcup WSDL.
+// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Responsemessage
+type ResponseMsg struct {
+	ServerRequestID string          `json:"serverrequestid"`
+	ClientRequestID string          `json:"clientrequestid,omitempty"`
+	Action          string          `json:"action"`
+	Status          string          `json:"status"`
+	StatusCode      int             `json:"statuscode"`
+	ShortMessage    string          `json:"shortmessage"`
+	LongMessage     string          `json:"longmessage"`
+	ResponseData    json.RawMessage `json:"responsedata,omitempty"`
+}
+
+func (r *ResponseMsg) Error() string {
+	return fmt.Sprintf("an error occurred during the action %s: [Status=%s, StatusCode=%d, ShortMessage=%s, LongMessage=%s]",
+		r.Action, r.Status, r.StatusCode, r.ShortMessage, r.LongMessage)
+}
+
+// LoginResponse response to login action.
+type LoginResponse struct {
+	APISessionID string `json:"apisessionid"`
+}
+
+// InfoDNSRecordsResponse response to infoDnsRecords action.
+type InfoDNSRecordsResponse struct {
+	APISessionID string      `json:"apisessionid"`
+	DNSRecords   []DNSRecord `json:"dnsrecords,omitempty"`
+}
diff --git a/providers/dns/netcup/netcup.go b/providers/dns/netcup/netcup.go
index d3d16483..328c25a1 100644
--- a/providers/dns/netcup/netcup.go
+++ b/providers/dns/netcup/netcup.go
@@ -2,6 +2,7 @@
 package netcup
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -96,16 +97,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("netcup: failed to find DNSZone, %w", err)
+		return fmt.Errorf("netcup: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	sessionID, err := d.client.Login()
+	ctx, err := d.client.CreateSessionContext(context.Background())
 	if err != nil {
 		return fmt.Errorf("netcup: %w", err)
 	}
 
 	defer func() {
-		err = d.client.Logout(sessionID)
+		err = d.client.Logout(ctx)
 		if err != nil {
 			log.Print("netcup: %v", err)
 		}
@@ -121,7 +122,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	zone = dns01.UnFqdn(zone)
 
-	records, err := d.client.GetDNSRecords(zone, sessionID)
+	records, err := d.client.GetDNSRecords(ctx, zone)
 	if err != nil {
 		// skip no existing records
 		log.Infof("no existing records, error ignored: %v", err)
@@ -129,7 +130,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	records = append(records, record)
 
-	err = d.client.UpdateDNSRecord(sessionID, zone, records)
+	err = d.client.UpdateDNSRecord(ctx, zone, records)
 	if err != nil {
 		return fmt.Errorf("netcup: failed to add TXT-Record: %w", err)
 	}
@@ -143,16 +144,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("netcup: failed to find DNSZone, %w", err)
+		return fmt.Errorf("netcup: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	sessionID, err := d.client.Login()
+	ctx, err := d.client.CreateSessionContext(context.Background())
 	if err != nil {
 		return fmt.Errorf("netcup: %w", err)
 	}
 
 	defer func() {
-		err = d.client.Logout(sessionID)
+		err = d.client.Logout(ctx)
 		if err != nil {
 			log.Print("netcup: %v", err)
 		}
@@ -162,7 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	zone = dns01.UnFqdn(zone)
 
-	records, err := d.client.GetDNSRecords(zone, sessionID)
+	records, err := d.client.GetDNSRecords(ctx, zone)
 	if err != nil {
 		return fmt.Errorf("netcup: %w", err)
 	}
@@ -180,7 +181,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	records[idx].DeleteRecord = true
 
-	err = d.client.UpdateDNSRecord(sessionID, zone, []internal.DNSRecord{records[idx]})
+	err = d.client.UpdateDNSRecord(ctx, zone, []internal.DNSRecord{records[idx]})
 	if err != nil {
 		return fmt.Errorf("netcup: %w", err)
 	}
diff --git a/providers/dns/netlify/internal/client.go b/providers/dns/netlify/internal/client.go
index 5955625f..06651bde 100644
--- a/providers/dns/netlify/internal/client.go
+++ b/providers/dns/netlify/internal/client.go
@@ -2,157 +2,161 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/oauth2"
 )
 
 const defaultBaseURL = "https://api.netlify.com/api/v1"
 
 // Client Netlify API client.
 type Client struct {
-	HTTPClient *http.Client
-	BaseURL    string
-
-	token string
+	baseURL    *url.URL
+	httpClient *http.Client
 }
 
 // NewClient creates a new Client.
-func NewClient(token string) *Client {
-	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    defaultBaseURL,
-		token:      token,
+func NewClient(hc *http.Client) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	if hc == nil {
+		hc = &http.Client{Timeout: 5 * time.Second}
 	}
+
+	return &Client{baseURL: baseURL, httpClient: hc}
 }
 
 // GetRecords gets a DNS records.
-func (c *Client) GetRecords(zoneID string) ([]DNSRecord, error) {
-	endpoint, err := c.createEndpoint("dns_zones", zoneID, "dns_records")
-	if err != nil {
-		return nil, fmt.Errorf("failed to parse endpoint: %w", err)
-	}
+func (c *Client) GetRecords(ctx context.Context, zoneID string) ([]DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "dns_records")
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request: %w", err)
 	}
 
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
-
-	resp, err := c.HTTPClient.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("API call failed: %w", err)
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
+	if resp.StatusCode != http.StatusOK {
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("invalid status code: %s: %s", resp.Status, string(body))
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	var records []DNSRecord
-	err = json.Unmarshal(body, &records)
+	err = json.Unmarshal(raw, &records)
 	if err != nil {
-		return nil, fmt.Errorf("failed to marshal response body: %w", err)
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return records, nil
 }
 
 // CreateRecord creates a DNS records.
-func (c *Client) CreateRecord(zoneID string, record DNSRecord) (*DNSRecord, error) {
-	endpoint, err := c.createEndpoint("dns_zones", zoneID, "dns_records")
-	if err != nil {
-		return nil, fmt.Errorf("failed to parse endpoint: %w", err)
-	}
+func (c *Client) CreateRecord(ctx context.Context, zoneID string, record DNSRecord) (*DNSRecord, error) {
+	endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "dns_records")
 
-	marshaledRecord, err := json.Marshal(record)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshal request body: %w", err)
-	}
-
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(marshaledRecord))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request: %w", err)
 	}
 
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json; charset=utf-8")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
-
-	resp, err := c.HTTPClient.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("API call failed: %w", err)
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
+	if resp.StatusCode != http.StatusCreated {
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	if resp.StatusCode != http.StatusCreated {
-		return nil, fmt.Errorf("invalid status code: %s: %s", resp.Status, string(body))
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	var recordResp DNSRecord
-	err = json.Unmarshal(body, &recordResp)
+	err = json.Unmarshal(raw, &recordResp)
 	if err != nil {
-		return nil, fmt.Errorf("failed to marshal response body: %w", err)
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return &recordResp, nil
 }
 
 // RemoveRecord removes a DNS records.
-func (c *Client) RemoveRecord(zoneID, recordID string) error {
-	endpoint, err := c.createEndpoint("dns_zones", zoneID, "dns_records", recordID)
+func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) error {
+	endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "dns_records", recordID)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
-		return fmt.Errorf("failed to parse endpoint: %w", err)
+		return err
 	}
 
-	req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
-
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return fmt.Errorf("API call failed: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("failed to read response body: %w", err)
-	}
-
 	if resp.StatusCode != http.StatusNoContent {
-		return fmt.Errorf("invalid status code: %s: %s", resp.Status, string(body))
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
 	return nil
 }
 
-func (c *Client) createEndpoint(parts ...string) (*url.URL, error) {
-	base, err := url.Parse(c.BaseURL)
-	if err != nil {
-		return nil, fmt.Errorf("failed to parse base URL: %w", err)
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload interface{}) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
 	}
 
-	return base.JoinPath(parts...), nil
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	}
+
+	return req, nil
+}
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
 }
diff --git a/providers/dns/netlify/internal/client_test.go b/providers/dns/netlify/internal/client_test.go
index a645bde1..e06a579b 100644
--- a/providers/dns/netlify/internal/client_test.go
+++ b/providers/dns/netlify/internal/client_test.go
@@ -1,10 +1,12 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -12,11 +14,22 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestClient_GetRecords(t *testing.T) {
+func setupTest(t *testing.T, token string) (*Client, *http.ServeMux) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
+	client := NewClient(OAuthStaticAccessToken(server.Client(), token))
+	client.baseURL, _ = url.Parse(server.URL)
+
+	return client, mux
+}
+
+func TestClient_GetRecords(t *testing.T) {
+	client, mux := setupTest(t, "tokenA")
+
 	mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, "unsupported method", http.StatusMethodNotAllowed)
@@ -45,10 +58,7 @@ func TestClient_GetRecords(t *testing.T) {
 		}
 	})
 
-	client := NewClient("tokenA")
-	client.BaseURL = server.URL
-
-	records, err := client.GetRecords("zoneID")
+	records, err := client.GetRecords(context.Background(), "zoneID")
 	require.NoError(t, err)
 
 	expected := []DNSRecord{
@@ -60,9 +70,7 @@ func TestClient_GetRecords(t *testing.T) {
 }
 
 func TestClient_CreateRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	client, mux := setupTest(t, "tokenB")
 
 	mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
@@ -93,9 +101,6 @@ func TestClient_CreateRecord(t *testing.T) {
 		}
 	})
 
-	client := NewClient("tokenB")
-	client.BaseURL = server.URL
-
 	record := DNSRecord{
 		Hostname: "_acme-challenge.example.com",
 		TTL:      300,
@@ -103,7 +108,7 @@ func TestClient_CreateRecord(t *testing.T) {
 		Value:    "txtxtxtxtxtxt",
 	}
 
-	result, err := client.CreateRecord("zoneID", record)
+	result, err := client.CreateRecord(context.Background(), "zoneID", record)
 	require.NoError(t, err)
 
 	expected := &DNSRecord{
@@ -118,9 +123,7 @@ func TestClient_CreateRecord(t *testing.T) {
 }
 
 func TestClient_RemoveRecord(t *testing.T) {
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
+	client, mux := setupTest(t, "tokenC")
 
 	mux.HandleFunc("/dns_zones/zoneID/dns_records/recordID", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodDelete {
@@ -137,9 +140,6 @@ func TestClient_RemoveRecord(t *testing.T) {
 		rw.WriteHeader(http.StatusNoContent)
 	})
 
-	client := NewClient("tokenC")
-	client.BaseURL = server.URL
-
-	err := client.RemoveRecord("zoneID", "recordID")
+	err := client.RemoveRecord(context.Background(), "zoneID", "recordID")
 	require.NoError(t, err)
 }
diff --git a/providers/dns/netlify/internal/model.go b/providers/dns/netlify/internal/types.go
similarity index 100%
rename from providers/dns/netlify/internal/model.go
rename to providers/dns/netlify/internal/types.go
diff --git a/providers/dns/netlify/netlify.go b/providers/dns/netlify/netlify.go
index 4608cd3e..28e85f54 100644
--- a/providers/dns/netlify/netlify.go
+++ b/providers/dns/netlify/netlify.go
@@ -2,6 +2,7 @@
 package netlify
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -80,11 +81,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("netlify: incomplete credentials, missing token")
 	}
 
-	client := internal.NewClient(config.Token)
-
-	if config.HTTPClient != nil {
-		client.HTTPClient = config.HTTPClient
-	}
+	client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token))
 
 	return &DNSProvider{
 		config:    config,
@@ -105,7 +102,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("netlify: failed to find zone: %w", err)
+		return fmt.Errorf("netlify: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	authZone = dns01.UnFqdn(authZone)
@@ -117,7 +114,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Value:    info.Value,
 	}
 
-	resp, err := d.client.CreateRecord(strings.ReplaceAll(authZone, ".", "_"), record)
+	resp, err := d.client.CreateRecord(context.Background(), strings.ReplaceAll(authZone, ".", "_"), record)
 	if err != nil {
 		return fmt.Errorf("netlify: failed to create TXT records: fqdn=%s, authZone=%s: %w", info.EffectiveFQDN, authZone, err)
 	}
@@ -135,7 +132,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("netlify: failed to find zone: %w", err)
+		return fmt.Errorf("netlify: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	authZone = dns01.UnFqdn(authZone)
@@ -148,7 +145,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("netlify: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	err = d.client.RemoveRecord(strings.ReplaceAll(authZone, ".", "_"), recordID)
+	err = d.client.RemoveRecord(context.Background(), strings.ReplaceAll(authZone, ".", "_"), recordID)
 	if err != nil {
 		return fmt.Errorf("netlify: failed to delete TXT records: fqdn=%s, authZone=%s, recordID=%s: %w", info.EffectiveFQDN, authZone, recordID, err)
 	}
diff --git a/providers/dns/nicmanager/internal/client.go b/providers/dns/nicmanager/internal/client.go
index 5f165662..3134fc4f 100644
--- a/providers/dns/nicmanager/internal/client.go
+++ b/providers/dns/nicmanager/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -10,6 +11,7 @@ import (
 	"strconv"
 	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	"github.com/pquerna/otp/totp"
 )
 
@@ -39,14 +41,14 @@ type Options struct {
 
 // Client a nicmanager DNS client.
 type Client struct {
-	HTTPClient *http.Client
-	baseURL    *url.URL
-
 	username string
 	password string
 	otp      string
 
 	mode string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient create a new Client.
@@ -72,29 +74,16 @@ func NewClient(opts Options) *Client {
 	return c
 }
 
-func (c Client) GetZone(name string) (*Zone, error) {
+func (c Client) GetZone(ctx context.Context, name string) (*Zone, error) {
 	endpoint := c.baseURL.JoinPath(c.mode, name)
 
-	resp, err := c.do(http.MethodGet, endpoint, nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		b, _ := io.ReadAll(resp.Body)
-
-		msg := APIError{StatusCode: resp.StatusCode}
-		if err = json.Unmarshal(b, &msg); err != nil {
-			return nil, fmt.Errorf("failed to get zone info for %s", name)
-		}
-
-		return nil, msg
-	}
-
 	var zone Zone
-	err = json.NewDecoder(resp.Body).Decode(&zone)
+	err = c.do(req, http.StatusOK, &zone)
 	if err != nil {
 		return nil, err
 	}
@@ -102,83 +91,109 @@ func (c Client) GetZone(name string) (*Zone, error) {
 	return &zone, nil
 }
 
-func (c Client) AddRecord(zone string, req RecordCreateUpdate) error {
+func (c Client) AddRecord(ctx context.Context, zone string, payload RecordCreateUpdate) error {
 	endpoint := c.baseURL.JoinPath(c.mode, zone, "records")
 
-	resp, err := c.do(http.MethodPost, endpoint, req)
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload)
 	if err != nil {
 		return err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusAccepted {
-		b, _ := io.ReadAll(resp.Body)
-
-		msg := APIError{StatusCode: resp.StatusCode}
-		if err = json.Unmarshal(b, &msg); err != nil {
-			return fmt.Errorf("records create should've returned %d but returned %d", http.StatusAccepted, resp.StatusCode)
-		}
-
-		return msg
+	err = c.do(req, http.StatusAccepted, nil)
+	if err != nil {
+		return err
 	}
 
 	return nil
 }
 
-func (c Client) DeleteRecord(zone string, record int) error {
+func (c Client) DeleteRecord(ctx context.Context, zone string, record int) error {
 	endpoint := c.baseURL.JoinPath(c.mode, zone, "records", strconv.Itoa(record))
 
-	resp, err := c.do(http.MethodDelete, endpoint, nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
 
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusAccepted {
-		b, _ := io.ReadAll(resp.Body)
-
-		msg := APIError{StatusCode: resp.StatusCode}
-		if err = json.Unmarshal(b, &msg); err != nil {
-			return fmt.Errorf("records delete should've returned %d but returned %d", http.StatusAccepted, resp.StatusCode)
-		}
-
-		return msg
+	err = c.do(req, http.StatusAccepted, nil)
+	if err != nil {
+		return err
 	}
 
 	return nil
 }
 
-func (c Client) do(method string, endpoint *url.URL, body interface{}) (*http.Response, error) {
-	var reqBody io.Reader
-	if body != nil {
-		jsonValue, err := json.Marshal(body)
-		if err != nil {
-			return nil, err
-		}
-
-		reqBody = bytes.NewBuffer(jsonValue)
-	}
-
-	r, err := http.NewRequest(method, endpoint.String(), reqBody)
-	if err != nil {
-		return nil, err
-	}
-
-	r.Header.Set("Accept", "application/json")
-	r.Header.Set("Content-Type", "application/json")
-
-	r.SetBasicAuth(c.username, c.password)
+func (c Client) do(req *http.Request, expectedStatusCode int, result any) error {
+	req.SetBasicAuth(c.username, c.password)
 
 	if c.otp != "" {
 		tan, err := totp.GenerateCode(c.otp, time.Now())
 		if err != nil {
-			return nil, err
+			return err
 		}
 
-		r.Header.Set(headerTOTPToken, tan)
+		req.Header.Set(headerTOTPToken, tan)
 	}
 
-	return c.HTTPClient.Do(r)
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != expectedStatusCode {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return err
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errAPI := APIError{StatusCode: resp.StatusCode}
+	if err := json.Unmarshal(raw, &errAPI); err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return errAPI
 }
diff --git a/providers/dns/nicmanager/internal/client_test.go b/providers/dns/nicmanager/internal/client_test.go
index 3823020b..822ec0db 100644
--- a/providers/dns/nicmanager/internal/client_test.go
+++ b/providers/dns/nicmanager/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -17,7 +18,7 @@ import (
 func TestClient_GetZone(t *testing.T) {
 	client := setupTest(t, "/anycast/nicmanager-anycastdns4.net", testHandler(http.MethodGet, http.StatusOK, "zone.json"))
 
-	zone, err := client.GetZone("nicmanager-anycastdns4.net")
+	zone, err := client.GetZone(context.Background(), "nicmanager-anycastdns4.net")
 	require.NoError(t, err)
 
 	expected := &Zone{
@@ -40,7 +41,7 @@ func TestClient_GetZone(t *testing.T) {
 func TestClient_GetZone_error(t *testing.T) {
 	client := setupTest(t, "/anycast/foo", testHandler(http.MethodGet, http.StatusNotFound, "error.json"))
 
-	_, err := client.GetZone("foo")
+	_, err := client.GetZone(context.Background(), "foo")
 	require.Error(t, err)
 }
 
@@ -54,7 +55,7 @@ func TestClient_AddRecord(t *testing.T) {
 		TTL:   3600,
 	}
 
-	err := client.AddRecord("zonedomain.tld", record)
+	err := client.AddRecord(context.Background(), "zonedomain.tld", record)
 	require.NoError(t, err)
 }
 
@@ -68,21 +69,21 @@ func TestClient_AddRecord_error(t *testing.T) {
 		TTL:   3600,
 	}
 
-	err := client.AddRecord("zonedomain.tld", record)
+	err := client.AddRecord(context.Background(), "zonedomain.tld", record)
 	require.Error(t, err)
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
 	client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusAccepted, "error.json"))
 
-	err := client.DeleteRecord("zonedomain.tld", 6)
+	err := client.DeleteRecord(context.Background(), "zonedomain.tld", 6)
 	require.NoError(t, err)
 }
 
 func TestClient_DeleteRecord_error(t *testing.T) {
 	client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusNoContent, ""))
 
-	err := client.DeleteRecord("zonedomain.tld", 7)
+	err := client.DeleteRecord(context.Background(), "zonedomain.tld", 7)
 	require.Error(t, err)
 }
 
diff --git a/providers/dns/nicmanager/nicmanager.go b/providers/dns/nicmanager/nicmanager.go
index 48561c76..e37efba9 100644
--- a/providers/dns/nicmanager/nicmanager.go
+++ b/providers/dns/nicmanager/nicmanager.go
@@ -2,6 +2,7 @@
 package nicmanager
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -139,10 +140,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("nicmanager: could not determine zone for domain %q: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("nicmanager: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	zone, err := d.client.GetZone(dns01.UnFqdn(rootDomain))
+	ctx := context.Background()
+
+	zone, err := d.client.GetZone(ctx, dns01.UnFqdn(rootDomain))
 	if err != nil {
 		return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err)
 	}
@@ -156,7 +159,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Value: info.Value,
 	}
 
-	err = d.client.AddRecord(zone.Name, record)
+	err = d.client.AddRecord(ctx, zone.Name, record)
 	if err != nil {
 		return fmt.Errorf("nicmanager: failed to create record [zone: %q, fqdn: %q]: %w", zone.Name, info.EffectiveFQDN, err)
 	}
@@ -170,10 +173,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("nicmanager: could not determine zone for domain %q: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("nicmanager: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	zone, err := d.client.GetZone(dns01.UnFqdn(rootDomain))
+	ctx := context.Background()
+
+	zone, err := d.client.GetZone(ctx, dns01.UnFqdn(rootDomain))
 	if err != nil {
 		return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err)
 	}
@@ -190,7 +195,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	}
 
 	if existingRecordFound {
-		err = d.client.DeleteRecord(zone.Name, existingRecord.ID)
+		err = d.client.DeleteRecord(ctx, zone.Name, existingRecord.ID)
 		if err != nil {
 			return fmt.Errorf("nicmanager: failed to delete record [zone: %q, domain: %q]: %w", zone.Name, name, err)
 		}
diff --git a/providers/dns/nifcloud/internal/client.go b/providers/dns/nifcloud/internal/client.go
index 24ad195a..3ad95488 100644
--- a/providers/dns/nifcloud/internal/client.go
+++ b/providers/dns/nifcloud/internal/client.go
@@ -2,14 +2,19 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"crypto/hmac"
 	"crypto/sha1"
 	"encoding/base64"
 	"encoding/xml"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
+	"net/url"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const (
@@ -19,72 +24,13 @@ const (
 	XMLNs = "https://route53.amazonaws.com/doc/2012-12-12/"
 )
 
-// ChangeResourceRecordSetsRequest is a complex type that contains change information for the resource record set.
-type ChangeResourceRecordSetsRequest struct {
-	XMLNs       string      `xml:"xmlns,attr"`
-	ChangeBatch ChangeBatch `xml:"ChangeBatch"`
-}
+// Client the API client for NIFCLOUD DNS.
+type Client struct {
+	accessKey string
+	secretKey string
 
-// ChangeResourceRecordSetsResponse is a complex type containing the response for the request.
-type ChangeResourceRecordSetsResponse struct {
-	ChangeInfo ChangeInfo `xml:"ChangeInfo"`
-}
-
-// GetChangeResponse is a complex type that contains the ChangeInfo element.
-type GetChangeResponse struct {
-	ChangeInfo ChangeInfo `xml:"ChangeInfo"`
-}
-
-// ErrorResponse is the information for any errors.
-type ErrorResponse struct {
-	Error struct {
-		Type    string `xml:"Type"`
-		Message string `xml:"Message"`
-		Code    string `xml:"Code"`
-	} `xml:"Error"`
-	RequestID string `xml:"RequestId"`
-}
-
-// ChangeBatch is the information for a change request.
-type ChangeBatch struct {
-	Changes Changes `xml:"Changes"`
-	Comment string  `xml:"Comment"`
-}
-
-// Changes is array of Change.
-type Changes struct {
-	Change []Change `xml:"Change"`
-}
-
-// Change is the information for each resource record set that you want to change.
-type Change struct {
-	Action            string            `xml:"Action"`
-	ResourceRecordSet ResourceRecordSet `xml:"ResourceRecordSet"`
-}
-
-// ResourceRecordSet is the information about the resource record set to create or delete.
-type ResourceRecordSet struct {
-	Name            string          `xml:"Name"`
-	Type            string          `xml:"Type"`
-	TTL             int             `xml:"TTL"`
-	ResourceRecords ResourceRecords `xml:"ResourceRecords"`
-}
-
-// ResourceRecords is array of ResourceRecord.
-type ResourceRecords struct {
-	ResourceRecord []ResourceRecord `xml:"ResourceRecord"`
-}
-
-// ResourceRecord is the information specific to the resource record.
-type ResourceRecord struct {
-	Value string `xml:"Value"`
-}
-
-// ChangeInfo is A complex type that describes change information about changes made to your hosted zone.
-type ChangeInfo struct {
-	ID          string `xml:"Id"`
-	Status      string `xml:"Status"`
-	SubmittedAt string `xml:"SubmittedAt"`
+	BaseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient Creates a new client of NIFCLOUD DNS.
@@ -93,117 +39,86 @@ func NewClient(accessKey, secretKey string) (*Client, error) {
 		return nil, errors.New("credentials missing")
 	}
 
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
 		accessKey:  accessKey,
 		secretKey:  secretKey,
-		BaseURL:    defaultBaseURL,
-		HTTPClient: &http.Client{},
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}, nil
 }
 
-// Client client of NIFCLOUD DNS.
-type Client struct {
-	accessKey  string
-	secretKey  string
-	BaseURL    string
-	HTTPClient *http.Client
-}
-
 // ChangeResourceRecordSets Call ChangeResourceRecordSets API and return response.
-func (c *Client) ChangeResourceRecordSets(hostedZoneID string, input ChangeResourceRecordSetsRequest) (*ChangeResourceRecordSetsResponse, error) {
-	requestURL := fmt.Sprintf("%s/%s/hostedzone/%s/rrset", c.BaseURL, apiVersion, hostedZoneID)
+func (c *Client) ChangeResourceRecordSets(ctx context.Context, hostedZoneID string, input ChangeResourceRecordSetsRequest) (*ChangeResourceRecordSetsResponse, error) {
+	endpoint := c.BaseURL.JoinPath(apiVersion, "hostedzone", hostedZoneID, "rrset")
 
-	body := &bytes.Buffer{}
-	body.WriteString(xml.Header)
-	err := xml.NewEncoder(body).Encode(input)
+	req, err := newXMLRequest(ctx, http.MethodPost, endpoint, input)
 	if err != nil {
 		return nil, err
 	}
 
-	req, err := http.NewRequest(http.MethodPost, requestURL, body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "text/xml; charset=utf-8")
-
-	err = c.sign(req)
-	if err != nil {
-		return nil, fmt.Errorf("an error occurred during the creation of the signature: %w", err)
-	}
-
-	res, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	if res.Body == nil {
-		return nil, errors.New("the response body is nil")
-	}
-
-	defer res.Body.Close()
-
-	if res.StatusCode != http.StatusOK {
-		errResp := &ErrorResponse{}
-		err = xml.NewDecoder(res.Body).Decode(errResp)
-		if err != nil {
-			return nil, fmt.Errorf("an error occurred while unmarshaling the error body to XML: %w", err)
-		}
-
-		return nil, fmt.Errorf("an error occurred: %s", errResp.Error.Message)
-	}
-
 	output := &ChangeResourceRecordSetsResponse{}
-	err = xml.NewDecoder(res.Body).Decode(output)
-	if err != nil {
-		return nil, fmt.Errorf("an error occurred while unmarshaling the response body to XML: %w", err)
-	}
-
-	return output, err
-}
-
-// GetChange Call GetChange API and return response.
-func (c *Client) GetChange(statusID string) (*GetChangeResponse, error) {
-	requestURL := fmt.Sprintf("%s/%s/change/%s", c.BaseURL, apiVersion, statusID)
-
-	req, err := http.NewRequest(http.MethodGet, requestURL, nil)
+	err = c.do(req, output)
 	if err != nil {
 		return nil, err
 	}
 
-	err = c.sign(req)
-	if err != nil {
-		return nil, fmt.Errorf("an error occurred during the creation of the signature: %w", err)
-	}
-
-	res, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	if res.Body == nil {
-		return nil, errors.New("the response body is nil")
-	}
-
-	defer res.Body.Close()
-
-	if res.StatusCode != http.StatusOK {
-		errResp := &ErrorResponse{}
-		err = xml.NewDecoder(res.Body).Decode(errResp)
-		if err != nil {
-			return nil, fmt.Errorf("an error occurred while unmarshaling the error body to XML: %w", err)
-		}
-
-		return nil, fmt.Errorf("an error occurred: %s", errResp.Error.Message)
-	}
-
-	output := &GetChangeResponse{}
-	err = xml.NewDecoder(res.Body).Decode(output)
-	if err != nil {
-		return nil, fmt.Errorf("an error occurred while unmarshaling the response body to XML: %w", err)
-	}
-
 	return output, nil
 }
 
+// GetChange Call GetChange API and return response.
+func (c *Client) GetChange(ctx context.Context, statusID string) (*GetChangeResponse, error) {
+	endpoint := c.BaseURL.JoinPath(apiVersion, "change", statusID)
+
+	req, err := newXMLRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	output := &GetChangeResponse{}
+	err = c.do(req, output)
+	if err != nil {
+		return nil, err
+	}
+
+	return output, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	err := c.sign(req)
+	if err != nil {
+		return fmt.Errorf("an error occurred during the creation of the signature: %w", err)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = xml.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
 func (c *Client) sign(req *http.Request) error {
 	if req.Header.Get("Date") == "" {
 		location, err := time.LoadLocation("GMT")
@@ -232,3 +147,39 @@ func (c *Client) sign(req *http.Request) error {
 
 	return nil
 }
+
+func newXMLRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		body := new(bytes.Buffer)
+		body.WriteString(xml.Header)
+		err := xml.NewEncoder(body).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request XML body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "text/xml; charset=utf-8")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errResp := &ErrorResponse{}
+	err := xml.Unmarshal(raw, errResp)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return errResp.Error
+}
diff --git a/providers/dns/nifcloud/internal/client_test.go b/providers/dns/nifcloud/internal/client_test.go
index cc0ae5b0..38a220b6 100644
--- a/providers/dns/nifcloud/internal/client_test.go
+++ b/providers/dns/nifcloud/internal/client_test.go
@@ -1,9 +1,11 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -25,7 +27,7 @@ func setupTest(t *testing.T, responseBody string, statusCode int) *Client {
 	require.NoError(t, err)
 
 	client.HTTPClient = server.Client()
-	client.BaseURL = server.URL
+	client.BaseURL, _ = url.Parse(server.URL)
 
 	return client
 }
@@ -43,7 +45,7 @@ func TestChangeResourceRecordSets(t *testing.T) {
 
 	client := setupTest(t, responseBody, http.StatusOK)
 
-	res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{})
+	res, err := client.ChangeResourceRecordSets(context.Background(), "example.com", ChangeResourceRecordSetsRequest{})
 	require.NoError(t, err)
 
 	assert.Equal(t, "xxxxx", res.ChangeInfo.ID)
@@ -70,19 +72,19 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) {
 </ErrorResponse>
 `,
 			statusCode: http.StatusUnauthorized,
-			expected:   "an error occurred: The request signature we calculated does not match the signature you provided.",
+			expected:   "Sender(AuthFailed): The request signature we calculated does not match the signature you provided.",
 		},
 		{
 			desc:         "response body error",
 			responseBody: "foo",
 			statusCode:   http.StatusOK,
-			expected:     "an error occurred while unmarshaling the response body to XML: EOF",
+			expected:     "unable to unmarshal response: [status code: 200] body: foo error: EOF",
 		},
 		{
 			desc:         "error message error",
 			responseBody: "foo",
 			statusCode:   http.StatusInternalServerError,
-			expected:     "an error occurred while unmarshaling the error body to XML: EOF",
+			expected:     "unexpected status code: [status code: 500] body: foo",
 		},
 	}
 
@@ -91,7 +93,7 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) {
 		t.Run(test.desc, func(t *testing.T) {
 			client := setupTest(t, test.responseBody, test.statusCode)
 
-			res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{})
+			res, err := client.ChangeResourceRecordSets(context.Background(), "example.com", ChangeResourceRecordSetsRequest{})
 			assert.Nil(t, res)
 			assert.EqualError(t, err, test.expected)
 		})
@@ -111,7 +113,7 @@ func TestGetChange(t *testing.T) {
 
 	client := setupTest(t, responseBody, http.StatusOK)
 
-	res, err := client.GetChange("12345")
+	res, err := client.GetChange(context.Background(), "12345")
 	require.NoError(t, err)
 
 	assert.Equal(t, "xxxxx", res.ChangeInfo.ID)
@@ -138,19 +140,19 @@ func TestGetChangeErrors(t *testing.T) {
 </ErrorResponse>
 `,
 			statusCode: http.StatusUnauthorized,
-			expected:   "an error occurred: The request signature we calculated does not match the signature you provided.",
+			expected:   "Sender(AuthFailed): The request signature we calculated does not match the signature you provided.",
 		},
 		{
 			desc:         "response body error",
 			responseBody: "foo",
 			statusCode:   http.StatusOK,
-			expected:     "an error occurred while unmarshaling the response body to XML: EOF",
+			expected:     "unable to unmarshal response: [status code: 200] body: foo error: EOF",
 		},
 		{
 			desc:         "error message error",
 			responseBody: "foo",
 			statusCode:   http.StatusInternalServerError,
-			expected:     "an error occurred while unmarshaling the error body to XML: EOF",
+			expected:     "unexpected status code: [status code: 500] body: foo",
 		},
 	}
 
@@ -159,7 +161,7 @@ func TestGetChangeErrors(t *testing.T) {
 		t.Run(test.desc, func(t *testing.T) {
 			client := setupTest(t, test.responseBody, test.statusCode)
 
-			res, err := client.GetChange("12345")
+			res, err := client.GetChange(context.Background(), "12345")
 			assert.Nil(t, res)
 			assert.EqualError(t, err, test.expected)
 		})
diff --git a/providers/dns/nifcloud/internal/types.go b/providers/dns/nifcloud/internal/types.go
new file mode 100644
index 00000000..2df9f1e5
--- /dev/null
+++ b/providers/dns/nifcloud/internal/types.go
@@ -0,0 +1,77 @@
+package internal
+
+import "fmt"
+
+// ChangeResourceRecordSetsRequest is a complex type that contains change information for the resource record set.
+type ChangeResourceRecordSetsRequest struct {
+	XMLNs       string      `xml:"xmlns,attr"`
+	ChangeBatch ChangeBatch `xml:"ChangeBatch"`
+}
+
+// ChangeResourceRecordSetsResponse is a complex type containing the response for the request.
+type ChangeResourceRecordSetsResponse struct {
+	ChangeInfo ChangeInfo `xml:"ChangeInfo"`
+}
+
+// GetChangeResponse is a complex type that contains the ChangeInfo element.
+type GetChangeResponse struct {
+	ChangeInfo ChangeInfo `xml:"ChangeInfo"`
+}
+
+type Error struct {
+	Type    string `xml:"Type"`
+	Message string `xml:"Message"`
+	Code    string `xml:"Code"`
+}
+
+func (e Error) Error() string {
+	return fmt.Sprintf("%s(%s): %s", e.Type, e.Code, e.Message)
+}
+
+// ErrorResponse is the information for any errors.
+type ErrorResponse struct {
+	Error     Error  `xml:"Error"`
+	RequestID string `xml:"RequestId"`
+}
+
+// ChangeBatch is the information for a change request.
+type ChangeBatch struct {
+	Changes Changes `xml:"Changes"`
+	Comment string  `xml:"Comment"`
+}
+
+// Changes is array of Change.
+type Changes struct {
+	Change []Change `xml:"Change"`
+}
+
+// Change is the information for each resource record set that you want to change.
+type Change struct {
+	Action            string            `xml:"Action"`
+	ResourceRecordSet ResourceRecordSet `xml:"ResourceRecordSet"`
+}
+
+// ResourceRecordSet is the information about the resource record set to create or delete.
+type ResourceRecordSet struct {
+	Name            string          `xml:"Name"`
+	Type            string          `xml:"Type"`
+	TTL             int             `xml:"TTL"`
+	ResourceRecords ResourceRecords `xml:"ResourceRecords"`
+}
+
+// ResourceRecords is array of ResourceRecord.
+type ResourceRecords struct {
+	ResourceRecord []ResourceRecord `xml:"ResourceRecord"`
+}
+
+// ResourceRecord is the information specific to the resource record.
+type ResourceRecord struct {
+	Value string `xml:"Value"`
+}
+
+// ChangeInfo is A complex type that describes change information about changes made to your hosted zone.
+type ChangeInfo struct {
+	ID          string `xml:"Id"`
+	Status      string `xml:"Status"`
+	SubmittedAt string `xml:"SubmittedAt"`
+}
diff --git a/providers/dns/nifcloud/nifcloud.go b/providers/dns/nifcloud/nifcloud.go
index 8e0ff334..5078175a 100644
--- a/providers/dns/nifcloud/nifcloud.go
+++ b/providers/dns/nifcloud/nifcloud.go
@@ -2,9 +2,11 @@
 package nifcloud
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
@@ -88,8 +90,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		client.HTTPClient = config.HTTPClient
 	}
 
-	if len(config.BaseURL) > 0 {
-		client.BaseURL = config.BaseURL
+	if config.BaseURL != "" {
+		baseURL, err := url.Parse(config.BaseURL)
+		if err != nil {
+			return nil, fmt.Errorf("nifcloud: %w", err)
+		}
+
+		client.BaseURL = baseURL
 	}
 
 	return &DNSProvider{client: client, config: config}, nil
@@ -154,10 +161,12 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
 
 	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return fmt.Errorf("failed to find zone: %w", err)
+		return fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
-	resp, err := d.client.ChangeResourceRecordSets(dns01.UnFqdn(authZone), reqParams)
+	ctx := context.Background()
+
+	resp, err := d.client.ChangeResourceRecordSets(ctx, dns01.UnFqdn(authZone), reqParams)
 	if err != nil {
 		return fmt.Errorf("failed to change record set: %w", err)
 	}
@@ -165,7 +174,7 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
 	statusID := resp.ChangeInfo.ID
 
 	return wait.For("nifcloud", 120*time.Second, 4*time.Second, func() (bool, error) {
-		resp, err := d.client.GetChange(statusID)
+		resp, err := d.client.GetChange(ctx, statusID)
 		if err != nil {
 			return false, fmt.Errorf("failed to query change status: %w", err)
 		}
diff --git a/providers/dns/njalla/internal/client.go b/providers/dns/njalla/internal/client.go
index 8b1a1a8b..f7e0023a 100644
--- a/providers/dns/njalla/internal/client.go
+++ b/providers/dns/njalla/internal/client.go
@@ -2,53 +2,60 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const apiEndpoint = "https://njal.la/api/1/"
 
+const authorizationHeader = "Authorization"
+
 // Client is a Njalla API client.
 type Client struct {
-	HTTPClient  *http.Client
+	token string
+
 	apiEndpoint string
-	token       string
+	HTTPClient  *http.Client
 }
 
 // NewClient creates a new Client.
 func NewClient(token string) *Client {
 	return &Client{
-		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
-		apiEndpoint: apiEndpoint,
 		token:       token,
+		apiEndpoint: apiEndpoint,
+		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // AddRecord adds a record.
-func (c *Client) AddRecord(record Record) (*Record, error) {
+func (c *Client) AddRecord(ctx context.Context, record Record) (*Record, error) {
 	data := APIRequest{
 		Method: "add-record",
 		Params: record,
 	}
 
-	result, err := c.do(data)
+	req, err := newJSONRequest(ctx, http.MethodPost, c.apiEndpoint, data)
 	if err != nil {
 		return nil, err
 	}
 
-	var rcd Record
-	err = json.Unmarshal(result, &rcd)
+	var result APIResponse[*Record]
+	err = c.do(req, &result)
 	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal response result: %w", err)
+		return nil, err
 	}
 
-	return &rcd, nil
+	return result.Result, nil
 }
 
 // RemoveRecord removes a record.
-func (c *Client) RemoveRecord(id string, domain string) error {
+func (c *Client) RemoveRecord(ctx context.Context, id string, domain string) error {
 	data := APIRequest{
 		Method: "remove-record",
 		Params: Record{
@@ -57,7 +64,12 @@ func (c *Client) RemoveRecord(id string, domain string) error {
 		},
 	}
 
-	_, err := c.do(data)
+	req, err := newJSONRequest(ctx, http.MethodPost, c.apiEndpoint, data)
+	if err != nil {
+		return err
+	}
+
+	err = c.do(req, &APIResponse[json.RawMessage]{})
 	if err != nil {
 		return err
 	}
@@ -66,7 +78,7 @@ func (c *Client) RemoveRecord(id string, domain string) error {
 }
 
 // ListRecords list the records for one domain.
-func (c *Client) ListRecords(domain string) ([]Record, error) {
+func (c *Client) ListRecords(ctx context.Context, domain string) ([]Record, error) {
 	data := APIRequest{
 		Method: "list-records",
 		Params: Record{
@@ -74,64 +86,67 @@ func (c *Client) ListRecords(domain string) ([]Record, error) {
 		},
 	}
 
-	result, err := c.do(data)
+	req, err := newJSONRequest(ctx, http.MethodPost, c.apiEndpoint, data)
 	if err != nil {
 		return nil, err
 	}
 
-	var rcds Records
-	err = json.Unmarshal(result, &rcds)
+	var result APIResponse[Records]
+	err = c.do(req, &result)
 	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal response result: %w", err)
+		return nil, err
 	}
 
-	return rcds.Records, nil
+	return result.Result.Records, nil
 }
 
-func (c *Client) do(data APIRequest) (json.RawMessage, error) {
-	req, err := c.createRequest(data)
-	if err != nil {
-		return nil, err
-	}
+func (c *Client) do(req *http.Request, result Response) error {
+	req.Header.Set(authorizationHeader, "Njalla "+c.token)
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("failed to perform request: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("unexpected error: %d", resp.StatusCode)
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	apiResponse := APIResponse{}
-	err = json.NewDecoder(resp.Body).Decode(&apiResponse)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	if apiResponse.Error != nil {
-		return nil, apiResponse.Error
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
-	return apiResponse.Result, nil
+	return result.GetError()
 }
 
-func (c *Client) createRequest(data APIRequest) (*http.Request, error) {
-	reqBody, err := json.Marshal(data)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshall request body: %w", err)
+func newJSONRequest(ctx context.Context, method string, endpoint string, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
 	}
 
-	req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(reqBody))
+	req, err := http.NewRequestWithContext(ctx, method, endpoint, buf)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create request: %w", err)
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
 	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", "Njalla "+c.token)
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
 
 	return req, nil
 }
diff --git a/providers/dns/njalla/internal/client_test.go b/providers/dns/njalla/internal/client_test.go
index 934cbe76..3f173db6 100644
--- a/providers/dns/njalla/internal/client_test.go
+++ b/providers/dns/njalla/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -11,7 +12,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setup(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Client {
+func setupTest(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Client {
 	t.Helper()
 
 	mux := http.NewServeMux()
@@ -24,7 +25,7 @@ func setup(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Clie
 			return
 		}
 
-		token := req.Header.Get("Authorization")
+		token := req.Header.Get(authorizationHeader)
 		if token != "Njalla secret" {
 			_, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 403, "message": "Invalid token."}}`))
 			return
@@ -44,7 +45,7 @@ func setup(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Clie
 }
 
 func TestClient_AddRecord(t *testing.T) {
-	client := setup(t, func(rw http.ResponseWriter, req *http.Request) {
+	client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
 		apiReq := struct {
 			Method string `json:"method"`
 			Params Record `json:"params"`
@@ -79,7 +80,7 @@ func TestClient_AddRecord(t *testing.T) {
 		Type:    "TXT",
 	}
 
-	result, err := client.AddRecord(record)
+	result, err := client.AddRecord(context.Background(), record)
 	require.NoError(t, err)
 
 	expected := &Record{
@@ -94,7 +95,7 @@ func TestClient_AddRecord(t *testing.T) {
 }
 
 func TestClient_AddRecord_error(t *testing.T) {
-	client := setup(t, nil)
+	client := setupTest(t, nil)
 	client.token = "invalid"
 
 	record := Record{
@@ -105,14 +106,14 @@ func TestClient_AddRecord_error(t *testing.T) {
 		Type:    "TXT",
 	}
 
-	result, err := client.AddRecord(record)
+	result, err := client.AddRecord(context.Background(), record)
 	require.Error(t, err)
 
 	assert.Nil(t, result)
 }
 
 func TestClient_ListRecords(t *testing.T) {
-	client := setup(t, func(rw http.ResponseWriter, req *http.Request) {
+	client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
 		apiReq := struct {
 			Method string `json:"method"`
 			Params Record `json:"params"`
@@ -156,7 +157,7 @@ func TestClient_ListRecords(t *testing.T) {
 		}
 	})
 
-	records, err := client.ListRecords("example.com")
+	records, err := client.ListRecords(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -182,17 +183,17 @@ func TestClient_ListRecords(t *testing.T) {
 }
 
 func TestClient_ListRecords_error(t *testing.T) {
-	client := setup(t, nil)
+	client := setupTest(t, nil)
 	client.token = "invalid"
 
-	records, err := client.ListRecords("example.com")
+	records, err := client.ListRecords(context.Background(), "example.com")
 	require.Error(t, err)
 
 	assert.Empty(t, records)
 }
 
 func TestClient_RemoveRecord(t *testing.T) {
-	client := setup(t, func(rw http.ResponseWriter, req *http.Request) {
+	client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) {
 		apiReq := struct {
 			Method string `json:"method"`
 			Params Record `json:"params"`
@@ -217,14 +218,14 @@ func TestClient_RemoveRecord(t *testing.T) {
 		_, _ = rw.Write([]byte(`{"jsonrpc":"2.0"}`))
 	})
 
-	err := client.RemoveRecord("123", "example.com")
+	err := client.RemoveRecord(context.Background(), "123", "example.com")
 	require.NoError(t, err)
 }
 
 func TestClient_RemoveRecord_error(t *testing.T) {
-	client := setup(t, nil)
+	client := setupTest(t, nil)
 	client.token = "invalid"
 
-	err := client.RemoveRecord("123", "example.com")
+	err := client.RemoveRecord(context.Background(), "123", "example.com")
 	require.Error(t, err)
 }
diff --git a/providers/dns/njalla/internal/types.go b/providers/dns/njalla/internal/types.go
index 74efe8d4..d6b8167d 100644
--- a/providers/dns/njalla/internal/types.go
+++ b/providers/dns/njalla/internal/types.go
@@ -1,22 +1,33 @@
 package internal
 
 import (
-	"encoding/json"
 	"fmt"
 )
 
 // APIRequest represents an API request body.
 type APIRequest struct {
-	Method string      `json:"method"`
-	Params interface{} `json:"params"`
+	Method string `json:"method"`
+	Params any    `json:"params"`
+}
+
+type Response interface {
+	GetError() error
 }
 
 // APIResponse represents an API response body.
-type APIResponse struct {
-	ID     string          `json:"id"`
-	RPC    string          `json:"jsonrpc"`
-	Error  *APIError       `json:"error,omitempty"`
-	Result json.RawMessage `json:"result,omitempty"`
+type APIResponse[T any] struct {
+	ID     string    `json:"id"`
+	RPC    string    `json:"jsonrpc"`
+	Error  *APIError `json:"error,omitempty"`
+	Result T         `json:"result,omitempty"`
+}
+
+func (a APIResponse[T]) GetError() error {
+	if a.Error == (*APIError)(nil) {
+		return nil
+	}
+
+	return a.Error
 }
 
 // APIError is an API error.
diff --git a/providers/dns/njalla/njalla.go b/providers/dns/njalla/njalla.go
index c090fc5d..fe23e8d6 100644
--- a/providers/dns/njalla/njalla.go
+++ b/providers/dns/njalla/njalla.go
@@ -2,6 +2,7 @@
 package njalla
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -116,7 +117,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Type:    "TXT",
 	}
 
-	resp, err := d.client.AddRecord(record)
+	resp, err := d.client.AddRecord(context.Background(), record)
 	if err != nil {
 		return fmt.Errorf("njalla: failed to add record: %w", err)
 	}
@@ -145,7 +146,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("njalla: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	err = d.client.RemoveRecord(recordID, dns01.UnFqdn(rootDomain))
+	err = d.client.RemoveRecord(context.Background(), recordID, dns01.UnFqdn(rootDomain))
 	if err != nil {
 		return fmt.Errorf("njalla: failed to delete TXT records: fqdn=%s, recordID=%s: %w", info.EffectiveFQDN, recordID, err)
 	}
diff --git a/providers/dns/nodion/nodion.go b/providers/dns/nodion/nodion.go
index 8da79ea2..6b2a0be2 100644
--- a/providers/dns/nodion/nodion.go
+++ b/providers/dns/nodion/nodion.go
@@ -109,7 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("nodion: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("nodion: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -160,7 +160,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("nodion: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("nodion: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	d.zoneIDsMu.Lock()
diff --git a/providers/dns/ns1/ns1.go b/providers/dns/ns1/ns1.go
index 811e9d85..906ec8d6 100644
--- a/providers/dns/ns1/ns1.go
+++ b/providers/dns/ns1/ns1.go
@@ -150,11 +150,13 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 }
 
 func (d *DNSProvider) getHostedZone(fqdn string) (*dns.Zone, error) {
-	authZone, err := getAuthZone(fqdn)
+	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return nil, fmt.Errorf("failed to extract auth zone from fqdn %q: %w", fqdn, err)
+		return nil, fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
+	authZone = dns01.UnFqdn(authZone)
+
 	zone, _, err := d.client.Zones.Get(authZone)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get zone [authZone: %q, fqdn: %q]: %w", authZone, fqdn, err)
@@ -162,12 +164,3 @@ func (d *DNSProvider) getHostedZone(fqdn string) (*dns.Zone, error) {
 
 	return zone, nil
 }
-
-func getAuthZone(fqdn string) (string, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return "", err
-	}
-
-	return dns01.UnFqdn(authZone), nil
-}
diff --git a/providers/dns/ns1/ns1_test.go b/providers/dns/ns1/ns1_test.go
index ea4eaa64..6df6b4af 100644
--- a/providers/dns/ns1/ns1_test.go
+++ b/providers/dns/ns1/ns1_test.go
@@ -5,7 +5,6 @@ import (
 	"time"
 
 	"github.com/go-acme/lego/v4/platform/tester"
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -91,57 +90,6 @@ func TestNewDNSProviderConfig(t *testing.T) {
 	}
 }
 
-func Test_getAuthZone(t *testing.T) {
-	type expected struct {
-		AuthZone string
-		Error    string
-	}
-
-	testCases := []struct {
-		desc     string
-		fqdn     string
-		expected expected
-	}{
-		{
-			desc: "valid fqdn",
-			fqdn: "_acme-challenge.myhost.sub.example.com.",
-			expected: expected{
-				AuthZone: "example.com",
-			},
-		},
-		{
-			desc: "invalid fqdn",
-			fqdn: "_acme-challenge.myhost.sub.example.com",
-			expected: expected{
-				Error: "could not find the start of authority for _acme-challenge.myhost.sub.example.com: dns: domain must be fully qualified",
-			},
-		},
-		{
-			desc: "invalid authority",
-			fqdn: "_acme-challenge.myhost.sub.domain.tld.",
-			expected: expected{
-				Error: "could not find the start of authority for _acme-challenge.myhost.sub.domain.tld.: NXDOMAIN",
-			},
-		},
-	}
-
-	for _, test := range testCases {
-		test := test
-		t.Run(test.desc, func(t *testing.T) {
-			t.Parallel()
-
-			authZone, err := getAuthZone(test.fqdn)
-
-			if len(test.expected.Error) > 0 {
-				assert.EqualError(t, err, test.expected.Error)
-			} else {
-				require.NoError(t, err)
-				assert.Equal(t, test.expected.AuthZone, authZone)
-			}
-		})
-	}
-}
-
 func TestLivePresent(t *testing.T) {
 	if !envTest.IsLiveTest() {
 		t.Skip("skipping live test")
diff --git a/providers/dns/oraclecloud/oraclecloud.go b/providers/dns/oraclecloud/oraclecloud.go
index f6739b46..de3a9eed 100644
--- a/providers/dns/oraclecloud/oraclecloud.go
+++ b/providers/dns/oraclecloud/oraclecloud.go
@@ -105,9 +105,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zoneNameOrID, err1 := dns01.FindZoneByFqdn(info.EffectiveFQDN)
-	if err1 != nil {
-		return fmt.Errorf("oraclecloud: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err1)
+	zoneNameOrID, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+	if err != nil {
+		return fmt.Errorf("oraclecloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// generate request to dns.PatchDomainRecordsRequest
@@ -128,7 +128,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		},
 	}
 
-	_, err := d.client.PatchDomainRecords(context.Background(), request)
+	_, err = d.client.PatchDomainRecords(context.Background(), request)
 	if err != nil {
 		return fmt.Errorf("oraclecloud: %w", err)
 	}
@@ -140,9 +140,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zoneNameOrID, err1 := dns01.FindZoneByFqdn(info.EffectiveFQDN)
-	if err1 != nil {
-		return fmt.Errorf("oraclecloud: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err1)
+	zoneNameOrID, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+	if err != nil {
+		return fmt.Errorf("oraclecloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// search to TXT record's hash to delete
diff --git a/providers/dns/otc/client.go b/providers/dns/otc/client.go
deleted file mode 100644
index 6ad4cdfd..00000000
--- a/providers/dns/otc/client.go
+++ /dev/null
@@ -1,266 +0,0 @@
-package otc
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-)
-
-type recordset struct {
-	Name        string   `json:"name"`
-	Description string   `json:"description"`
-	Type        string   `json:"type"`
-	TTL         int      `json:"ttl"`
-	Records     []string `json:"records"`
-}
-
-type nameResponse struct {
-	Name string `json:"name"`
-}
-
-type userResponse struct {
-	Name     string       `json:"name"`
-	Password string       `json:"password"`
-	Domain   nameResponse `json:"domain"`
-}
-
-type passwordResponse struct {
-	User userResponse `json:"user"`
-}
-
-type identityResponse struct {
-	Methods  []string         `json:"methods"`
-	Password passwordResponse `json:"password"`
-}
-
-type scopeResponse struct {
-	Project nameResponse `json:"project"`
-}
-
-type authResponse struct {
-	Identity identityResponse `json:"identity"`
-	Scope    scopeResponse    `json:"scope"`
-}
-
-type loginResponse struct {
-	Auth authResponse `json:"auth"`
-}
-
-type endpointResponse struct {
-	Token token `json:"token"`
-}
-
-type token struct {
-	Catalog []catalog `json:"catalog"`
-}
-
-type catalog struct {
-	Type      string     `json:"type"`
-	Endpoints []endpoint `json:"endpoints"`
-}
-
-type endpoint struct {
-	URL string `json:"url"`
-}
-
-type zoneItem struct {
-	ID   string `json:"id"`
-	Name string `json:"name"`
-}
-
-type zonesResponse struct {
-	Zones []zoneItem `json:"zones"`
-}
-
-type recordSet struct {
-	ID string `json:"id"`
-}
-
-type recordSetsResponse struct {
-	RecordSets []recordSet `json:"recordsets"`
-}
-
-// Starts a new OTC API Session. Authenticates using userName, password
-// and receives a token to be used in for subsequent requests.
-func (d *DNSProvider) login() error {
-	return d.loginRequest()
-}
-
-func (d *DNSProvider) loginRequest() error {
-	userResp := userResponse{
-		Name:     d.config.UserName,
-		Password: d.config.Password,
-		Domain: nameResponse{
-			Name: d.config.DomainName,
-		},
-	}
-
-	loginResp := loginResponse{
-		Auth: authResponse{
-			Identity: identityResponse{
-				Methods: []string{"password"},
-				Password: passwordResponse{
-					User: userResp,
-				},
-			},
-			Scope: scopeResponse{
-				Project: nameResponse{
-					Name: d.config.ProjectName,
-				},
-			},
-		},
-	}
-
-	body, err := json.Marshal(loginResp)
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest(http.MethodPost, d.config.IdentityEndpoint, bytes.NewReader(body))
-	if err != nil {
-		return err
-	}
-	req.Header.Set("Content-Type", "application/json")
-
-	client := &http.Client{Timeout: d.config.HTTPClient.Timeout}
-	resp, err := client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return fmt.Errorf("OTC API request failed with HTTP status code %d", resp.StatusCode)
-	}
-
-	d.token = resp.Header.Get("X-Subject-Token")
-
-	if d.token == "" {
-		return errors.New("unable to get auth token")
-	}
-
-	var endpointResp endpointResponse
-
-	err = json.NewDecoder(resp.Body).Decode(&endpointResp)
-	if err != nil {
-		return err
-	}
-
-	var endpoints []endpoint
-	for _, v := range endpointResp.Token.Catalog {
-		if v.Type == "dns" {
-			endpoints = append(endpoints, v.Endpoints...)
-		}
-	}
-
-	if len(endpoints) > 0 {
-		d.baseURL = fmt.Sprintf("%s/v2", endpoints[0].URL)
-	} else {
-		return errors.New("unable to get dns endpoint")
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) getZoneID(zone string) (string, error) {
-	resource := fmt.Sprintf("zones?name=%s", zone)
-	resp, err := d.sendRequest(http.MethodGet, resource, nil)
-	if err != nil {
-		return "", err
-	}
-
-	var zonesRes zonesResponse
-	err = json.NewDecoder(resp).Decode(&zonesRes)
-	if err != nil {
-		return "", err
-	}
-
-	if len(zonesRes.Zones) < 1 {
-		return "", fmt.Errorf("zone %s not found", zone)
-	}
-
-	for _, z := range zonesRes.Zones {
-		if z.Name == zone {
-			return z.ID, nil
-		}
-	}
-
-	return "", fmt.Errorf("zone %s not found", zone)
-}
-
-func (d *DNSProvider) getRecordSetID(zoneID, fqdn string) (string, error) {
-	resource := fmt.Sprintf("zones/%s/recordsets?type=TXT&name=%s", zoneID, fqdn)
-	resp, err := d.sendRequest(http.MethodGet, resource, nil)
-	if err != nil {
-		return "", err
-	}
-
-	var recordSetsRes recordSetsResponse
-	err = json.NewDecoder(resp).Decode(&recordSetsRes)
-	if err != nil {
-		return "", err
-	}
-
-	if len(recordSetsRes.RecordSets) < 1 {
-		return "", errors.New("record not found")
-	}
-
-	if len(recordSetsRes.RecordSets) > 1 {
-		return "", errors.New("to many records found")
-	}
-
-	if recordSetsRes.RecordSets[0].ID == "" {
-		return "", errors.New("id not found")
-	}
-
-	return recordSetsRes.RecordSets[0].ID, nil
-}
-
-func (d *DNSProvider) deleteRecordSet(zoneID, recordID string) error {
-	resource := fmt.Sprintf("zones/%s/recordsets/%s", zoneID, recordID)
-
-	_, err := d.sendRequest(http.MethodDelete, resource, nil)
-	return err
-}
-
-func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (io.Reader, error) {
-	url := fmt.Sprintf("%s/%s", d.baseURL, resource)
-
-	var body io.Reader
-	if payload != nil {
-		content, err := json.Marshal(payload)
-		if err != nil {
-			return nil, err
-		}
-		body = bytes.NewReader(content)
-	}
-
-	req, err := http.NewRequest(method, url, body)
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Set("Content-Type", "application/json")
-	if len(d.token) > 0 {
-		req.Header.Set("X-Auth-Token", d.token)
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return nil, fmt.Errorf("OTC API request %s failed with HTTP status code %d", url, resp.StatusCode)
-	}
-
-	body1, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	return bytes.NewReader(body1), nil
-}
diff --git a/providers/dns/otc/internal/client.go b/providers/dns/otc/internal/client.go
new file mode 100644
index 00000000..59a68514
--- /dev/null
+++ b/providers/dns/otc/internal/client.go
@@ -0,0 +1,221 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"sync"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+type Client struct {
+	username    string
+	password    string
+	domainName  string
+	projectName string
+
+	IdentityEndpoint string
+	token            string
+	muToken          sync.Mutex
+
+	baseURL   *url.URL
+	muBaseURL sync.Mutex
+
+	HTTPClient *http.Client
+}
+
+func NewClient(username string, password string, domainName string, projectName string) *Client {
+	return &Client{
+		username:         username,
+		password:         password,
+		domainName:       domainName,
+		projectName:      projectName,
+		IdentityEndpoint: DefaultIdentityEndpoint,
+		HTTPClient:       &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c *Client) GetZoneID(ctx context.Context, zone string) (string, error) {
+	zonesResp, err := c.getZones(ctx, zone)
+	if err != nil {
+		return "", err
+	}
+
+	if len(zonesResp.Zones) < 1 {
+		return "", fmt.Errorf("zone %s not found", zone)
+	}
+
+	for _, z := range zonesResp.Zones {
+		if z.Name == zone {
+			return z.ID, nil
+		}
+	}
+
+	return "", fmt.Errorf("zone %s not found", zone)
+}
+
+// https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/public_zone_management/querying_public_zones.html
+func (c *Client) getZones(ctx context.Context, zone string) (*ZonesResponse, error) {
+	c.muBaseURL.Lock()
+	endpoint := c.baseURL.JoinPath("zones")
+	c.muBaseURL.Unlock()
+
+	query := endpoint.Query()
+	query.Set("name", zone)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var zones ZonesResponse
+	err = c.do(req, &zones)
+	if err != nil {
+		return nil, err
+	}
+
+	return &zones, nil
+}
+
+func (c *Client) GetRecordSetID(ctx context.Context, zoneID, fqdn string) (string, error) {
+	recordSetsRes, err := c.getRecordSet(ctx, zoneID, fqdn)
+	if err != nil {
+		return "", err
+	}
+
+	if len(recordSetsRes.RecordSets) < 1 {
+		return "", errors.New("record not found")
+	}
+
+	if len(recordSetsRes.RecordSets) > 1 {
+		return "", errors.New("to many records found")
+	}
+
+	if recordSetsRes.RecordSets[0].ID == "" {
+		return "", errors.New("id not found")
+	}
+
+	return recordSetsRes.RecordSets[0].ID, nil
+}
+
+// https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/record_set_management/querying_all_record_sets.html
+func (c *Client) getRecordSet(ctx context.Context, zoneID, fqdn string) (*RecordSetsResponse, error) {
+	c.muBaseURL.Lock()
+	endpoint := c.baseURL.JoinPath("zones", zoneID, "recordsets")
+	c.muBaseURL.Unlock()
+
+	query := endpoint.Query()
+	query.Set("type", "TXT")
+	query.Set("name", fqdn)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var recordSetsRes RecordSetsResponse
+	err = c.do(req, &recordSetsRes)
+	if err != nil {
+		return nil, err
+	}
+
+	return &recordSetsRes, nil
+}
+
+// CreateRecordSet creates a record.
+// https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/record_set_management/creating_a_record_set.html
+func (c *Client) CreateRecordSet(ctx context.Context, zoneID string, record RecordSets) error {
+	c.muBaseURL.Lock()
+	endpoint := c.baseURL.JoinPath("zones", zoneID, "recordsets")
+	c.muBaseURL.Unlock()
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+// DeleteRecordSet delete a record set.
+// https://docs.otc.t-systems.com/domain-name-service/api-ref/apis/record_set_management/deleting_a_record_set.html
+func (c *Client) DeleteRecordSet(ctx context.Context, zoneID, recordID string) error {
+	c.muBaseURL.Lock()
+	endpoint := c.baseURL.JoinPath("zones", zoneID, "recordsets", recordID)
+	c.muBaseURL.Unlock()
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	c.muToken.Lock()
+	if c.token != "" {
+		req.Header.Set("X-Auth-Token", c.token)
+	}
+	c.muToken.Unlock()
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode >= http.StatusBadRequest {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload interface{}) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s", endpoint), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/otc/internal/fixtures/zones-recordsets_DELETE.json b/providers/dns/otc/internal/fixtures/zones-recordsets_DELETE.json
new file mode 100644
index 00000000..3090cc6a
--- /dev/null
+++ b/providers/dns/otc/internal/fixtures/zones-recordsets_DELETE.json
@@ -0,0 +1,17 @@
+{
+  "id": "2c9eb155587228570158722b6ac30007",
+  "name": "www.example.com.",
+  "description": "This is an example record set.",
+  "type": "A",
+  "ttl": 300,
+  "status": "PENDING_DELETE",
+  "links": {
+    "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149/recordsets/2c9eb155587228570158722b6ac30007"
+  },
+  "zone_id": "2c9eb155587194ec01587224c9f90149",
+  "zone_name": "example.com.",
+  "create_at": "2016-11-17T12:03:17.827",
+  "update_at": "2016-11-17T12:56:03.827",
+  "default": false,
+  "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c"
+}
diff --git a/providers/dns/otc/internal/fixtures/zones-recordsets_GET.json b/providers/dns/otc/internal/fixtures/zones-recordsets_GET.json
new file mode 100644
index 00000000..bfec4cfe
--- /dev/null
+++ b/providers/dns/otc/internal/fixtures/zones-recordsets_GET.json
@@ -0,0 +1,30 @@
+{
+  "links": {
+    "self": "https://Endpoint/v2/recordsets",
+    "next": "https://Endpoint/v2/recordsets?id=&limit=11&marker=2c9eb155587194ec01587224c9f9014a"
+  },
+  "recordsets": [
+    {
+      "id": "321321",
+      "name": "_acme-challenge.example.com",
+      "type": "TXT",
+      "ttl": 300,
+      "records": [
+        "ns1.hotrot.de. xx.example.com. (1 7200 900 1209600 300)"
+      ],
+      "status": "ACTIVE",
+      "links": {
+        "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149/recordsets/2c9eb155587194ec01587224c9f9014a"
+      },
+      "zone_id": "2c9eb155587194ec01587224c9f90149",
+      "zone_name": "example.com.",
+      "create_at": "2016-11-17T11:56:03.439",
+      "update_at": "2016-11-17T11:56:03.827",
+      "default": true,
+      "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c"
+    }
+  ],
+  "metadata": {
+    "total_count": 1
+  }
+}
diff --git a/providers/dns/otc/internal/fixtures/zones-recordsets_GET_empty.json b/providers/dns/otc/internal/fixtures/zones-recordsets_GET_empty.json
new file mode 100644
index 00000000..7899f988
--- /dev/null
+++ b/providers/dns/otc/internal/fixtures/zones-recordsets_GET_empty.json
@@ -0,0 +1,3 @@
+{
+  "recordsets": []
+}
diff --git a/providers/dns/otc/internal/fixtures/zones-recordsets_POST.json b/providers/dns/otc/internal/fixtures/zones-recordsets_POST.json
new file mode 100644
index 00000000..f70c1744
--- /dev/null
+++ b/providers/dns/otc/internal/fixtures/zones-recordsets_POST.json
@@ -0,0 +1,21 @@
+{
+  "id": "2c9eb155587228570158722b6ac30007",
+  "name": "www.example.com.",
+  "description": "This is an example record set.",
+  "type": "A",
+  "ttl": 300,
+  "records": [
+    "192.168.10.1",
+    "192.168.10.2"
+  ],
+  "status": "PENDING_CREATE",
+  "links": {
+    "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149/recordsets/2c9eb155587228570158722b6ac30007"
+  },
+  "zone_id": "2c9eb155587194ec01587224c9f90149",
+  "zone_name": "example.com.",
+  "create_at": "2016-11-17T12:03:17.827",
+  "update_at": null,
+  "default": false,
+  "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c"
+}
diff --git a/providers/dns/otc/internal/fixtures/zones_GET.json b/providers/dns/otc/internal/fixtures/zones_GET.json
new file mode 100644
index 00000000..fcc327b7
--- /dev/null
+++ b/providers/dns/otc/internal/fixtures/zones_GET.json
@@ -0,0 +1,49 @@
+{
+  "links": {
+    "self": "https://Endpoint/v2/zones?type=public&limit=11",
+    "next": "https://Endpoint/v2/zones?type=public&limit=11&marker=2c9eb155587194ec01587224c9f90149"
+  },
+  "zones": [
+    {
+      "id": "123123",
+      "name": "example.com.",
+      "description": "This is an example zone.",
+      "email": "xx@example.com",
+      "ttl": 300,
+      "serial": 0,
+      "masters": [],
+      "status": "ACTIVE",
+      "links": {
+        "self": "https://Endpoint/v2/zones/2c9eb155587194ec01587224c9f90149"
+      },
+      "pool_id": "00000000570e54ee01570e9939b20019",
+      "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c",
+      "zone_type": "public",
+      "created_at": "2016-11-17T11:56:03.439",
+      "updated_at": "2016-11-17T11:56:05.528",
+      "record_num": 2
+    },
+    {
+      "id": "2c9eb155587228570158722996c50001",
+      "name": "example.org.",
+      "description": "This is an example zone.",
+      "email": "xx@example.org",
+      "ttl": 300,
+      "serial": 0,
+      "masters": [],
+      "status": "PENDING_CREATE",
+      "links": {
+        "self": "https://Endpoint/v2/zones/2c9eb155587228570158722996c50001"
+      },
+      "pool_id": "00000000570e54ee01570e9939b20019",
+      "project_id": "e55c6f3dc4e34c9f86353b664ae0e70c",
+      "zone_type": "public",
+      "created_at": "2016-11-17T12:01:17.996",
+      "updated_at": "2016-11-17T12:01:18.528",
+      "record_num": 2
+    }
+  ],
+  "metadata": {
+    "total_count": 2
+  }
+}
diff --git a/providers/dns/otc/internal/fixtures/zones_GET_empty.json b/providers/dns/otc/internal/fixtures/zones_GET_empty.json
new file mode 100644
index 00000000..ee59e4e0
--- /dev/null
+++ b/providers/dns/otc/internal/fixtures/zones_GET_empty.json
@@ -0,0 +1,3 @@
+{
+  "zones": []
+}
diff --git a/providers/dns/otc/internal/identity.go b/providers/dns/otc/internal/identity.go
new file mode 100644
index 00000000..f9e7cb08
--- /dev/null
+++ b/providers/dns/otc/internal/identity.go
@@ -0,0 +1,125 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"io"
+	"net/http"
+	"net/url"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// DefaultIdentityEndpoint the default API identity endpoint.
+const DefaultIdentityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens"
+
+// Login Starts a new OTC API Session. Authenticates using userName, password
+// and receives a token to be used in for subsequent requests.
+func (c *Client) Login(ctx context.Context) error {
+	payload := LoginRequest{
+		Auth: Auth{
+			Identity: Identity{
+				Methods: []string{"password"},
+				Password: Password{
+					User: User{
+						Name:     c.username,
+						Password: c.password,
+						Domain: Domain{
+							Name: c.domainName,
+						},
+					},
+				},
+			},
+			Scope: Scope{
+				Project: Project{
+					Name: c.projectName,
+				},
+			},
+		},
+	}
+
+	tokenResp, token, err := c.obtainUserToken(ctx, payload)
+	if err != nil {
+		return err
+	}
+
+	c.muToken.Lock()
+	defer c.muToken.Unlock()
+	c.token = token
+
+	if c.token == "" {
+		return errors.New("unable to get auth token")
+	}
+
+	baseURL, err := getBaseURL(tokenResp)
+	if err != nil {
+		return err
+	}
+
+	c.muBaseURL.Lock()
+	c.baseURL = baseURL
+	c.muBaseURL.Unlock()
+
+	return nil
+}
+
+// https://docs.otc.t-systems.com/identity-access-management/api-ref/apis/token_management/obtaining_a_user_token.html
+func (c *Client) obtainUserToken(ctx context.Context, payload LoginRequest) (*TokenResponse, string, error) {
+	req, err := newJSONRequest(ctx, http.MethodPost, c.IdentityEndpoint, payload)
+	if err != nil {
+		return nil, "", err
+	}
+
+	client := &http.Client{Timeout: c.HTTPClient.Timeout}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, "", err
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return nil, "", errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	token := resp.Header.Get("X-Subject-Token")
+
+	if token == "" {
+		return nil, "", errors.New("unable to get auth token")
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, "", errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var newToken TokenResponse
+	err = json.Unmarshal(raw, &newToken)
+	if err != nil {
+		return nil, "", errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return &newToken, token, nil
+}
+
+func getBaseURL(tokenResp *TokenResponse) (*url.URL, error) {
+	var endpoints []Endpoint
+	for _, v := range tokenResp.Token.Catalog {
+		if v.Type == "dns" {
+			endpoints = append(endpoints, v.Endpoints...)
+		}
+	}
+
+	if len(endpoints) == 0 {
+		return nil, errors.New("unable to get dns endpoint")
+	}
+
+	baseURL, err := url.JoinPath(endpoints[0].URL, "v2")
+	if err != nil {
+		return nil, err
+	}
+
+	return url.Parse(baseURL)
+}
diff --git a/providers/dns/otc/internal/identity_test.go b/providers/dns/otc/internal/identity_test.go
new file mode 100644
index 00000000..18627869
--- /dev/null
+++ b/providers/dns/otc/internal/identity_test.go
@@ -0,0 +1,25 @@
+package internal
+
+import (
+	"context"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestClient_Login(t *testing.T) {
+	mock := NewDNSServerMock(t)
+	mock.HandleAuthSuccessfully()
+
+	client := NewClient("user", "secret", "example.com", "test")
+	client.IdentityEndpoint, _ = url.JoinPath(mock.GetServerURL(), "/v3/auth/token")
+
+	err := client.Login(context.Background())
+	require.NoError(t, err)
+
+	serverURL, _ := url.Parse(mock.GetServerURL())
+	assert.Equal(t, serverURL.JoinPath("v2").String(), client.baseURL.String())
+	assert.Equal(t, fakeOTCToken, client.token)
+}
diff --git a/providers/dns/otc/mock_test.go b/providers/dns/otc/internal/mock.go
similarity index 53%
rename from providers/dns/otc/mock_test.go
rename to providers/dns/otc/internal/mock.go
index 706d94f9..33cb0728 100644
--- a/providers/dns/otc/mock_test.go
+++ b/providers/dns/otc/internal/mock.go
@@ -1,10 +1,12 @@
-package otc
+package internal
 
 import (
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -12,11 +14,22 @@ import (
 
 const fakeOTCToken = "62244bc21da68d03ebac94e6636ff01f"
 
+func writeFixture(rw http.ResponseWriter, filename string) {
+	file, err := os.Open(filepath.Join("internal", "fixtures", filename))
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer func() { _ = file.Close() }()
+
+	_, _ = io.Copy(rw, file)
+}
+
 // DNSServerMock mock.
 type DNSServerMock struct {
 	t      *testing.T
 	server *httptest.Server
-	Mux    *http.ServeMux
+	mux    *http.ServeMux
 }
 
 // NewDNSServerMock create a new DNSServerMock.
@@ -28,7 +41,7 @@ func NewDNSServerMock(t *testing.T) *DNSServerMock {
 	return &DNSServerMock{
 		t:      t,
 		server: httptest.NewServer(mux),
-		Mux:    mux,
+		mux:    mux,
 	}
 }
 
@@ -43,10 +56,10 @@ func (m *DNSServerMock) ShutdownServer() {
 
 // HandleAuthSuccessfully Handle auth successfully.
 func (m *DNSServerMock) HandleAuthSuccessfully() {
-	m.Mux.HandleFunc("/v3/auth/token", func(w http.ResponseWriter, _ *http.Request) {
+	m.mux.HandleFunc("/v3/auth/token", func(w http.ResponseWriter, _ *http.Request) {
 		w.Header().Set("X-Subject-Token", fakeOTCToken)
 
-		fmt.Fprintf(w, `{
+		_, _ = fmt.Fprintf(w, `{
 		  "token": {
 		    "catalog": [
 		      {
@@ -70,84 +83,66 @@ func (m *DNSServerMock) HandleAuthSuccessfully() {
 
 // HandleListZonesSuccessfully Handle list zones successfully.
 func (m *DNSServerMock) HandleListZonesSuccessfully() {
-	m.Mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) {
-		assert.Equal(m.t, r.Method, http.MethodGet)
-		assert.Equal(m.t, r.URL.Path, "/v2/zones")
-		assert.Equal(m.t, r.URL.RawQuery, "name=example.com.")
-		assert.Equal(m.t, r.Header.Get("Content-Type"), "application/json")
+	m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(m.t, http.MethodGet, r.Method)
+		assert.Equal(m.t, "/v2/zones", r.URL.Path)
+		assert.Equal(m.t, "name=example.com.", r.URL.RawQuery)
+		assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
 
-		fmt.Fprintf(w, `{
-		  "zones":[{
-		    "id":"123123",
-		    "name":"example.com."
-		  }]}
-		`)
+		writeFixture(w, "zones_GET.json")
 	})
 }
 
 // HandleListZonesEmpty Handle list zones empty.
 func (m *DNSServerMock) HandleListZonesEmpty() {
-	m.Mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) {
-		assert.Equal(m.t, r.Method, http.MethodGet)
-		assert.Equal(m.t, r.URL.Path, "/v2/zones")
-		assert.Equal(m.t, r.URL.RawQuery, "name=example.com.")
-		assert.Equal(m.t, r.Header.Get("Content-Type"), "application/json")
+	m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(m.t, http.MethodGet, r.Method)
+		assert.Equal(m.t, "/v2/zones", r.URL.Path)
+		assert.Equal(m.t, "name=example.com.", r.URL.RawQuery)
+		assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
 
-		fmt.Fprintf(w, `{
-		  "zones":[
-		  ]}
-		`)
+		writeFixture(w, "zones_GET_empty.json")
 	})
 }
 
 // HandleDeleteRecordsetsSuccessfully Handle delete recordsets successfully.
 func (m *DNSServerMock) HandleDeleteRecordsetsSuccessfully() {
-	m.Mux.HandleFunc("/v2/zones/123123/recordsets/321321", func(w http.ResponseWriter, r *http.Request) {
-		assert.Equal(m.t, r.Method, http.MethodDelete)
-		assert.Equal(m.t, r.URL.Path, "/v2/zones/123123/recordsets/321321")
-		assert.Equal(m.t, r.Header.Get("Content-Type"), "application/json")
+	m.mux.HandleFunc("/v2/zones/123123/recordsets/321321", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(m.t, http.MethodDelete, r.Method)
+		assert.Equal(m.t, "/v2/zones/123123/recordsets/321321", r.URL.Path)
+		assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
 
-		fmt.Fprintf(w, `{
-		  "zones":[{
-		    "id":"123123"
-		  }]}
-		`)
+		writeFixture(w, "zones-recordsets_DELETE.json")
 	})
 }
 
 // HandleListRecordsetsEmpty Handle list recordsets empty.
 func (m *DNSServerMock) HandleListRecordsetsEmpty() {
-	m.Mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) {
-		assert.Equal(m.t, r.URL.Path, "/v2/zones/123123/recordsets")
-		assert.Equal(m.t, r.URL.RawQuery, "type=TXT&name=_acme-challenge.example.com.")
+	m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path)
+		assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery)
 
-		fmt.Fprintf(w, `{
-		  "recordsets":[
-		  ]}
-		`)
+		writeFixture(w, "zones-recordsets_GET_empty.json")
 	})
 }
 
 // HandleListRecordsetsSuccessfully Handle list recordsets successfully.
 func (m *DNSServerMock) HandleListRecordsetsSuccessfully() {
-	m.Mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) {
-		if r.Method == http.MethodGet {
-			assert.Equal(m.t, r.URL.Path, "/v2/zones/123123/recordsets")
-			assert.Equal(m.t, r.URL.RawQuery, "type=TXT&name=_acme-challenge.example.com.")
-			assert.Equal(m.t, r.Header.Get("Content-Type"), "application/json")
+	m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(m.t, "application/json", r.Header.Get("Accept"))
 
-			fmt.Fprintf(w, `{
-			  "recordsets":[{
-			    "id":"321321"
-			  }]}
-			`)
+		if r.Method == http.MethodGet {
+			assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path)
+			assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery)
+
+			writeFixture(w, "zones-recordsets_GET.json")
 			return
 		}
 
 		if r.Method == http.MethodPost {
-			assert.Equal(m.t, r.Header.Get("Content-Type"), "application/json")
+			assert.Equal(m.t, "application/json", r.Header.Get("Content-Type"))
 
-			body, err := io.ReadAll(r.Body)
+			raw, err := io.ReadAll(r.Body)
 			assert.Nil(m.t, err)
 			exceptedString := `{
 				"name": "_acme-challenge.example.com.",
@@ -156,12 +151,10 @@ func (m *DNSServerMock) HandleListRecordsetsSuccessfully() {
 				"ttl": 300,
 				"records": ["\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\""]
 			}`
-			assert.JSONEq(m.t, string(body), exceptedString)
-			fmt.Fprintf(w, `{
-			  "recordsets":[{
-                            "id":"321321"
-			  }]}
-			`)
+
+			assert.JSONEq(m.t, exceptedString, string(raw))
+
+			writeFixture(w, "zones-recordsets_POST.json")
 			return
 		}
 
diff --git a/providers/dns/otc/internal/types.go b/providers/dns/otc/internal/types.go
new file mode 100644
index 00000000..38da4f11
--- /dev/null
+++ b/providers/dns/otc/internal/types.go
@@ -0,0 +1,147 @@
+package internal
+
+// LoginRequest
+
+type LoginRequest struct {
+	Auth Auth `json:"auth"`
+}
+
+type Auth struct {
+	Identity Identity `json:"identity"`
+	Scope    Scope    `json:"scope"`
+}
+
+type Identity struct {
+	Methods  []string `json:"methods"`
+	Password Password `json:"password"`
+}
+
+type Password struct {
+	User User `json:"user"`
+}
+
+type User struct {
+	Name     string `json:"name"`
+	Password string `json:"password"`
+	Domain   Domain `json:"domain"`
+}
+
+type Scope struct {
+	Project Project `json:"project"`
+}
+
+type Project struct {
+	Name string `json:"name"`
+}
+
+// TokenResponse
+
+type TokenResponse struct {
+	Token Token `json:"token"`
+}
+
+type Token struct {
+	User      UserR     `json:"user,omitempty"`
+	Domain    Domain    `json:"domain,omitempty"`
+	Catalog   []Catalog `json:"catalog,omitempty"`
+	Methods   []string  `json:"methods,omitempty"`
+	Roles     []Role    `json:"roles,omitempty"`
+	ExpiresAt string    `json:"expires_at,omitempty"`
+	IssuedAt  string    `json:"issued_at,omitempty"`
+}
+
+type Catalog struct {
+	ID        string     `json:"id,omitempty"`
+	Type      string     `json:"type,omitempty"`
+	Name      string     `json:"name,omitempty"`
+	Endpoints []Endpoint `json:"endpoints,omitempty"`
+}
+
+type UserR struct {
+	ID                string `json:"id,omitempty"`
+	Domain            Domain `json:"domain,omitempty"`
+	Name              string `json:"name,omitempty"`
+	PasswordExpiresAt string `json:"password_expires_at,omitempty"`
+}
+
+type Endpoint struct {
+	ID        string `json:"id,omitempty"`
+	URL       string `json:"url,omitempty"`
+	Region    string `json:"region,omitempty"`
+	RegionID  string `json:"region_id,omitempty"`
+	Interface string `json:"interface,omitempty"`
+}
+
+type Role struct {
+	ID   string `json:"id,omitempty"`
+	Name string `json:"name,omitempty"`
+}
+
+// RecordSetsResponse
+
+type RecordSetsResponse struct {
+	Links      Links        `json:"links"`
+	RecordSets []RecordSets `json:"recordsets"`
+	Metadata   Metadata     `json:"metadata"`
+}
+
+type RecordSets struct {
+	ID          string   `json:"id,omitempty"`
+	Name        string   `json:"name,omitempty"`
+	Description string   `json:"description,omitempty"`
+	Type        string   `json:"type,omitempty"`
+	TTL         int      `json:"ttl,omitempty"`
+	Records     []string `json:"records,omitempty"`
+
+	Status    string `json:"status,omitempty"`
+	Links     *Links `json:"links,omitempty"`
+	ZoneID    string `json:"zone_id,omitempty"`
+	ZoneName  string `json:"zone_name,omitempty"`
+	CreateAt  string `json:"create_at,omitempty"`
+	UpdateAt  string `json:"update_at,omitempty"`
+	Default   bool   `json:"default,omitempty"`
+	ProjectID string `json:"project_id,omitempty"`
+}
+
+// ZonesResponse
+
+type ZonesResponse struct {
+	Links    Links    `json:"links,omitempty"`
+	Zones    []Zone   `json:"zones"`
+	Metadata Metadata `json:"metadata"`
+}
+
+type Zone struct {
+	ID          string `json:"id,omitempty"`
+	Name        string `json:"name,omitempty"`
+	Description string `json:"description,omitempty"`
+	Email       string `json:"email,omitempty"`
+	TTL         int    `json:"ttl,omitempty"`
+	Serial      int    `json:"serial,omitempty"`
+	Status      string `json:"status,omitempty"`
+	Links       *Links `json:"links,omitempty"`
+	PoolID      string `json:"pool_id,omitempty"`
+	ProjectID   string `json:"project_id,omitempty"`
+	ZoneType    string `json:"zone_type,omitempty"`
+	CreatedAt   string `json:"created_at,omitempty"`
+	UpdatedAt   string `json:"updated_at,omitempty"`
+	RecordNum   int    `json:"record_num,omitempty"`
+}
+
+// Response
+
+type Links struct {
+	Self string `json:"self,omitempty"`
+	Next string `json:"next,omitempty"`
+}
+
+type Metadata struct {
+	TotalCount int `json:"total_count,omitempty"`
+}
+
+// Shared
+
+type Domain struct {
+	ID   string `json:"id,omitempty"`
+	Name string `json:"name,omitempty"`
+}
diff --git a/providers/dns/otc/otc.go b/providers/dns/otc/otc.go
index ffcf7552..e3e0b925 100644
--- a/providers/dns/otc/otc.go
+++ b/providers/dns/otc/otc.go
@@ -2,6 +2,7 @@
 package otc
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/otc/internal"
 )
 
 const defaultIdentityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens"
@@ -60,7 +62,6 @@ func NewDefaultConfig() *Config {
 				DialContext: (&net.Dialer{
 					Timeout:   30 * time.Second,
 					KeepAlive: 30 * time.Second,
-					DualStack: true,
 				}).DialContext,
 				MaxIdleConns:          100,
 				IdleConnTimeout:       90 * time.Second,
@@ -76,9 +77,8 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config  *Config
-	baseURL string
-	token   string
+	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for OTC DNS.
@@ -113,11 +113,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, fmt.Errorf("otc: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
 	}
 
-	if config.IdentityEndpoint == "" {
-		config.IdentityEndpoint = defaultIdentityEndpoint
+	client := internal.NewClient(config.UserName, config.Password, config.DomainName, config.ProjectName)
+
+	if config.IdentityEndpoint != "" {
+		client.IdentityEndpoint = config.IdentityEndpoint
 	}
 
-	return &DNSProvider{config: config}, nil
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record using the specified parameters.
@@ -126,22 +132,22 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("otc: %w", err)
+		return fmt.Errorf("otc: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	err = d.login()
+	ctx := context.Background()
+
+	err = d.client.Login(ctx)
 	if err != nil {
 		return fmt.Errorf("otc: %w", err)
 	}
 
-	zoneID, err := d.getZoneID(authZone)
+	zoneID, err := d.client.GetZoneID(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("otc: unable to get zone: %w", err)
 	}
 
-	resource := fmt.Sprintf("zones/%s/recordsets", zoneID)
-
-	r1 := &recordset{
+	record := internal.RecordSets{
 		Name:        info.EffectiveFQDN,
 		Description: "Added TXT record for ACME dns-01 challenge using lego client",
 		Type:        "TXT",
@@ -149,10 +155,11 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Records:     []string{fmt.Sprintf("%q", info.Value)},
 	}
 
-	_, err = d.sendRequest(http.MethodPost, resource, r1)
+	err = d.client.CreateRecordSet(ctx, zoneID, record)
 	if err != nil {
 		return fmt.Errorf("otc: %w", err)
 	}
+
 	return nil
 }
 
@@ -162,28 +169,31 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("otc: %w", err)
+		return fmt.Errorf("otc: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	err = d.login()
+	ctx := context.Background()
+
+	err = d.client.Login(ctx)
 	if err != nil {
 		return fmt.Errorf("otc: %w", err)
 	}
 
-	zoneID, err := d.getZoneID(authZone)
+	zoneID, err := d.client.GetZoneID(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("otc: %w", err)
 	}
 
-	recordID, err := d.getRecordSetID(zoneID, info.EffectiveFQDN)
+	recordID, err := d.client.GetRecordSetID(ctx, zoneID, info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("otc: unable go get record %s for zone %s: %w", info.EffectiveFQDN, domain, err)
+		return fmt.Errorf("otc: unable to get record %s for zone %s: %w", info.EffectiveFQDN, domain, err)
 	}
 
-	err = d.deleteRecordSet(zoneID, recordID)
+	err = d.client.DeleteRecordSet(ctx, zoneID, recordID)
 	if err != nil {
 		return fmt.Errorf("otc: %w", err)
 	}
+
 	return nil
 }
 
diff --git a/providers/dns/otc/otc.toml b/providers/dns/otc/otc.toml
index d60aa3fc..7f9703bd 100644
--- a/providers/dns/otc/otc.toml
+++ b/providers/dns/otc/otc.toml
@@ -20,5 +20,5 @@ Example = ''''''
     OTC_HTTP_TIMEOUT = "API request timeout"
 
 [Links]
-  API = "https://docs.otc.t-systems.com/en-us/dns/index.html"
+  API = "https://docs.otc.t-systems.com/domain-name-service/api-ref/index.html"
 
diff --git a/providers/dns/otc/otc_test.go b/providers/dns/otc/otc_test.go
index 03e02c81..3edfca8b 100644
--- a/providers/dns/otc/otc_test.go
+++ b/providers/dns/otc/otc_test.go
@@ -6,18 +6,20 @@ import (
 	"testing"
 
 	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/go-acme/lego/v4/providers/dns/otc/internal"
 	"github.com/stretchr/testify/suite"
 )
 
 type OTCSuite struct {
 	suite.Suite
-	Mock    *DNSServerMock
+
+	mock    *internal.DNSServerMock
 	envTest *tester.EnvTest
 }
 
 func (s *OTCSuite) SetupTest() {
-	s.Mock = NewDNSServerMock(s.T())
-	s.Mock.HandleAuthSuccessfully()
+	s.mock = internal.NewDNSServerMock(s.T())
+	s.mock.HandleAuthSuccessfully()
 	s.envTest = tester.NewEnvTest(
 		EnvDomainName,
 		EnvUserName,
@@ -29,7 +31,7 @@ func (s *OTCSuite) SetupTest() {
 
 func (s *OTCSuite) TearDownTest() {
 	s.envTest.RestoreEnv()
-	s.Mock.ShutdownServer()
+	s.mock.ShutdownServer()
 }
 
 func TestTestSuite(t *testing.T) {
@@ -42,22 +44,11 @@ func (s *OTCSuite) createDNSProvider() (*DNSProvider, error) {
 	config.Password = "Password"
 	config.DomainName = "DomainName"
 	config.ProjectName = "ProjectName"
-	config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", s.Mock.GetServerURL())
+	config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", s.mock.GetServerURL())
 
 	return NewDNSProviderConfig(config)
 }
 
-func (s *OTCSuite) TestLogin() {
-	provider, err := s.createDNSProvider()
-	s.Require().NoError(err)
-
-	err = provider.loginRequest()
-	s.Require().NoError(err)
-
-	s.Equal(provider.baseURL, fmt.Sprintf("%s/v2", s.Mock.GetServerURL()))
-	s.Equal(fakeOTCToken, provider.token)
-}
-
 func (s *OTCSuite) TestLoginEnv() {
 	s.envTest.ClearEnv()
 
@@ -94,8 +85,8 @@ func (s *OTCSuite) TestLoginEnvEmpty() {
 }
 
 func (s *OTCSuite) TestDNSProvider_Present() {
-	s.Mock.HandleListZonesSuccessfully()
-	s.Mock.HandleListRecordsetsSuccessfully()
+	s.mock.HandleListZonesSuccessfully()
+	s.mock.HandleListRecordsetsSuccessfully()
 
 	provider, err := s.createDNSProvider()
 	s.Require().NoError(err)
@@ -105,8 +96,8 @@ func (s *OTCSuite) TestDNSProvider_Present() {
 }
 
 func (s *OTCSuite) TestDNSProvider_Present_EmptyZone() {
-	s.Mock.HandleListZonesEmpty()
-	s.Mock.HandleListRecordsetsSuccessfully()
+	s.mock.HandleListZonesEmpty()
+	s.mock.HandleListRecordsetsSuccessfully()
 
 	provider, err := s.createDNSProvider()
 	s.Require().NoError(err)
@@ -116,9 +107,9 @@ func (s *OTCSuite) TestDNSProvider_Present_EmptyZone() {
 }
 
 func (s *OTCSuite) TestDNSProvider_CleanUp() {
-	s.Mock.HandleListZonesSuccessfully()
-	s.Mock.HandleListRecordsetsSuccessfully()
-	s.Mock.HandleDeleteRecordsetsSuccessfully()
+	s.mock.HandleListZonesSuccessfully()
+	s.mock.HandleListRecordsetsSuccessfully()
+	s.mock.HandleDeleteRecordsetsSuccessfully()
 
 	provider, err := s.createDNSProvider()
 	s.Require().NoError(err)
@@ -128,8 +119,8 @@ func (s *OTCSuite) TestDNSProvider_CleanUp() {
 }
 
 func (s *OTCSuite) TestDNSProvider_CleanUp_EmptyRecordset() {
-	s.Mock.HandleListZonesSuccessfully()
-	s.Mock.HandleListRecordsetsEmpty()
+	s.mock.HandleListZonesSuccessfully()
+	s.mock.HandleListRecordsetsEmpty()
 
 	provider, err := s.createDNSProvider()
 	s.Require().NoError(err)
diff --git a/providers/dns/ovh/ovh.go b/providers/dns/ovh/ovh.go
index 014daf15..9f269f6d 100644
--- a/providers/dns/ovh/ovh.go
+++ b/providers/dns/ovh/ovh.go
@@ -127,7 +127,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	// Parse domain name
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("ovh: could not determine zone for domain %q: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("ovh: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	authZone = dns01.UnFqdn(authZone)
@@ -175,7 +175,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("ovh: could not determine zone for domain %q: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("ovh: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	authZone = dns01.UnFqdn(authZone)
diff --git a/providers/dns/pdns/client.go b/providers/dns/pdns/client.go
deleted file mode 100644
index 47ffff2e..00000000
--- a/providers/dns/pdns/client.go
+++ /dev/null
@@ -1,217 +0,0 @@
-package pdns
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"path"
-	"strconv"
-	"strings"
-
-	"github.com/go-acme/lego/v4/challenge/dns01"
-	"github.com/miekg/dns"
-)
-
-type Record struct {
-	Content  string `json:"content"`
-	Disabled bool   `json:"disabled"`
-
-	// pre-v1 API
-	Name string `json:"name"`
-	Type string `json:"type"`
-	TTL  int    `json:"ttl,omitempty"`
-}
-
-type hostedZone struct {
-	ID     string  `json:"id"`
-	Name   string  `json:"name"`
-	URL    string  `json:"url"`
-	Kind   string  `json:"kind"`
-	RRSets []rrSet `json:"rrsets"`
-
-	// pre-v1 API
-	Records []Record `json:"records"`
-}
-
-type rrSet struct {
-	Name       string   `json:"name"`
-	Type       string   `json:"type"`
-	Kind       string   `json:"kind"`
-	ChangeType string   `json:"changetype"`
-	Records    []Record `json:"records,omitempty"`
-	TTL        int      `json:"ttl,omitempty"`
-}
-
-type rrSets struct {
-	RRSets []rrSet `json:"rrsets"`
-}
-
-type apiError struct {
-	ShortMsg string `json:"error"`
-}
-
-func (a apiError) Error() string {
-	return a.ShortMsg
-}
-
-type apiVersion struct {
-	URL     string `json:"url"`
-	Version int    `json:"version"`
-}
-
-func (d *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return nil, err
-	}
-
-	p := path.Join("/servers", d.config.ServerName, "/zones/", dns.Fqdn(authZone))
-
-	result, err := d.sendRequest(http.MethodGet, p, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	var zone hostedZone
-	err = json.Unmarshal(result, &zone)
-	if err != nil {
-		return nil, err
-	}
-
-	// convert pre-v1 API result
-	if len(zone.Records) > 0 {
-		zone.RRSets = []rrSet{}
-		for _, record := range zone.Records {
-			set := rrSet{
-				Name:    record.Name,
-				Type:    record.Type,
-				Records: []Record{record},
-			}
-			zone.RRSets = append(zone.RRSets, set)
-		}
-	}
-
-	return &zone, nil
-}
-
-func (d *DNSProvider) findTxtRecord(fqdn string) (*rrSet, error) {
-	zone, err := d.getHostedZone(fqdn)
-	if err != nil {
-		return nil, err
-	}
-
-	_, err = d.sendRequest(http.MethodGet, zone.URL, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	for _, set := range zone.RRSets {
-		if set.Type == "TXT" && (set.Name == dns01.UnFqdn(fqdn) || set.Name == fqdn) {
-			return &set, nil
-		}
-	}
-
-	return nil, nil
-}
-
-func (d *DNSProvider) getAPIVersion() (int, error) {
-	result, err := d.sendRequest(http.MethodGet, "/api", nil)
-	if err != nil {
-		return 0, err
-	}
-
-	var versions []apiVersion
-	err = json.Unmarshal(result, &versions)
-	if err != nil {
-		return 0, err
-	}
-
-	latestVersion := 0
-	for _, v := range versions {
-		if v.Version > latestVersion {
-			latestVersion = v.Version
-		}
-	}
-
-	return latestVersion, err
-}
-
-func (d *DNSProvider) notify(zone *hostedZone) error {
-	if d.apiVersion < 1 || zone.Kind != "Master" && zone.Kind != "Slave" {
-		return nil
-	}
-
-	_, err := d.sendRequest(http.MethodPut, path.Join(zone.URL, "/notify"), nil)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (d *DNSProvider) sendRequest(method, uri string, body io.Reader) (json.RawMessage, error) {
-	req, err := d.makeRequest(method, uri, body)
-	if err != nil {
-		return nil, err
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, fmt.Errorf("error talking to PDNS API: %w", err)
-	}
-
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusUnprocessableEntity && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
-		return nil, fmt.Errorf("unexpected HTTP status code %d when %sing '%s'", resp.StatusCode, req.Method, req.URL)
-	}
-
-	var msg json.RawMessage
-	err = json.NewDecoder(resp.Body).Decode(&msg)
-	if err != nil {
-		if errors.Is(err, io.EOF) {
-			// empty body
-			return nil, nil
-		}
-		// other error
-		return nil, err
-	}
-
-	// check for PowerDNS error message
-	if len(msg) > 0 && msg[0] == '{' {
-		var errInfo apiError
-		err = json.Unmarshal(msg, &errInfo)
-		if err != nil {
-			return nil, err
-		}
-		if errInfo.ShortMsg != "" {
-			return nil, fmt.Errorf("error talking to PDNS API: %w", errInfo)
-		}
-	}
-	return msg, nil
-}
-
-func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (*http.Request, error) {
-	p := path.Join("/", uri)
-
-	if p != "/api" && d.apiVersion > 0 && !strings.HasPrefix(p, "/api/v") {
-		p = path.Join("/api", "v"+strconv.Itoa(d.apiVersion), p)
-	}
-
-	endpoint := d.config.Host.JoinPath(p)
-
-	req, err := http.NewRequest(method, strings.TrimSuffix(endpoint.String(), "/"), body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("X-API-Key", d.config.APIKey)
-
-	if method != http.MethodGet && method != http.MethodDelete {
-		req.Header.Set("Content-Type", "application/json")
-	}
-
-	return req, nil
-}
diff --git a/providers/dns/pdns/client_test.go b/providers/dns/pdns/client_test.go
deleted file mode 100644
index cb8befb9..00000000
--- a/providers/dns/pdns/client_test.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package pdns
-
-import (
-	"net/http"
-	"net/url"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestDNSProvider_makeRequest(t *testing.T) {
-	testCases := []struct {
-		desc       string
-		apiVersion int
-		baseURL    string
-		uri        string
-		expected   string
-	}{
-		{
-			desc:       "host with path",
-			apiVersion: 1,
-			baseURL:    "https://example.com/test",
-			uri:        "/foo",
-			expected:   "https://example.com/test/api/v1/foo",
-		},
-		{
-			desc:       "host with path + trailing slash",
-			apiVersion: 1,
-			baseURL:    "https://example.com/test/",
-			uri:        "/foo",
-			expected:   "https://example.com/test/api/v1/foo",
-		},
-		{
-			desc:       "no URI",
-			apiVersion: 1,
-			baseURL:    "https://example.com/test",
-			uri:        "",
-			expected:   "https://example.com/test/api/v1",
-		},
-		{
-			desc:       "host without path",
-			apiVersion: 1,
-			baseURL:    "https://example.com",
-			uri:        "/foo",
-			expected:   "https://example.com/api/v1/foo",
-		},
-		{
-			desc:       "api",
-			apiVersion: 1,
-			baseURL:    "https://example.com",
-			uri:        "/api",
-			expected:   "https://example.com/api",
-		},
-		{
-			desc:       "API version 0, host with path",
-			apiVersion: 0,
-			baseURL:    "https://example.com/test",
-			uri:        "/foo",
-			expected:   "https://example.com/test/foo",
-		},
-		{
-			desc:       "API version 0, host with path + trailing slash",
-			apiVersion: 0,
-			baseURL:    "https://example.com/test/",
-			uri:        "/foo",
-			expected:   "https://example.com/test/foo",
-		},
-		{
-			desc:       "API version 0, no URI",
-			apiVersion: 0,
-			baseURL:    "https://example.com/test",
-			uri:        "",
-			expected:   "https://example.com/test",
-		},
-		{
-			desc:       "API version 0, host without path",
-			apiVersion: 0,
-			baseURL:    "https://example.com",
-			uri:        "/foo",
-			expected:   "https://example.com/foo",
-		},
-		{
-			desc:       "API version 0, api",
-			apiVersion: 0,
-			baseURL:    "https://example.com",
-			uri:        "/api",
-			expected:   "https://example.com/api",
-		},
-	}
-
-	for _, test := range testCases {
-		test := test
-		t.Run(test.desc, func(t *testing.T) {
-			t.Parallel()
-
-			host, err := url.Parse(test.baseURL)
-			require.NoError(t, err)
-
-			config := &Config{Host: host, APIKey: "secret"}
-
-			p := &DNSProvider{
-				config:     config,
-				apiVersion: test.apiVersion,
-			}
-
-			req, err := p.makeRequest(http.MethodGet, test.uri, nil)
-			require.NoError(t, err)
-
-			assert.Equal(t, test.expected, req.URL.String())
-			assert.Equal(t, "secret", req.Header.Get("X-API-Key"))
-		})
-	}
-}
diff --git a/providers/dns/pdns/internal/client.go b/providers/dns/pdns/internal/client.go
new file mode 100644
index 00000000..cddb1c63
--- /dev/null
+++ b/providers/dns/pdns/internal/client.go
@@ -0,0 +1,226 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"github.com/miekg/dns"
+)
+
+// Client the PowerDNS API client.
+type Client struct {
+	serverName string
+	apiKey     string
+
+	apiVersion int
+
+	Host       *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(host *url.URL, serverName string, apiKey string) *Client {
+	return &Client{
+		serverName: serverName,
+		apiKey:     apiKey,
+		Host:       host,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+func (c *Client) APIVersion() int {
+	return c.apiVersion
+}
+
+func (c *Client) SetAPIVersion(ctx context.Context) error {
+	var err error
+
+	c.apiVersion, err = c.getAPIVersion(ctx)
+
+	return err
+}
+
+func (c *Client) getAPIVersion(ctx context.Context) (int, error) {
+	endpoint := c.joinPath("/", "api")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return 0, err
+	}
+
+	result, err := c.do(req)
+	if err != nil {
+		return 0, err
+	}
+
+	var versions []apiVersion
+	err = json.Unmarshal(result, &versions)
+	if err != nil {
+		return 0, err
+	}
+
+	latestVersion := 0
+	for _, v := range versions {
+		if v.Version > latestVersion {
+			latestVersion = v.Version
+		}
+	}
+
+	return latestVersion, err
+}
+
+func (c *Client) GetHostedZone(ctx context.Context, authZone string) (*HostedZone, error) {
+	endpoint := c.joinPath("/", "servers", c.serverName, "zones", dns.Fqdn(authZone))
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	result, err := c.do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	var zone HostedZone
+	err = json.Unmarshal(result, &zone)
+	if err != nil {
+		return nil, err
+	}
+
+	// convert pre-v1 API result
+	if len(zone.Records) > 0 {
+		zone.RRSets = []RRSet{}
+		for _, record := range zone.Records {
+			set := RRSet{
+				Name:    record.Name,
+				Type:    record.Type,
+				Records: []Record{record},
+			}
+			zone.RRSets = append(zone.RRSets, set)
+		}
+	}
+
+	return &zone, nil
+}
+
+func (c *Client) UpdateRecords(ctx context.Context, zone *HostedZone, sets RRSets) error {
+	endpoint := c.joinPath("/", zone.URL)
+
+	req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, sets)
+	if err != nil {
+		return err
+	}
+
+	_, err = c.do(req)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (c *Client) Notify(ctx context.Context, zone *HostedZone) error {
+	if c.apiVersion < 1 || zone.Kind != "Master" && zone.Kind != "Slave" {
+		return nil
+	}
+
+	endpoint := c.joinPath("/", zone.URL, "/notify")
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	_, err = c.do(req)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (c *Client) joinPath(elem ...string) *url.URL {
+	p := path.Join(elem...)
+
+	if p != "/api" && c.apiVersion > 0 && !strings.HasPrefix(p, "/api/v") {
+		p = path.Join("/api", "v"+strconv.Itoa(c.apiVersion), p)
+	}
+
+	return c.Host.JoinPath(p)
+}
+
+func (c *Client) do(req *http.Request) (json.RawMessage, error) {
+	req.Header.Set("X-API-Key", c.apiKey)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusUnprocessableEntity && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	var msg json.RawMessage
+	err = json.NewDecoder(resp.Body).Decode(&msg)
+	if err != nil {
+		if errors.Is(err, io.EOF) {
+			// empty body
+			return nil, nil
+		}
+		// other error
+		return nil, err
+	}
+
+	// check for PowerDNS error message
+	if len(msg) > 0 && msg[0] == '{' {
+		var errInfo apiError
+		err = json.Unmarshal(msg, &errInfo)
+		if err != nil {
+			return nil, errutils.NewUnmarshalError(req, resp.StatusCode, msg, err)
+		}
+		if errInfo.ShortMsg != "" {
+			return nil, fmt.Errorf("error talking to PDNS API: %w", errInfo)
+		}
+	}
+
+	return msg, nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(endpoint.String(), "/"), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/pdns/internal/client_test.go b/providers/dns/pdns/internal/client_test.go
new file mode 100644
index 00000000..d102a5ef
--- /dev/null
+++ b/providers/dns/pdns/internal/client_test.go
@@ -0,0 +1,352 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, method, pattern string, status int, file string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		apiKey := req.Header.Get("X-API-Key")
+		if apiKey != "secret" {
+			http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiKey), http.StatusBadRequest)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	serverURL, _ := url.Parse(server.URL)
+
+	client := NewClient(serverURL, "server", "secret")
+	client.HTTPClient = server.Client()
+
+	return client
+}
+
+func TestClient_joinPath(t *testing.T) {
+	testCases := []struct {
+		desc       string
+		apiVersion int
+		baseURL    string
+		uri        string
+		expected   string
+	}{
+		{
+			desc:       "host with path",
+			apiVersion: 1,
+			baseURL:    "https://example.com/test",
+			uri:        "/foo",
+			expected:   "https://example.com/test/api/v1/foo",
+		},
+		{
+			desc:       "host with path + trailing slash",
+			apiVersion: 1,
+			baseURL:    "https://example.com/test/",
+			uri:        "/foo",
+			expected:   "https://example.com/test/api/v1/foo",
+		},
+		{
+			desc:       "no URI",
+			apiVersion: 1,
+			baseURL:    "https://example.com/test",
+			uri:        "",
+			expected:   "https://example.com/test/api/v1",
+		},
+		{
+			desc:       "host without path",
+			apiVersion: 1,
+			baseURL:    "https://example.com",
+			uri:        "/foo",
+			expected:   "https://example.com/api/v1/foo",
+		},
+		{
+			desc:       "api",
+			apiVersion: 1,
+			baseURL:    "https://example.com",
+			uri:        "/api",
+			expected:   "https://example.com/api",
+		},
+		{
+			desc:       "API version 0, host with path",
+			apiVersion: 0,
+			baseURL:    "https://example.com/test",
+			uri:        "/foo",
+			expected:   "https://example.com/test/foo",
+		},
+		{
+			desc:       "API version 0, host with path + trailing slash",
+			apiVersion: 0,
+			baseURL:    "https://example.com/test/",
+			uri:        "/foo",
+			expected:   "https://example.com/test/foo",
+		},
+		{
+			desc:       "API version 0, no URI",
+			apiVersion: 0,
+			baseURL:    "https://example.com/test",
+			uri:        "",
+			expected:   "https://example.com/test",
+		},
+		{
+			desc:       "API version 0, host without path",
+			apiVersion: 0,
+			baseURL:    "https://example.com",
+			uri:        "/foo",
+			expected:   "https://example.com/foo",
+		},
+		{
+			desc:       "API version 0, api",
+			apiVersion: 0,
+			baseURL:    "https://example.com",
+			uri:        "/api",
+			expected:   "https://example.com/api",
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			host, err := url.Parse(test.baseURL)
+			require.NoError(t, err)
+
+			client := NewClient(host, "test", "secret")
+			client.apiVersion = test.apiVersion
+
+			endpoint := client.joinPath(test.uri)
+
+			assert.Equal(t, test.expected, endpoint.String())
+		})
+	}
+}
+
+func TestClient_GetHostedZone(t *testing.T) {
+	client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusOK, "zone.json")
+	client.apiVersion = 1
+
+	zone, err := client.GetHostedZone(context.Background(), "example.org.")
+	require.NoError(t, err)
+
+	expected := &HostedZone{
+		ID:   "example.org.",
+		Name: "example.org.",
+		URL:  "api/v1/servers/localhost/zones/example.org.",
+		Kind: "Master",
+		RRSets: []RRSet{
+			{
+				Name:    "example.org.",
+				Type:    "NS",
+				Records: []Record{{Content: "ns2.example.org."}, {Content: "ns1.example.org."}},
+				TTL:     86400,
+			},
+			{
+				Name:    "example.org.",
+				Type:    "SOA",
+				Records: []Record{{Content: "ns1.example.org. hostmaster.example.org. 2015120401 10800 15 604800 10800"}},
+				TTL:     86400,
+			},
+			{
+				Name:    "ns1.example.org.",
+				Type:    "A",
+				Records: []Record{{Content: "192.168.0.1"}},
+				TTL:     86400,
+			},
+			{
+				Name:    "www.example.org.",
+				Type:    "A",
+				Records: []Record{{Content: "192.168.0.2"}},
+				TTL:     86400,
+			},
+		},
+	}
+
+	assert.Equal(t, expected, zone)
+}
+
+func TestClient_GetHostedZone_error(t *testing.T) {
+	client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusUnprocessableEntity, "error.json")
+	client.apiVersion = 1
+
+	_, err := client.GetHostedZone(context.Background(), "example.org.")
+	require.ErrorAs(t, err, &apiError{})
+}
+
+func TestClient_GetHostedZone_v0(t *testing.T) {
+	client := setupTest(t, http.MethodGet, "/servers/server/zones/example.org.", http.StatusOK, "zone.json")
+	client.apiVersion = 0
+
+	zone, err := client.GetHostedZone(context.Background(), "example.org.")
+	require.NoError(t, err)
+
+	expected := &HostedZone{
+		ID:   "example.org.",
+		Name: "example.org.",
+		URL:  "api/v1/servers/localhost/zones/example.org.",
+		Kind: "Master",
+		RRSets: []RRSet{
+			{
+				Name:    "example.org.",
+				Type:    "NS",
+				Records: []Record{{Content: "ns2.example.org."}, {Content: "ns1.example.org."}},
+				TTL:     86400,
+			},
+			{
+				Name:    "example.org.",
+				Type:    "SOA",
+				Records: []Record{{Content: "ns1.example.org. hostmaster.example.org. 2015120401 10800 15 604800 10800"}},
+				TTL:     86400,
+			},
+			{
+				Name:    "ns1.example.org.",
+				Type:    "A",
+				Records: []Record{{Content: "192.168.0.1"}},
+				TTL:     86400,
+			},
+			{
+				Name:    "www.example.org.",
+				Type:    "A",
+				Records: []Record{{Content: "192.168.0.2"}},
+				TTL:     86400,
+			},
+		},
+	}
+
+	assert.Equal(t, expected, zone)
+}
+
+func TestClient_UpdateRecords(t *testing.T) {
+	client := setupTest(t, http.MethodPatch, "/api/v1/servers/localhost/zones/example.org.", http.StatusOK, "zone.json")
+	client.apiVersion = 1
+
+	zone := &HostedZone{
+		ID:   "example.org.",
+		Name: "example.org.",
+		URL:  "api/v1/servers/localhost/zones/example.org.",
+		Kind: "Master",
+	}
+
+	rrSets := RRSets{
+		RRSets: []RRSet{{
+			Name:       "example.org.",
+			Type:       "NS",
+			ChangeType: "REPLACE",
+			Records: []Record{{
+				Content: "192.0.2.5",
+				Name:    "ns1.example.org.",
+				TTL:     86400,
+				Type:    "A",
+			}},
+		}},
+	}
+
+	err := client.UpdateRecords(context.Background(), zone, rrSets)
+	require.NoError(t, err)
+}
+
+func TestClient_UpdateRecords_v0(t *testing.T) {
+	client := setupTest(t, http.MethodPatch, "/servers/localhost/zones/example.org.", http.StatusOK, "zone.json")
+	client.apiVersion = 0
+
+	zone := &HostedZone{
+		ID:   "example.org.",
+		Name: "example.org.",
+		URL:  "servers/localhost/zones/example.org.",
+		Kind: "Master",
+	}
+
+	rrSets := RRSets{
+		RRSets: []RRSet{{
+			Name:       "example.org.",
+			Type:       "NS",
+			ChangeType: "REPLACE",
+			Records: []Record{{
+				Content: "192.0.2.5",
+				Name:    "ns1.example.org.",
+				TTL:     86400,
+				Type:    "A",
+			}},
+		}},
+	}
+
+	err := client.UpdateRecords(context.Background(), zone, rrSets)
+	require.NoError(t, err)
+}
+
+func TestClient_Notify(t *testing.T) {
+	client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "")
+	client.apiVersion = 1
+
+	zone := &HostedZone{
+		ID:   "example.org.",
+		Name: "example.org.",
+		URL:  "api/v1/servers/localhost/zones/example.org.",
+		Kind: "Master",
+	}
+
+	err := client.Notify(context.Background(), zone)
+	require.NoError(t, err)
+}
+
+func TestClient_Notify_v0(t *testing.T) {
+	client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "")
+
+	zone := &HostedZone{
+		ID:   "example.org.",
+		Name: "example.org.",
+		URL:  "servers/localhost/zones/example.org.",
+		Kind: "Master",
+	}
+
+	err := client.Notify(context.Background(), zone)
+	require.NoError(t, err)
+}
+
+func TestClient_getAPIVersion(t *testing.T) {
+	client := setupTest(t, http.MethodGet, "/api", http.StatusOK, "versions.json")
+
+	version, err := client.getAPIVersion(context.Background())
+	require.NoError(t, err)
+
+	assert.Equal(t, 4, version)
+}
diff --git a/providers/dns/pdns/internal/fixtures/error.json b/providers/dns/pdns/internal/fixtures/error.json
new file mode 100644
index 00000000..90b28efc
--- /dev/null
+++ b/providers/dns/pdns/internal/fixtures/error.json
@@ -0,0 +1,3 @@
+{
+  "error": "A human readable error message"
+}
diff --git a/providers/dns/pdns/internal/fixtures/versions.json b/providers/dns/pdns/internal/fixtures/versions.json
new file mode 100644
index 00000000..4d7694d8
--- /dev/null
+++ b/providers/dns/pdns/internal/fixtures/versions.json
@@ -0,0 +1,18 @@
+[
+  {
+    "url": "/fooa",
+    "version": 0
+  },
+  {
+    "url": "/foob",
+    "version": 4
+  },
+  {
+    "url": "/fooc",
+    "version": 2
+  },
+  {
+    "url": "/food",
+    "version": 1
+  }
+]
diff --git a/providers/dns/pdns/internal/fixtures/zone.json b/providers/dns/pdns/internal/fixtures/zone.json
new file mode 100644
index 00000000..dabf69b2
--- /dev/null
+++ b/providers/dns/pdns/internal/fixtures/zone.json
@@ -0,0 +1,69 @@
+{
+  "id": "example.org.",
+  "url": "api/v1/servers/localhost/zones/example.org.",
+  "name": "example.org.",
+  "kind": "Master",
+  "dnssec": false,
+  "account": "",
+  "masters": [],
+  "serial": 2015120401,
+  "notified_serial": 0,
+  "last_check": 0,
+  "soa_edit_api": "",
+  "soa_edit": "",
+  "rrsets": [
+    {
+      "comments": [],
+      "name": "example.org.",
+      "records": [
+        {
+          "content": "ns2.example.org.",
+          "disabled": false
+        },
+        {
+          "content": "ns1.example.org.",
+          "disabled": false
+        }
+      ],
+      "ttl": 86400,
+      "type": "NS"
+    },
+    {
+      "comments": [],
+      "name": "example.org.",
+      "type": "SOA",
+      "ttl": 86400,
+      "records": [
+        {
+          "disabled": false,
+          "content": "ns1.example.org. hostmaster.example.org. 2015120401 10800 15 604800 10800"
+        }
+      ]
+    },
+    {
+      "comments": [],
+      "name": "ns1.example.org.",
+      "type": "A",
+      "ttl": 86400,
+      "records": [
+        {
+          "content": "192.168.0.1",
+          "disabled": false
+        }
+      ]
+    },
+    {
+      "comments": [],
+      "name": "www.example.org.",
+      "type": "A",
+      "ttl": 86400,
+      "records": [
+        {
+          "disabled": false,
+          "content": "192.168.0.2"
+        }
+      ]
+    }
+  ]
+}
+
diff --git a/providers/dns/pdns/internal/types.go b/providers/dns/pdns/internal/types.go
new file mode 100644
index 00000000..df885d9c
--- /dev/null
+++ b/providers/dns/pdns/internal/types.go
@@ -0,0 +1,48 @@
+package internal
+
+type Record struct {
+	Content  string `json:"content"`
+	Disabled bool   `json:"disabled"`
+
+	// pre-v1 API
+	Name string `json:"name"`
+	Type string `json:"type"`
+	TTL  int    `json:"ttl,omitempty"`
+}
+
+type HostedZone struct {
+	ID     string  `json:"id"`
+	Name   string  `json:"name"`
+	URL    string  `json:"url"`
+	Kind   string  `json:"kind"`
+	RRSets []RRSet `json:"rrsets"`
+
+	// pre-v1 API
+	Records []Record `json:"records"`
+}
+
+type RRSet struct {
+	Name       string   `json:"name"`
+	Type       string   `json:"type"`
+	Kind       string   `json:"kind"`
+	ChangeType string   `json:"changetype"`
+	Records    []Record `json:"records,omitempty"`
+	TTL        int      `json:"ttl,omitempty"`
+}
+
+type RRSets struct {
+	RRSets []RRSet `json:"rrsets"`
+}
+
+type apiError struct {
+	ShortMsg string `json:"error"`
+}
+
+func (a apiError) Error() string {
+	return a.ShortMsg
+}
+
+type apiVersion struct {
+	URL     string `json:"url"`
+	Version int    `json:"version"`
+}
diff --git a/providers/dns/pdns/pdns.go b/providers/dns/pdns/pdns.go
index 99e3c82e..9885362d 100644
--- a/providers/dns/pdns/pdns.go
+++ b/providers/dns/pdns/pdns.go
@@ -2,8 +2,7 @@
 package pdns
 
 import (
-	"bytes"
-	"encoding/json"
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -13,6 +12,7 @@ import (
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/log"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/pdns/internal"
 )
 
 // Environment variables names.
@@ -55,8 +55,8 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	apiVersion int
-	config     *Config
+	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for pdns.
@@ -94,15 +94,14 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("pdns: API URL missing")
 	}
 
-	d := &DNSProvider{config: config}
+	client := internal.NewClient(config.Host, config.ServerName, config.APIKey)
 
-	apiVersion, err := d.getAPIVersion()
+	err := client.SetAPIVersion(context.Background())
 	if err != nil {
 		log.Warnf("pdns: failed to get API version %v", err)
 	}
-	d.apiVersion = apiVersion
 
-	return d, nil
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS
@@ -115,20 +114,35 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.getHostedZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+	if err != nil {
+		return fmt.Errorf("pdns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
+	}
+
+	ctx := context.Background()
+
+	zone, err := d.client.GetHostedZone(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("pdns: %w", err)
 	}
 
 	name := info.EffectiveFQDN
-
-	// pre-v1 API wants non-fqdn
-	if d.apiVersion == 0 {
+	if d.client.APIVersion() == 0 {
+		// pre-v1 API wants non-fqdn
 		name = dns01.UnFqdn(info.EffectiveFQDN)
 	}
 
-	rec := Record{
-		Content:  "\"" + info.Value + "\"",
+	// Look for existing records.
+	existingRRSet := findTxtRecord(zone, info.EffectiveFQDN)
+
+	// merge the existing and new records
+	var records []internal.Record
+	if existingRRSet != nil {
+		records = existingRRSet.Records
+	}
+
+	rec := internal.Record{
+		Content:  "\"" + info.EffectiveFQDN + "\"",
 		Disabled: false,
 
 		// pre-v1 API
@@ -137,64 +151,51 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:  d.config.TTL,
 	}
 
-	// Look for existing records.
-	existingRrSet, err := d.findTxtRecord(info.EffectiveFQDN)
-	if err != nil {
-		return fmt.Errorf("pdns: %w", err)
-	}
-
-	// merge the existing and new records
-	var records []Record
-	if existingRrSet != nil {
-		records = existingRrSet.Records
-	}
-	records = append(records, rec)
-
-	rrsets := rrSets{
-		RRSets: []rrSet{
+	rrSets := internal.RRSets{
+		RRSets: []internal.RRSet{
 			{
 				Name:       name,
 				ChangeType: "REPLACE",
 				Type:       "TXT",
 				Kind:       "Master",
 				TTL:        d.config.TTL,
-				Records:    records,
+				Records:    append(records, rec),
 			},
 		},
 	}
 
-	body, err := json.Marshal(rrsets)
+	err = d.client.UpdateRecords(ctx, zone, rrSets)
 	if err != nil {
 		return fmt.Errorf("pdns: %w", err)
 	}
 
-	_, err = d.sendRequest(http.MethodPatch, zone.URL, bytes.NewReader(body))
-	if err != nil {
-		return fmt.Errorf("pdns: %w", err)
-	}
-
-	return d.notify(zone)
+	return d.client.Notify(ctx, zone)
 }
 
 // CleanUp removes the TXT record matching the specified parameters.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.getHostedZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+	if err != nil {
+		return fmt.Errorf("pdns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
+	}
+
+	ctx := context.Background()
+
+	zone, err := d.client.GetHostedZone(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("pdns: %w", err)
 	}
 
-	set, err := d.findTxtRecord(info.EffectiveFQDN)
-	if err != nil {
-		return fmt.Errorf("pdns: %w", err)
-	}
+	set := findTxtRecord(zone, info.EffectiveFQDN)
+
 	if set == nil {
 		return fmt.Errorf("pdns: no existing record found for %s", info.EffectiveFQDN)
 	}
 
-	rrsets := rrSets{
-		RRSets: []rrSet{
+	rrSets := internal.RRSets{
+		RRSets: []internal.RRSet{
 			{
 				Name:       set.Name,
 				Type:       set.Type,
@@ -202,15 +203,21 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 			},
 		},
 	}
-	body, err := json.Marshal(rrsets)
+
+	err = d.client.UpdateRecords(ctx, zone, rrSets)
 	if err != nil {
 		return fmt.Errorf("pdns: %w", err)
 	}
 
-	_, err = d.sendRequest(http.MethodPatch, zone.URL, bytes.NewReader(body))
-	if err != nil {
-		return fmt.Errorf("pdns: %w", err)
-	}
-
-	return d.notify(zone)
+	return d.client.Notify(ctx, zone)
+}
+
+func findTxtRecord(zone *internal.HostedZone, fqdn string) *internal.RRSet {
+	for _, set := range zone.RRSets {
+		if set.Type == "TXT" && (set.Name == dns01.UnFqdn(fqdn) || set.Name == fqdn) {
+			return &set
+		}
+	}
+
+	return nil
 }
diff --git a/providers/dns/plesk/internal/client.go b/providers/dns/plesk/internal/client.go
index cb61d388..9dd9d5ee 100644
--- a/providers/dns/plesk/internal/client.go
+++ b/providers/dns/plesk/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/xml"
 	"errors"
 	"fmt"
@@ -9,34 +10,37 @@ import (
 	"net/http"
 	"net/url"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 // Client the Plesk API client.
 type Client struct {
-	HTTPClient *http.Client
+	login    string
+	password string
+
 	baseURL    *url.URL
-	login      string
-	password   string
+	HTTPClient *http.Client
 }
 
 // NewClient created a new Client.
 func NewClient(baseURL *url.URL, login string, password string) *Client {
 	return &Client{
-		HTTPClient: &http.Client{Timeout: 10 * time.Second},
-		baseURL:    baseURL,
 		login:      login,
 		password:   password,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}
 }
 
 // GetSite gets a site.
 // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-sites-domains/getting-information-about-sites.66583/
-func (c Client) GetSite(domain string) (int, error) {
+func (c Client) GetSite(ctx context.Context, domain string) (int, error) {
 	payload := RequestPacketType{Site: &SiteTypeRequest{Get: SiteGetRequest{Filter: &SiteFilterType{
 		Name: domain,
 	}}}}
 
-	response, err := c.do(payload)
+	response, err := c.doRequest(ctx, payload)
 	if err != nil {
 		return 0, err
 	}
@@ -58,7 +62,7 @@ func (c Client) GetSite(domain string) (int, error) {
 
 // AddRecord adds a TXT record.
 // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/adding-dns-record.34798/
-func (c Client) AddRecord(siteID int, host, value string) (int, error) {
+func (c Client) AddRecord(ctx context.Context, siteID int, host, value string) (int, error) {
 	payload := RequestPacketType{DNS: &DNSInputType{AddRec: []AddRecRequest{{
 		SiteID: siteID,
 		Type:   "TXT",
@@ -66,7 +70,7 @@ func (c Client) AddRecord(siteID int, host, value string) (int, error) {
 		Value:  value,
 	}}}}
 
-	response, err := c.do(payload)
+	response, err := c.doRequest(ctx, payload)
 	if err != nil {
 		return 0, err
 	}
@@ -88,12 +92,12 @@ func (c Client) AddRecord(siteID int, host, value string) (int, error) {
 
 // DeleteRecord Deletes a TXT record.
 // https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-dns/managing-dns-records/deleting-dns-records.34864/
-func (c Client) DeleteRecord(recordID int) (int, error) {
+func (c Client) DeleteRecord(ctx context.Context, recordID int) (int, error) {
 	payload := RequestPacketType{DNS: &DNSInputType{DelRec: []DelRecRequest{{Filter: DNSSelectionFilterType{
 		ID: recordID,
 	}}}}}
 
-	response, err := c.do(payload)
+	response, err := c.doRequest(ctx, payload)
 	if err != nil {
 		return 0, err
 	}
@@ -113,36 +117,45 @@ func (c Client) DeleteRecord(recordID int) (int, error) {
 	return response.DNS.DelRec[0].Result.ID, nil
 }
 
-func (c Client) do(payload RequestPacketType) (*ResponsePacketType, error) {
+func (c Client) doRequest(ctx context.Context, payload RequestPacketType) (*ResponsePacketType, error) {
 	endpoint := c.baseURL.JoinPath("/enterprise/control/agent.php")
 
-	body := &bytes.Buffer{}
+	body := new(bytes.Buffer)
 	err := xml.NewEncoder(body).Encode(payload)
 	if err != nil {
 		return nil, err
 	}
 
-	req, _ := http.NewRequest(http.MethodPost, endpoint.String(), body)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), body)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
 	req.Header.Set("Content-Type", "text/xml")
+
 	req.Header.Set("Http_auth_login", c.login)
 	req.Header.Set("Http_auth_passwd", c.password)
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode/100 != 2 {
-		all, _ := io.ReadAll(resp.Body)
-		return nil, fmt.Errorf("API error: %s", string(all))
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	var response ResponsePacketType
-	err = xml.NewDecoder(resp.Body).Decode(&response)
+	err = xml.Unmarshal(raw, &response)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return &response, nil
diff --git a/providers/dns/plesk/internal/client_test.go b/providers/dns/plesk/internal/client_test.go
index 0f9636e0..5d59a4c8 100644
--- a/providers/dns/plesk/internal/client_test.go
+++ b/providers/dns/plesk/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -16,6 +17,7 @@ import (
 
 func setupTest(t *testing.T, filename string) *Client {
 	t.Helper()
+
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
@@ -64,7 +66,7 @@ func setupTest(t *testing.T, filename string) *Client {
 func TestClient_GetSite(t *testing.T) {
 	client := setupTest(t, "get-site.xml")
 
-	siteID, err := client.GetSite("example.com")
+	siteID, err := client.GetSite(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	assert.Equal(t, 82, siteID)
@@ -73,7 +75,7 @@ func TestClient_GetSite(t *testing.T) {
 func TestClient_GetSite_error(t *testing.T) {
 	client := setupTest(t, "get-site-error.xml")
 
-	siteID, err := client.GetSite("example.com")
+	siteID, err := client.GetSite(context.Background(), "example.com")
 	require.Error(t, err)
 
 	assert.Equal(t, 0, siteID)
@@ -82,7 +84,7 @@ func TestClient_GetSite_error(t *testing.T) {
 func TestClient_GetSite_system_error(t *testing.T) {
 	client := setupTest(t, "global-error.xml")
 
-	siteID, err := client.GetSite("example.com")
+	siteID, err := client.GetSite(context.Background(), "example.com")
 	require.Error(t, err)
 
 	assert.Equal(t, 0, siteID)
@@ -91,7 +93,7 @@ func TestClient_GetSite_system_error(t *testing.T) {
 func TestClient_AddRecord(t *testing.T) {
 	client := setupTest(t, "add-record.xml")
 
-	recordID, err := client.AddRecord(123, "_acme-challenge.example.com", "txtTXTtxt")
+	recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt")
 	require.NoError(t, err)
 
 	assert.Equal(t, 4537, recordID)
@@ -100,8 +102,8 @@ func TestClient_AddRecord(t *testing.T) {
 func TestClient_AddRecord_error(t *testing.T) {
 	client := setupTest(t, "add-record-error.xml")
 
-	recordID, err := client.AddRecord(123, "_acme-challenge.example.com", "txtTXTtxt")
-	require.Error(t, err)
+	recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt")
+	require.ErrorAs(t, err, new(RecResult))
 
 	assert.Equal(t, 0, recordID)
 }
@@ -109,8 +111,8 @@ func TestClient_AddRecord_error(t *testing.T) {
 func TestClient_AddRecord_system_error(t *testing.T) {
 	client := setupTest(t, "global-error.xml")
 
-	recordID, err := client.AddRecord(123, "_acme-challenge.example.com", "txtTXTtxt")
-	require.Error(t, err)
+	recordID, err := client.AddRecord(context.Background(), 123, "_acme-challenge.example.com", "txtTXTtxt")
+	require.ErrorAs(t, err, new(*System))
 
 	assert.Equal(t, 0, recordID)
 }
@@ -118,7 +120,7 @@ func TestClient_AddRecord_system_error(t *testing.T) {
 func TestClient_DeleteRecord(t *testing.T) {
 	client := setupTest(t, "delete-record.xml")
 
-	recordID, err := client.DeleteRecord(4537)
+	recordID, err := client.DeleteRecord(context.Background(), 4537)
 	require.NoError(t, err)
 
 	assert.Equal(t, 4537, recordID)
@@ -127,8 +129,8 @@ func TestClient_DeleteRecord(t *testing.T) {
 func TestClient_DeleteRecord_error(t *testing.T) {
 	client := setupTest(t, "delete-record-error.xml")
 
-	recordID, err := client.DeleteRecord(4537)
-	require.Error(t, err)
+	recordID, err := client.DeleteRecord(context.Background(), 4537)
+	require.ErrorAs(t, err, new(RecResult))
 
 	assert.Equal(t, 0, recordID)
 }
@@ -136,8 +138,8 @@ func TestClient_DeleteRecord_error(t *testing.T) {
 func TestClient_DeleteRecord_system_error(t *testing.T) {
 	client := setupTest(t, "global-error.xml")
 
-	recordID, err := client.DeleteRecord(4537)
-	require.Error(t, err)
+	recordID, err := client.DeleteRecord(context.Background(), 4537)
+	require.ErrorAs(t, err, new(*System))
 
 	assert.Equal(t, 0, recordID)
 }
diff --git a/providers/dns/plesk/plesk.go b/providers/dns/plesk/plesk.go
index eb3dacff..aa0fc1dd 100644
--- a/providers/dns/plesk/plesk.go
+++ b/providers/dns/plesk/plesk.go
@@ -2,6 +2,7 @@
 package plesk
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -122,10 +123,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("plesk: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("plesk: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	siteID, err := d.client.GetSite(dns01.UnFqdn(authZone))
+	ctx := context.Background()
+
+	siteID, err := d.client.GetSite(ctx, dns01.UnFqdn(authZone))
 	if err != nil {
 		return fmt.Errorf("plesk: failed to get site: %w", err)
 	}
@@ -135,7 +138,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("nodion: %w", err)
 	}
 
-	recordID, err := d.client.AddRecord(siteID, subDomain, info.Value)
+	recordID, err := d.client.AddRecord(ctx, siteID, subDomain, info.Value)
 	if err != nil {
 		return fmt.Errorf("plesk: failed to add record: %w", err)
 	}
@@ -158,7 +161,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("plesk: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	_, err := d.client.DeleteRecord(recordID)
+	_, err := d.client.DeleteRecord(context.Background(), recordID)
 	if err != nil {
 		return fmt.Errorf("plesk: failed to delete record (%d): %w", recordID, err)
 	}
diff --git a/providers/dns/porkbun/porkbun.go b/providers/dns/porkbun/porkbun.go
index 8a55b955..86435f37 100644
--- a/providers/dns/porkbun/porkbun.go
+++ b/providers/dns/porkbun/porkbun.go
@@ -171,7 +171,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 func splitDomain(fqdn string) (string, string, error) {
 	zone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return "", "", err
+		return "", "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(fqdn, zone)
diff --git a/providers/dns/porkbun/porkbun_test.go b/providers/dns/porkbun/porkbun_test.go
index 9bc86adc..cdf022b5 100644
--- a/providers/dns/porkbun/porkbun_test.go
+++ b/providers/dns/porkbun/porkbun_test.go
@@ -1,7 +1,6 @@
 package porkbun
 
 import (
-	"fmt"
 	"testing"
 
 	"github.com/go-acme/lego/v4/platform/tester"
@@ -144,7 +143,3 @@ func TestLiveCleanUp(t *testing.T) {
 	err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
 	require.NoError(t, err)
 }
-
-func TestName(t *testing.T) {
-	fmt.Println(splitDomain("_acme-challenge.example.com."))
-}
diff --git a/providers/dns/rackspace/client.go b/providers/dns/rackspace/client.go
deleted file mode 100644
index b689c1e5..00000000
--- a/providers/dns/rackspace/client.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package rackspace
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-
-	"github.com/go-acme/lego/v4/challenge/dns01"
-)
-
-// APIKeyCredentials API credential.
-type APIKeyCredentials struct {
-	Username string `json:"username"`
-	APIKey   string `json:"apiKey"`
-}
-
-// Auth auth credentials.
-type Auth struct {
-	APIKeyCredentials `json:"RAX-KSKEY:apiKeyCredentials"`
-}
-
-// AuthData Auth data.
-type AuthData struct {
-	Auth `json:"auth"`
-}
-
-// Identity Identity.
-type Identity struct {
-	Access Access `json:"access"`
-}
-
-// Access Access.
-type Access struct {
-	ServiceCatalog []ServiceCatalog `json:"serviceCatalog"`
-	Token          Token            `json:"token"`
-}
-
-// Token Token.
-type Token struct {
-	ID string `json:"id"`
-}
-
-// ServiceCatalog ServiceCatalog.
-type ServiceCatalog struct {
-	Endpoints []Endpoint `json:"endpoints"`
-	Name      string     `json:"name"`
-}
-
-// Endpoint Endpoint.
-type Endpoint struct {
-	PublicURL string `json:"publicURL"`
-	TenantID  string `json:"tenantId"`
-}
-
-// ZoneSearchResponse represents the response when querying Rackspace DNS zones.
-type ZoneSearchResponse struct {
-	TotalEntries int          `json:"totalEntries"`
-	HostedZones  []HostedZone `json:"domains"`
-}
-
-// HostedZone HostedZone.
-type HostedZone struct {
-	ID   string `json:"id"`
-	Name string `json:"name"`
-}
-
-// Records is the list of records sent/received from the DNS API.
-type Records struct {
-	Record []Record `json:"records"`
-}
-
-// Record represents a Rackspace DNS record.
-type Record struct {
-	Name string `json:"name"`
-	Type string `json:"type"`
-	Data string `json:"data"`
-	TTL  int    `json:"ttl,omitempty"`
-	ID   string `json:"id,omitempty"`
-}
-
-// getHostedZoneID performs a lookup to get the DNS zone which needs
-// modifying for a given FQDN.
-func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) {
-	authZone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return "", err
-	}
-
-	result, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/domains?name=%s", dns01.UnFqdn(authZone)), nil)
-	if err != nil {
-		return "", err
-	}
-
-	var zoneSearchResponse ZoneSearchResponse
-	err = json.Unmarshal(result, &zoneSearchResponse)
-	if err != nil {
-		return "", err
-	}
-
-	// If nothing was returned, or for whatever reason more than 1 was returned (the search uses exact match, so should not occur)
-	if zoneSearchResponse.TotalEntries != 1 {
-		return "", fmt.Errorf("found %d zones for %s in Rackspace for domain %s", zoneSearchResponse.TotalEntries, authZone, fqdn)
-	}
-
-	return zoneSearchResponse.HostedZones[0].ID, nil
-}
-
-// findTxtRecord searches a DNS zone for a TXT record with a specific name.
-func (d *DNSProvider) findTxtRecord(fqdn string, zoneID string) (*Record, error) {
-	result, err := d.makeRequest(http.MethodGet, fmt.Sprintf("/domains/%s/records?type=TXT&name=%s", zoneID, dns01.UnFqdn(fqdn)), nil)
-	if err != nil {
-		return nil, err
-	}
-
-	var records Records
-	err = json.Unmarshal(result, &records)
-	if err != nil {
-		return nil, err
-	}
-
-	switch len(records.Record) {
-	case 1:
-	case 0:
-		return nil, fmt.Errorf("no TXT record found for %s", fqdn)
-	default:
-		return nil, fmt.Errorf("more than 1 TXT record found for %s", fqdn)
-	}
-
-	return &records.Record[0], nil
-}
-
-// makeRequest is a wrapper function used for making DNS API requests.
-func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawMessage, error) {
-	url := d.cloudDNSEndpoint + uri
-
-	req, err := http.NewRequest(method, url, body)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("X-Auth-Token", d.token)
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, fmt.Errorf("error querying DNS API: %w", err)
-	}
-
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
-		return nil, fmt.Errorf("request failed for %s %s. Response code: %d", method, url, resp.StatusCode)
-	}
-
-	var r json.RawMessage
-	err = json.NewDecoder(resp.Body).Decode(&r)
-	if err != nil {
-		return nil, fmt.Errorf("JSON decode failed for %s %s. Response code: %d", method, url, resp.StatusCode)
-	}
-
-	return r, nil
-}
-
-func login(config *Config) (*Identity, error) {
-	authData := AuthData{
-		Auth: Auth{
-			APIKeyCredentials: APIKeyCredentials{
-				Username: config.APIUser,
-				APIKey:   config.APIKey,
-			},
-		},
-	}
-
-	body, err := json.Marshal(authData)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(http.MethodPost, config.BaseURL, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := config.HTTPClient.Do(req)
-	if err != nil {
-		return nil, fmt.Errorf("error querying Identity API: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("authentication failed: response code: %d", resp.StatusCode)
-	}
-
-	var identity Identity
-	err = json.NewDecoder(resp.Body).Decode(&identity)
-	if err != nil {
-		return nil, err
-	}
-
-	return &identity, nil
-}
diff --git a/providers/dns/rackspace/internal/client.go b/providers/dns/rackspace/internal/client.go
new file mode 100644
index 00000000..525556a2
--- /dev/null
+++ b/providers/dns/rackspace/internal/client.go
@@ -0,0 +1,216 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+type Client struct {
+	token string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+func NewClient(endpoint string, token string) (*Client, error) {
+	baseURL, err := url.Parse(endpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Client{
+		token:      token,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}, nil
+}
+
+// AddRecord Adds one  record to a specified domain.
+// https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#add-records
+func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) error {
+	endpoint := c.baseURL.JoinPath("domains", zoneID, "records")
+
+	records := Records{Records: []Record{record}}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, records)
+	if err != nil {
+		return err
+	}
+
+	err = c.do(req, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// DeleteRecord Deletes a record from the domain.
+// https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#delete-records
+func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
+	endpoint := c.baseURL.JoinPath("domains", zoneID, "records")
+
+	query := endpoint.Query()
+	query.Set("id", recordID)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	err = c.do(req, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// GetHostedZoneID performs a lookup to get the DNS zone which needs modifying for a given FQDN.
+func (c *Client) GetHostedZoneID(ctx context.Context, fqdn string) (string, error) {
+	authZone, err := dns01.FindZoneByFqdn(fqdn)
+	if err != nil {
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
+	}
+
+	zoneSearchResponse, err := c.listDomainsByName(ctx, dns01.UnFqdn(authZone))
+	if err != nil {
+		return "", err
+	}
+
+	// If nothing was returned, or for whatever reason more than 1 was returned (the search uses exact match, so should not occur)
+	if zoneSearchResponse.TotalEntries != 1 {
+		return "", fmt.Errorf("found %d zones for %s in Rackspace for domain %s", zoneSearchResponse.TotalEntries, authZone, fqdn)
+	}
+
+	return zoneSearchResponse.HostedZones[0].ID, nil
+}
+
+// listDomainsByName Filters domains by domain name.
+// https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/domains#list-domains-by-name
+func (c *Client) listDomainsByName(ctx context.Context, domain string) (*ZoneSearchResponse, error) {
+	endpoint := c.baseURL.JoinPath("domains")
+
+	query := endpoint.Query()
+	query.Set("name", domain)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var zoneSearchResponse ZoneSearchResponse
+	err = c.do(req, &zoneSearchResponse)
+	if err != nil {
+		return nil, err
+	}
+
+	return &zoneSearchResponse, nil
+}
+
+// FindTxtRecord searches a DNS zone for a TXT record with a specific name.
+func (c *Client) FindTxtRecord(ctx context.Context, fqdn string, zoneID string) (*Record, error) {
+	records, err := c.searchRecords(ctx, zoneID, dns01.UnFqdn(fqdn), "TXT")
+	if err != nil {
+		return nil, err
+	}
+
+	switch len(records.Records) {
+	case 1:
+	case 0:
+		return nil, fmt.Errorf("no TXT record found for %s", fqdn)
+	default:
+		return nil, fmt.Errorf("more than 1 TXT record found for %s", fqdn)
+	}
+
+	return &records.Records[0], nil
+}
+
+// https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#search-records
+func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordType string) (*Records, error) {
+	endpoint := c.baseURL.JoinPath("domains", zoneID, "records")
+
+	query := endpoint.Query()
+	query.Set("type", recordType)
+	query.Set("name", recordName)
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var records Records
+	err = c.do(req, &records)
+	if err != nil {
+		return nil, err
+	}
+
+	return &records, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	req.Header.Set("X-Auth-Token", c.token)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest[T string | *url.URL](ctx context.Context, method string, endpoint T, payload interface{}) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s", endpoint), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/rackspace/internal/client_test.go b/providers/dns/rackspace/internal/client_test.go
new file mode 100644
index 00000000..993d34d9
--- /dev/null
+++ b/providers/dns/rackspace/internal/client_test.go
@@ -0,0 +1,108 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	client, err := NewClient(server.URL, "secret")
+	require.NoError(t, err)
+
+	client.HTTPClient = server.Client()
+
+	mux.HandleFunc(pattern, handler)
+
+	return client
+}
+
+func writeFixtureHandler(method, filename string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		if req.Header.Get("X-Auth-Token") != "secret" {
+			http.Error(rw, fmt.Sprintf("invalid token: %q", req.Header.Get("X-Auth-Token")), http.StatusUnauthorized)
+			return
+		}
+
+		if filename == "" {
+			return
+		}
+
+		file, err := os.Open(filepath.Join("fixtures", filename))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		defer func() { _ = file.Close() }()
+
+		_, _ = io.Copy(rw, file)
+	}
+}
+
+func TestClient_AddRecord(t *testing.T) {
+	client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodPost, "add-records.json"))
+
+	err := client.AddRecord(context.Background(), "1234", Record{})
+	require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+	client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodDelete, ""))
+
+	err := client.DeleteRecord(context.Background(), "1234", "2725233")
+	require.NoError(t, err)
+}
+
+func TestClient_searchRecords(t *testing.T) {
+	client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodGet, "search-records.json"))
+
+	records, err := client.searchRecords(context.Background(), "1234", "2725233", "A")
+	require.NoError(t, err)
+
+	expected := &Records{
+		TotalEntries: 6,
+		Records: []Record{
+			{Name: "ftp.example.com", Type: "A", Data: "192.0.2.8", TTL: 5771, ID: "A-6817754"},
+			{Name: "example.com", Type: "A", Data: "192.0.2.17", TTL: 86400, ID: "A-6822994"},
+			{Name: "example.com", Type: "NS", Data: "ns.rackspace.com", TTL: 3600, ID: "NS-6251982"},
+			{Name: "example.com", Type: "NS", Data: "ns2.rackspace.com", TTL: 3600, ID: "NS-6251983"},
+			{Name: "example.com", Type: "MX", Data: "mail.example.com", TTL: 3600, ID: "MX-3151218"},
+			{Name: "www.example.com", Type: "CNAME", Data: "example.com", TTL: 5400, ID: "CNAME-9778009"},
+		},
+	}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_listDomainsByName(t *testing.T) {
+	client := setupTest(t, "/domains", writeFixtureHandler(http.MethodGet, "list-domains-by-name.json"))
+
+	domains, err := client.listDomainsByName(context.Background(), "1234")
+	require.NoError(t, err)
+
+	expected := &ZoneSearchResponse{
+		TotalEntries: 114,
+		HostedZones:  []HostedZone{{ID: "2725257", Name: "sub1.example.com"}},
+	}
+
+	assert.Equal(t, expected, domains)
+}
diff --git a/providers/dns/rackspace/internal/fixtures/add-records.json b/providers/dns/rackspace/internal/fixtures/add-records.json
new file mode 100644
index 00000000..18e507ff
--- /dev/null
+++ b/providers/dns/rackspace/internal/fixtures/add-records.json
@@ -0,0 +1,61 @@
+{
+  "totalEntries": 6,
+  "records": [
+    {
+      "name": "ftp.example.com",
+      "id": "A-6817754",
+      "type": "A",
+      "data": "192.0.2.8",
+      "updated": "2011-05-19T13:07:08.000+0000",
+      "ttl": 5771,
+      "created": "2011-05-18T19:53:09.000+0000"
+    },
+    {
+      "name": "example.com",
+      "id": "A-6822994",
+      "type": "A",
+      "data": "192.0.2.17",
+      "updated": "2011-06-24T01:12:52.000+0000",
+      "ttl": 86400,
+      "created": "2011-06-24T01:12:52.000+0000"
+    },
+    {
+      "name": "example.com",
+      "id": "NS-6251982",
+      "type": "NS",
+      "data": "ns.rackspace.com",
+      "updated": "2011-06-24T01:12:51.000+0000",
+      "ttl": 3600,
+      "created": "2011-06-24T01:12:51.000+0000"
+    },
+    {
+      "name": "example.com",
+      "id": "NS-6251983",
+      "type": "NS",
+      "data": "ns2.rackspace.com",
+      "updated": "2011-06-24T01:12:51.000+0000",
+      "ttl": 3600,
+      "created": "2011-06-24T01:12:51.000+0000"
+    },
+    {
+      "name": "example.com",
+      "priority": 5,
+      "id": "MX-3151218",
+      "type": "MX",
+      "data": "mail.example.com",
+      "updated": "2011-06-24T01:12:53.000+0000",
+      "ttl": 3600,
+      "created": "2011-06-24T01:12:53.000+0000"
+    },
+    {
+      "name": "www.example.com",
+      "id": "CNAME-9778009",
+      "type": "CNAME",
+      "comment": "This is a comment on the CNAME record",
+      "data": "example.com",
+      "updated": "2011-06-24T01:12:54.000+0000",
+      "ttl": 5400,
+      "created": "2011-06-24T01:12:54.000+0000"
+    }
+  ]
+}
diff --git a/providers/dns/rackspace/internal/fixtures/delete-records_error.json b/providers/dns/rackspace/internal/fixtures/delete-records_error.json
new file mode 100644
index 00000000..9fd735fb
--- /dev/null
+++ b/providers/dns/rackspace/internal/fixtures/delete-records_error.json
@@ -0,0 +1,16 @@
+{
+  "failedItems" : {
+    "faults" : [ {
+      "message" : "Object not Found.",
+      "code" : 404,
+      "details" : "Domain ID: 2720150; Record ID: 111111111"
+    }, {
+      "message" : "Object not Found.",
+      "code" : 404,
+      "details" : "Domain ID: 2720150; Record ID: 222222222"
+    } ]
+  },
+  "message" : "One or more items could not be deleted.",
+  "code" : 500,
+  "details" : "See errors list for details."
+}
diff --git a/providers/dns/rackspace/internal/fixtures/list-domains-by-name.json b/providers/dns/rackspace/internal/fixtures/list-domains-by-name.json
new file mode 100644
index 00000000..d34bd3b0
--- /dev/null
+++ b/providers/dns/rackspace/internal/fixtures/list-domains-by-name.json
@@ -0,0 +1,13 @@
+{
+  "domains": [
+    {
+      "name": "sub1.example.com",
+      "id": "2725257",
+      "comment": "1st sample subdomain",
+      "updated": "2011-06-23T03:09:34.000+0000",
+      "emailAddress": "sample@rackspace.com",
+      "created": "2011-06-23T03:09:33.000+0000"
+    }
+  ],
+  "totalEntries": 114
+}
diff --git a/providers/dns/rackspace/internal/fixtures/search-records.json b/providers/dns/rackspace/internal/fixtures/search-records.json
new file mode 100644
index 00000000..18e507ff
--- /dev/null
+++ b/providers/dns/rackspace/internal/fixtures/search-records.json
@@ -0,0 +1,61 @@
+{
+  "totalEntries": 6,
+  "records": [
+    {
+      "name": "ftp.example.com",
+      "id": "A-6817754",
+      "type": "A",
+      "data": "192.0.2.8",
+      "updated": "2011-05-19T13:07:08.000+0000",
+      "ttl": 5771,
+      "created": "2011-05-18T19:53:09.000+0000"
+    },
+    {
+      "name": "example.com",
+      "id": "A-6822994",
+      "type": "A",
+      "data": "192.0.2.17",
+      "updated": "2011-06-24T01:12:52.000+0000",
+      "ttl": 86400,
+      "created": "2011-06-24T01:12:52.000+0000"
+    },
+    {
+      "name": "example.com",
+      "id": "NS-6251982",
+      "type": "NS",
+      "data": "ns.rackspace.com",
+      "updated": "2011-06-24T01:12:51.000+0000",
+      "ttl": 3600,
+      "created": "2011-06-24T01:12:51.000+0000"
+    },
+    {
+      "name": "example.com",
+      "id": "NS-6251983",
+      "type": "NS",
+      "data": "ns2.rackspace.com",
+      "updated": "2011-06-24T01:12:51.000+0000",
+      "ttl": 3600,
+      "created": "2011-06-24T01:12:51.000+0000"
+    },
+    {
+      "name": "example.com",
+      "priority": 5,
+      "id": "MX-3151218",
+      "type": "MX",
+      "data": "mail.example.com",
+      "updated": "2011-06-24T01:12:53.000+0000",
+      "ttl": 3600,
+      "created": "2011-06-24T01:12:53.000+0000"
+    },
+    {
+      "name": "www.example.com",
+      "id": "CNAME-9778009",
+      "type": "CNAME",
+      "comment": "This is a comment on the CNAME record",
+      "data": "example.com",
+      "updated": "2011-06-24T01:12:54.000+0000",
+      "ttl": 5400,
+      "created": "2011-06-24T01:12:54.000+0000"
+    }
+  ]
+}
diff --git a/providers/dns/rackspace/internal/fixtures/tokens.json b/providers/dns/rackspace/internal/fixtures/tokens.json
new file mode 100644
index 00000000..ca44076f
--- /dev/null
+++ b/providers/dns/rackspace/internal/fixtures/tokens.json
@@ -0,0 +1,87 @@
+{
+  "access": {
+    "token": {
+      "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+      "expires": "2014-11-24T22:05:39.115Z",
+      "tenant": {
+        "id": "110011",
+        "name": "110011"
+      },
+      "RAX-AUTH:authenticatedBy": [
+        "APIKEY"
+      ]
+    },
+    "serviceCatalog": [
+      {
+        "name": "cloudDatabases",
+        "endpoints": [
+          {
+            "publicURL": "https://syd.databases.api.rackspacecloud.com/v1.0/110011",
+            "region": "SYD",
+            "tenantId": "110011"
+          },
+          {
+            "publicURL": "https://dfw.databases.api.rackspacecloud.com/v1.0/110011",
+            "region": "DFW",
+            "tenantId": "110011"
+          },
+          {
+            "publicURL": "https://ord.databases.api.rackspacecloud.com/v1.0/110011",
+            "region": "ORD",
+            "tenantId": "110011"
+          },
+          {
+            "publicURL": "https://iad.databases.api.rackspacecloud.com/v1.0/110011",
+            "region": "IAD",
+            "tenantId": "110011"
+          },
+          {
+            "publicURL": "https://hkg.databases.api.rackspacecloud.com/v1.0/110011",
+            "region": "HKG",
+            "tenantId": "110011"
+          }
+        ],
+        "type": "rax:database"
+      },
+      {
+        "name": "cloudDNS",
+        "endpoints": [
+          {
+            "publicURL": "https://dns.api.rackspacecloud.com/v1.0/110011",
+            "tenantId": "110011"
+          }
+        ],
+        "type": "rax:dns"
+      },
+      {
+        "name": "rackCDN",
+        "endpoints": [
+          {
+            "internalURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011",
+            "publicURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011",
+            "tenantId": "110011"
+          }
+        ],
+        "type": "rax:cdn"
+      }
+    ],
+    "user": {
+      "id": "123456",
+      "roles": [
+        {
+          "description": "A Role that allows a user access to keystone Service methods",
+          "id": "6",
+          "name": "compute:default",
+          "tenantId": "110011"
+        },
+        {
+          "description": "User Admin Role.",
+          "id": "3",
+          "name": "identity:user-admin"
+        }
+      ],
+      "name": "jsmith",
+      "RAX-AUTH:defaultRegion": "ORD"
+    }
+  }
+}
diff --git a/providers/dns/rackspace/internal/identity.go b/providers/dns/rackspace/internal/identity.go
new file mode 100644
index 00000000..062350df
--- /dev/null
+++ b/providers/dns/rackspace/internal/identity.go
@@ -0,0 +1,74 @@
+package internal
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// DefaultIdentityURL represents the Identity API endpoint to call.
+const DefaultIdentityURL = "https://identity.api.rackspacecloud.com/v2.0/tokens"
+
+type Identifier struct {
+	baseURL    string
+	httpClient *http.Client
+}
+
+// NewIdentifier creates a new Identifier.
+func NewIdentifier(httpClient *http.Client, baseURL string) *Identifier {
+	if httpClient == nil {
+		httpClient = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	if baseURL == "" {
+		baseURL = DefaultIdentityURL
+	}
+
+	return &Identifier{baseURL: baseURL, httpClient: httpClient}
+}
+
+// Login sends an authentication request.
+// https://docs.rackspace.com/docs/cloud-dns/v1/getting-started/authenticate
+func (a *Identifier) Login(ctx context.Context, apiUser, apiKey string) (*Identity, error) {
+	authData := AuthData{
+		Auth: Auth{
+			APIKeyCredentials: APIKeyCredentials{
+				Username: apiUser,
+				APIKey:   apiKey,
+			},
+		},
+	}
+
+	req, err := newJSONRequest(ctx, http.MethodPost, a.baseURL, authData)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := a.httpClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	var identity Identity
+	err = json.Unmarshal(raw, &identity)
+	if err != nil {
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return &identity, nil
+}
diff --git a/providers/dns/rackspace/internal/identity_test.go b/providers/dns/rackspace/internal/identity_test.go
new file mode 100644
index 00000000..9ba5abb5
--- /dev/null
+++ b/providers/dns/rackspace/internal/identity_test.go
@@ -0,0 +1,95 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func writeIdentityFixtureHandler(method, filename string) http.HandlerFunc {
+	return func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		if filename == "" {
+			return
+		}
+
+		file, err := os.Open(filepath.Join("fixtures", filename))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		defer func() { _ = file.Close() }()
+
+		_, _ = io.Copy(rw, file)
+	}
+}
+
+func TestIdentifier_Login(t *testing.T) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	identifier := NewIdentifier(server.Client(), server.URL)
+
+	mux.HandleFunc("/", writeIdentityFixtureHandler(http.MethodPost, "tokens.json"))
+
+	identity, err := identifier.Login(context.Background(), "user", "secret")
+	require.NoError(t, err)
+
+	expected := &Identity{
+		Access: Access{
+			Token: Token{
+				ID:                     "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+				Expires:                "2014-11-24T22:05:39.115Z",
+				Tenant:                 Tenant{ID: "110011", Name: "110011"},
+				RAXAUTHAuthenticatedBy: []string{"APIKEY"},
+			},
+			ServiceCatalog: []ServiceCatalog{
+				{
+					Name: "cloudDatabases",
+					Type: "rax:database",
+					Endpoints: []Endpoint{
+						{PublicURL: "https://syd.databases.api.rackspacecloud.com/v1.0/110011", Region: "SYD", TenantID: "110011", InternalURL: ""},
+						{PublicURL: "https://dfw.databases.api.rackspacecloud.com/v1.0/110011", Region: "DFW", TenantID: "110011", InternalURL: ""},
+						{PublicURL: "https://ord.databases.api.rackspacecloud.com/v1.0/110011", Region: "ORD", TenantID: "110011", InternalURL: ""},
+						{PublicURL: "https://iad.databases.api.rackspacecloud.com/v1.0/110011", Region: "IAD", TenantID: "110011", InternalURL: ""},
+						{PublicURL: "https://hkg.databases.api.rackspacecloud.com/v1.0/110011", Region: "HKG", TenantID: "110011", InternalURL: ""},
+					},
+				},
+				{
+					Name:      "cloudDNS",
+					Type:      "rax:dns",
+					Endpoints: []Endpoint{{PublicURL: "https://dns.api.rackspacecloud.com/v1.0/110011", Region: "", TenantID: "110011", InternalURL: ""}},
+				},
+				{
+					Name:      "rackCDN",
+					Type:      "rax:cdn",
+					Endpoints: []Endpoint{{PublicURL: "https://global.cdn.api.rackspacecloud.com/v1.0/110011", Region: "", TenantID: "110011", InternalURL: "https://global.cdn.api.rackspacecloud.com/v1.0/110011"}},
+				},
+			},
+			User: User{
+				ID: "123456",
+				Roles: []Role{
+					{Description: "A Role that allows a user access to keystone Service methods", ID: "6", Name: "compute:default", TenantID: "110011"},
+					{Description: "User Admin Role.", ID: "3", Name: "identity:user-admin", TenantID: ""},
+				},
+				Name:                 "jsmith",
+				RAXAUTHDefaultRegion: "ORD",
+			},
+		},
+	}
+
+	assert.Equal(t, expected, identity)
+}
diff --git a/providers/dns/rackspace/internal/types.go b/providers/dns/rackspace/internal/types.go
new file mode 100644
index 00000000..b34d3a33
--- /dev/null
+++ b/providers/dns/rackspace/internal/types.go
@@ -0,0 +1,104 @@
+package internal
+
+// Authentication response.
+
+// Identity api structure.
+type Identity struct {
+	Access Access `json:"access"`
+}
+
+// Access api structure.
+type Access struct {
+	Token          Token            `json:"token"`
+	ServiceCatalog []ServiceCatalog `json:"serviceCatalog"`
+	User           User             `json:"user"`
+}
+
+// Token api structure.
+type Token struct {
+	ID                     string   `json:"id"`
+	Expires                string   `json:"expires"`
+	Tenant                 Tenant   `json:"tenant"`
+	RAXAUTHAuthenticatedBy []string `json:"RAX-AUTH:authenticatedBy"`
+}
+
+// ServiceCatalog service catalog.
+type ServiceCatalog struct {
+	Name      string     `json:"name"`
+	Type      string     `json:"type"`
+	Endpoints []Endpoint `json:"endpoints"`
+}
+
+type Tenant struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// Endpoint api structure.
+type Endpoint struct {
+	PublicURL   string `json:"publicURL"`
+	Region      string `json:"region,omitempty"`
+	TenantID    string `json:"tenantId"`
+	InternalURL string `json:"internalURL,omitempty"`
+}
+
+type Role struct {
+	Description string `json:"description"`
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	TenantID    string `json:"tenantId,omitempty"`
+}
+
+type User struct {
+	ID                   string `json:"id"`
+	Roles                []Role `json:"roles"`
+	Name                 string `json:"name"`
+	RAXAUTHDefaultRegion string `json:"RAX-AUTH:defaultRegion"`
+}
+
+// Authentication request.
+
+// AuthData api structure.
+type AuthData struct {
+	Auth `json:"auth"`
+}
+
+// Auth api structure.
+type Auth struct {
+	APIKeyCredentials `json:"RAX-KSKEY:apiKeyCredentials"`
+}
+
+// APIKeyCredentials api structure.
+type APIKeyCredentials struct {
+	Username string `json:"username"`
+	APIKey   string `json:"apiKey"`
+}
+
+// API responses.
+
+// ZoneSearchResponse represents the response when querying Rackspace DNS zones.
+type ZoneSearchResponse struct {
+	TotalEntries int          `json:"totalEntries"`
+	HostedZones  []HostedZone `json:"domains"`
+}
+
+// HostedZone api structure.
+type HostedZone struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// Records is the list of records sent/received from the DNS API.
+type Records struct {
+	TotalEntries int      `json:"totalEntries,omitempty"`
+	Records      []Record `json:"records,omitempty"`
+}
+
+// Record represents a Rackspace DNS record.
+type Record struct {
+	Name string `json:"name"`
+	Type string `json:"type"`
+	Data string `json:"data"`
+	TTL  int    `json:"ttl,omitempty"`
+	ID   string `json:"id,omitempty"`
+}
diff --git a/providers/dns/rackspace/rackspace.go b/providers/dns/rackspace/rackspace.go
index 5b79c2a9..c877de3b 100644
--- a/providers/dns/rackspace/rackspace.go
+++ b/providers/dns/rackspace/rackspace.go
@@ -2,8 +2,7 @@
 package rackspace
 
 import (
-	"bytes"
-	"encoding/json"
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -11,11 +10,9 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/rackspace/internal"
 )
 
-// defaultBaseURL represents the Identity API endpoint to call.
-const defaultBaseURL = "https://identity.api.rackspacecloud.com/v2.0/tokens"
-
 // Environment variables names.
 const (
 	envNamespace = "RACKSPACE_"
@@ -43,7 +40,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		BaseURL:            defaultBaseURL,
+		BaseURL:            internal.DefaultIdentityURL,
 		TTL:                env.GetOrDefaultInt(EnvTTL, 300),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
@@ -55,7 +52,9 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config           *Config
+	config *Config
+	client *internal.Client
+
 	token            string
 	cloudDNSEndpoint string
 }
@@ -87,7 +86,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("rackspace: credentials missing")
 	}
 
-	identity, err := login(config)
+	identifier := internal.NewIdentifier(config.HTTPClient, config.BaseURL)
+
+	identity, err := identifier.Login(context.Background(), config.APIUser, config.APIKey)
 	if err != nil {
 		return nil, fmt.Errorf("rackspace: %w", err)
 	}
@@ -105,8 +106,18 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("rackspace: failed to populate DNS endpoint, check Rackspace API for changes")
 	}
 
+	client, err := internal.NewClient(dnsEndpoint, identity.Access.Token.ID)
+	if err != nil {
+		return nil, fmt.Errorf("rackspace: %w", err)
+	}
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
 	return &DNSProvider{
 		config:           config,
+		client:           client,
 		token:            identity.Access.Token.ID,
 		cloudDNSEndpoint: dnsEndpoint,
 	}, nil
@@ -116,29 +127,25 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zoneID, err := d.getHostedZoneID(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zoneID, err := d.client.GetHostedZoneID(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("rackspace: %w", err)
 	}
 
-	rec := Records{
-		Record: []Record{{
-			Name: dns01.UnFqdn(info.EffectiveFQDN),
-			Type: "TXT",
-			Data: info.Value,
-			TTL:  d.config.TTL,
-		}},
+	record := internal.Record{
+		Name: dns01.UnFqdn(info.EffectiveFQDN),
+		Type: "TXT",
+		Data: info.Value,
+		TTL:  d.config.TTL,
 	}
 
-	body, err := json.Marshal(rec)
+	err = d.client.AddRecord(ctx, zoneID, record)
 	if err != nil {
 		return fmt.Errorf("rackspace: %w", err)
 	}
 
-	_, err = d.makeRequest(http.MethodPost, fmt.Sprintf("/domains/%s/records", zoneID), bytes.NewReader(body))
-	if err != nil {
-		return fmt.Errorf("rackspace: %w", err)
-	}
 	return nil
 }
 
@@ -146,20 +153,23 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zoneID, err := d.getHostedZoneID(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zoneID, err := d.client.GetHostedZoneID(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("rackspace: %w", err)
 	}
 
-	record, err := d.findTxtRecord(info.EffectiveFQDN, zoneID)
+	record, err := d.client.FindTxtRecord(ctx, info.EffectiveFQDN, zoneID)
 	if err != nil {
 		return fmt.Errorf("rackspace: %w", err)
 	}
 
-	_, err = d.makeRequest(http.MethodDelete, fmt.Sprintf("/domains/%s/records?id=%s", zoneID, record.ID), nil)
+	err = d.client.DeleteRecord(ctx, zoneID, record.ID)
 	if err != nil {
 		return fmt.Errorf("rackspace: %w", err)
 	}
+
 	return nil
 }
 
diff --git a/providers/dns/rackspace/rackspace_test.go b/providers/dns/rackspace/rackspace_test.go
index 8cea7c44..1e120e09 100644
--- a/providers/dns/rackspace/rackspace_test.go
+++ b/providers/dns/rackspace/rackspace_test.go
@@ -1,6 +1,7 @@
 package rackspace
 
 import (
+	"bytes"
 	"fmt"
 	"io"
 	"net/http"
@@ -124,14 +125,14 @@ func identityHandler(dnsEndpoint string) http.Handler {
 			return
 		}
 
-		if string(reqBody) != `{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}` {
-			w.WriteHeader(http.StatusBadRequest)
+		if string(bytes.TrimSpace(reqBody)) != `{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}` {
+			http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest)
 			return
 		}
 
 		resp := strings.Replace(identityResponseMock, "https://dns.api.rackspacecloud.com/v1.0/123456", dnsEndpoint, 1)
 		w.WriteHeader(http.StatusOK)
-		fmt.Fprint(w, resp)
+		_, _ = fmt.Fprint(w, resp)
 	})
 }
 
@@ -142,7 +143,7 @@ func dnsHandler() *http.ServeMux {
 	mux.HandleFunc("/123456/domains", func(w http.ResponseWriter, r *http.Request) {
 		if r.URL.Query().Get("name") == "example.com" {
 			w.WriteHeader(http.StatusOK)
-			fmt.Fprint(w, zoneDetailsMock)
+			_, _ = fmt.Fprint(w, zoneDetailsMock)
 			return
 		}
 		w.WriteHeader(http.StatusBadRequest)
@@ -158,27 +159,30 @@ func dnsHandler() *http.ServeMux {
 				return
 			}
 
-			if string(reqBody) != `{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}` {
-				w.WriteHeader(http.StatusBadRequest)
+			if string(bytes.TrimSpace(reqBody)) != `{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}` {
+				http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest)
 				return
 			}
 
 			w.WriteHeader(http.StatusAccepted)
-			fmt.Fprint(w, recordResponseMock)
-			// Used by `findTxtRecord()` finding `record.ID` "?type=TXT&name=_acme-challenge.example.com"
+			_, _ = fmt.Fprint(w, recordResponseMock)
+
+		// Used by `findTxtRecord()` finding `record.ID` "?type=TXT&name=_acme-challenge.example.com"
 		case http.MethodGet:
 			if r.URL.Query().Get("type") == "TXT" && r.URL.Query().Get("name") == "_acme-challenge.example.com" {
 				w.WriteHeader(http.StatusOK)
-				fmt.Fprint(w, recordDetailsMock)
+				_, _ = fmt.Fprint(w, recordDetailsMock)
 				return
 			}
+
 			w.WriteHeader(http.StatusBadRequest)
 			return
-			// Used by `CleanUp()` deleting the TXT record "?id=445566"
+
+		// Used by `CleanUp()` deleting the TXT record "?id=445566"
 		case http.MethodDelete:
 			if r.URL.Query().Get("id") == "TXT-654321" {
 				w.WriteHeader(http.StatusOK)
-				fmt.Fprint(w, recordDeleteMock)
+				_, _ = fmt.Fprint(w, recordDeleteMock)
 				return
 			}
 			w.WriteHeader(http.StatusBadRequest)
@@ -186,8 +190,7 @@ func dnsHandler() *http.ServeMux {
 	})
 
 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusNotFound)
-		fmt.Printf("Not Found for Request: (%+v)\n\n", r)
+		http.Error(w, fmt.Sprintf("Not Found for Request: (%+v)", r), http.StatusNotFound)
 	})
 
 	return mux
diff --git a/providers/dns/regru/internal/client.go b/providers/dns/regru/internal/client.go
index cac3a815..b4b81dc0 100644
--- a/providers/dns/regru/internal/client.go
+++ b/providers/dns/regru/internal/client.go
@@ -1,11 +1,15 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://api.reg.ru/api/regru2/"
@@ -15,36 +19,36 @@ type Client struct {
 	username string
 	password string
 
-	BaseURL    string
+	baseURL    *url.URL
 	HTTPClient *http.Client
 }
 
 // NewClient Creates a reg.ru client.
 func NewClient(username, password string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
 		username:   username,
 		password:   password,
-		BaseURL:    defaultBaseURL,
-		HTTPClient: http.DefaultClient,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // RemoveTxtRecord removes a TXT record.
 // https://www.reg.ru/support/help/api2#zone_remove_record
-func (c Client) RemoveTxtRecord(domain, subDomain, content string) error {
+func (c Client) RemoveTxtRecord(ctx context.Context, domain, subDomain, content string) error {
 	request := RemoveRecordRequest{
-		Username: c.username,
-		Password: c.password,
-		Domains: []Domain{
-			{DName: domain},
-		},
+		Username:          c.username,
+		Password:          c.password,
+		Domains:           []Domain{{DName: domain}},
 		SubDomain:         subDomain,
 		Content:           content,
 		RecordType:        "TXT",
 		OutputContentType: "plain",
 	}
 
-	resp, err := c.do(request, "zone", "remove_record")
+	resp, err := c.doRequest(ctx, request, "zone", "remove_record")
 	if err != nil {
 		return err
 	}
@@ -54,19 +58,17 @@ func (c Client) RemoveTxtRecord(domain, subDomain, content string) error {
 
 // AddTXTRecord adds a TXT record.
 // https://www.reg.ru/support/help/api2#zone_add_txt
-func (c Client) AddTXTRecord(domain, subDomain, content string) error {
+func (c Client) AddTXTRecord(ctx context.Context, domain, subDomain, content string) error {
 	request := AddTxtRequest{
-		Username: c.username,
-		Password: c.password,
-		Domains: []Domain{
-			{DName: domain},
-		},
+		Username:          c.username,
+		Password:          c.password,
+		Domains:           []Domain{{DName: domain}},
 		SubDomain:         subDomain,
 		Text:              content,
 		OutputContentType: "plain",
 	}
 
-	resp, err := c.do(request, "zone", "add_txt")
+	resp, err := c.doRequest(ctx, request, "zone", "add_txt")
 	if err != nil {
 		return err
 	}
@@ -74,15 +76,12 @@ func (c Client) AddTXTRecord(domain, subDomain, content string) error {
 	return resp.HasError()
 }
 
-func (c Client) do(request interface{}, fragments ...string) (*APIResponse, error) {
-	endpoint, err := c.createEndpoint(fragments...)
-	if err != nil {
-		return nil, err
-	}
+func (c Client) doRequest(ctx context.Context, request any, fragments ...string) (*APIResponse, error) {
+	endpoint := c.baseURL.JoinPath(fragments...)
 
 	inputData, err := json.Marshal(request)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("failed to create input data: %w", err)
 	}
 
 	query := endpoint.Query()
@@ -90,47 +89,44 @@ func (c Client) do(request interface{}, fragments ...string) (*APIResponse, erro
 	query.Add("input_format", "json")
 	endpoint.RawQuery = query.Encode()
 
-	resp, err := http.Get(endpoint.String())
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode/100 != 2 {
-		all, errB := io.ReadAll(resp.Body)
-		if errB != nil {
-			return nil, fmt.Errorf("API error, status code: %d", resp.StatusCode)
-		}
-
-		var apiResp APIResponse
-		errB = json.Unmarshal(all, &apiResp)
-		if errB != nil {
-			return nil, fmt.Errorf("API error, status code: %d, %s", resp.StatusCode, string(all))
-		}
-
-		return nil, fmt.Errorf("%w, status code: %d", apiResp, resp.StatusCode)
+		return nil, parseError(req, resp)
 	}
 
-	all, err := io.ReadAll(resp.Body)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	var apiResp APIResponse
-	err = json.Unmarshal(all, &apiResp)
+	err = json.Unmarshal(raw, &apiResp)
 	if err != nil {
-		return nil, err
+		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return &apiResp, nil
 }
 
-func (c Client) createEndpoint(fragments ...string) (*url.URL, error) {
-	baseURL, err := url.Parse(c.BaseURL)
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errAPI APIResponse
+	err := json.Unmarshal(raw, &errAPI)
 	if err != nil {
-		return nil, err
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 	}
 
-	return baseURL.JoinPath(fragments...), nil
+	return fmt.Errorf("status code: %d, %w", resp.StatusCode, errAPI)
 }
diff --git a/providers/dns/regru/internal/client_test.go b/providers/dns/regru/internal/client_test.go
index 0b7e7771..a599a2c3 100644
--- a/providers/dns/regru/internal/client_test.go
+++ b/providers/dns/regru/internal/client_test.go
@@ -1,7 +1,9 @@
 package internal
 
 import (
+	"context"
 	"net/http"
+	"net/url"
 	"os"
 	"testing"
 	"time"
@@ -22,7 +24,7 @@ func TestRemoveRecord(t *testing.T) {
 	client := NewClient(officialTestUser, officialTestPassword)
 	client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
 
-	err := client.RemoveTxtRecord("test.ru", "_acme-challenge", "txttxttxt")
+	err := client.RemoveTxtRecord(context.Background(), "test.ru", "_acme-challenge", "txttxttxt")
 	require.NoError(t, err)
 }
 
@@ -65,9 +67,9 @@ func TestRemoveRecord_errors(t *testing.T) {
 
 			client := NewClient(test.username, test.username)
 			client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
-			client.BaseURL = test.baseURL
+			client.baseURL, _ = url.Parse(test.baseURL)
 
-			err := client.RemoveTxtRecord(test.domain, "_acme-challenge", "txttxttxt")
+			err := client.RemoveTxtRecord(context.Background(), test.domain, "_acme-challenge", "txttxttxt")
 			require.EqualError(t, err, test.expected)
 		})
 	}
@@ -80,7 +82,7 @@ func TestAddTXTRecord(t *testing.T) {
 	client := NewClient(officialTestUser, officialTestPassword)
 	client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
 
-	err := client.AddTXTRecord("test.ru", "_acme-challenge", "txttxttxt")
+	err := client.AddTXTRecord(context.Background(), "test.ru", "_acme-challenge", "txttxttxt")
 	require.NoError(t, err)
 }
 
@@ -123,9 +125,9 @@ func TestAddTXTRecord_errors(t *testing.T) {
 
 			client := NewClient(test.username, test.username)
 			client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
-			client.BaseURL = test.baseURL
+			client.baseURL, _ = url.Parse(test.baseURL)
 
-			err := client.AddTXTRecord(test.domain, "_acme-challenge", "txttxttxt")
+			err := client.AddTXTRecord(context.Background(), test.domain, "_acme-challenge", "txttxttxt")
 			require.EqualError(t, err, test.expected)
 		})
 	}
diff --git a/providers/dns/regru/internal/model.go b/providers/dns/regru/internal/types.go
similarity index 100%
rename from providers/dns/regru/internal/model.go
rename to providers/dns/regru/internal/types.go
diff --git a/providers/dns/regru/regru.go b/providers/dns/regru/regru.go
index 5c5e5c95..b9ab272f 100644
--- a/providers/dns/regru/regru.go
+++ b/providers/dns/regru/regru.go
@@ -2,6 +2,7 @@
 package regru
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -101,7 +102,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("regru: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("regru: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -109,7 +110,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("regru: %w", err)
 	}
 
-	err = d.client.AddTXTRecord(dns01.UnFqdn(authZone), subDomain, info.Value)
+	err = d.client.AddTXTRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value)
 	if err != nil {
 		return fmt.Errorf("regru: failed to create TXT records [domain: %s, sub domain: %s]: %w",
 			dns01.UnFqdn(authZone), subDomain, err)
@@ -124,7 +125,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("regru: could not find zone for domain %q and fqdn %q : %w", domain, info.EffectiveFQDN, err)
+		return fmt.Errorf("regru: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -132,7 +133,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("regru: %w", err)
 	}
 
-	err = d.client.RemoveTxtRecord(dns01.UnFqdn(authZone), subDomain, info.Value)
+	err = d.client.RemoveTxtRecord(context.Background(), dns01.UnFqdn(authZone), subDomain, info.Value)
 	if err != nil {
 		return fmt.Errorf("regru: failed to remove TXT records [domain: %s, sub domain: %s]: %w",
 			dns01.UnFqdn(authZone), subDomain, err)
diff --git a/providers/dns/rfc2136/rfc2136.go b/providers/dns/rfc2136/rfc2136.go
index 981302e1..bcff990d 100644
--- a/providers/dns/rfc2136/rfc2136.go
+++ b/providers/dns/rfc2136/rfc2136.go
@@ -179,7 +179,7 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
 	c.SingleInflight = true
 
 	// TSIG authentication / msg signing
-	if len(d.config.TSIGKey) > 0 && len(d.config.TSIGSecret) > 0 {
+	if d.config.TSIGKey != "" && d.config.TSIGSecret != "" {
 		key := strings.ToLower(dns.Fqdn(d.config.TSIGKey))
 		alg := dns.Fqdn(d.config.TSIGAlgorithm)
 		m.SetTsig(key, alg, 300, time.Now().Unix())
diff --git a/providers/dns/rimuhosting/rimuhosting.go b/providers/dns/rimuhosting/rimuhosting.go
index 52c6f769..09b31d4f 100644
--- a/providers/dns/rimuhosting/rimuhosting.go
+++ b/providers/dns/rimuhosting/rimuhosting.go
@@ -2,6 +2,7 @@
 package rimuhosting
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -96,20 +97,22 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	records, err := d.client.FindTXTRecords(dns01.UnFqdn(info.EffectiveFQDN))
+	ctx := context.Background()
+
+	records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN))
 	if err != nil {
 		return fmt.Errorf("rimuhosting: failed to find record(s) for %s: %w", domain, err)
 	}
 
 	actions := []rimuhosting.ActionParameter{
-		rimuhosting.AddRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL),
+		rimuhosting.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL),
 	}
 
 	for _, record := range records {
-		actions = append(actions, rimuhosting.AddRecord(record.Name, record.Content, d.config.TTL))
+		actions = append(actions, rimuhosting.NewAddRecordAction(record.Name, record.Content, d.config.TTL))
 	}
 
-	_, err = d.client.DoActions(actions...)
+	_, err = d.client.DoActions(ctx, actions...)
 	if err != nil {
 		return fmt.Errorf("rimuhosting: failed to add record(s) for %s: %w", domain, err)
 	}
@@ -121,9 +124,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	action := rimuhosting.DeleteRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+	action := rimuhosting.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
 
-	_, err := d.client.DoActions(action)
+	_, err := d.client.DoActions(context.Background(), action)
 	if err != nil {
 		return fmt.Errorf("rimuhosting: failed to delete record for %s: %w", domain, err)
 	}
diff --git a/providers/dns/route53/route53.go b/providers/dns/route53/route53.go
index 9ddeece2..df504cbd 100644
--- a/providers/dns/route53/route53.go
+++ b/providers/dns/route53/route53.go
@@ -278,7 +278,7 @@ func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) {
 
 	authZone, err := dns01.FindZoneByFqdn(fqdn)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", fqdn, err)
 	}
 
 	// .DNSName should not have a trailing dot
diff --git a/providers/dns/safedns/internal/client.go b/providers/dns/safedns/internal/client.go
index af416ece..254ec097 100644
--- a/providers/dns/safedns/internal/client.go
+++ b/providers/dns/safedns/internal/client.go
@@ -2,8 +2,8 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -12,10 +12,13 @@ import (
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://api.ukfast.io/safedns/v1"
 
+const authorizationHeader = "Authorization"
+
 // Client the UKFast SafeDNS client.
 type Client struct {
 	authToken string
@@ -27,6 +30,7 @@ type Client struct {
 // NewClient Creates a new Client.
 func NewClient(authToken string) *Client {
 	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
 		authToken:  authToken,
 		baseURL:    baseURL,
@@ -35,93 +39,103 @@ func NewClient(authToken string) *Client {
 }
 
 // AddRecord adds a DNS record.
-func (c *Client) AddRecord(zone string, record Record) (*AddRecordResponse, error) {
-	body, err := json.Marshal(record)
-	if err != nil {
-		return nil, err
-	}
-
+func (c *Client) AddRecord(ctx context.Context, zone string, record Record) (*AddRecordResponse, error) {
 	endpoint := c.baseURL.JoinPath("zones", dns01.UnFqdn(zone), "records")
 
-	req, err := c.newRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return nil, err
 	}
 
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return nil, readError(req, resp)
-	}
-
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.New(toUnreadableBodyMessage(req, content))
-	}
-
 	respData := &AddRecordResponse{}
-	err = json.Unmarshal(content, respData)
+	err = c.do(req, respData)
 	if err != nil {
-		return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(req, content))
+		return nil, fmt.Errorf("remove record: %w", err)
 	}
 
 	return respData, nil
 }
 
 // RemoveRecord removes a DNS record.
-func (c *Client) RemoveRecord(zone string, recordID int) error {
+func (c *Client) RemoveRecord(ctx context.Context, zone string, recordID int) error {
 	endpoint := c.baseURL.JoinPath("zones", dns01.UnFqdn(zone), "records", strconv.Itoa(recordID))
 
-	req, err := c.newRequest(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
 
-	resp, err := c.HTTPClient.Do(req)
+	err = c.do(req, nil)
 	if err != nil {
-		return err
-	}
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return readError(req, resp)
+		return fmt.Errorf("remove record: %w", err)
 	}
 
 	return nil
 }
 
-func (c *Client) newRequest(method, endpoint string, body io.Reader) (*http.Request, error) {
-	req, err := http.NewRequest(method, endpoint, body)
+func (c *Client) do(req *http.Request, result any) error {
+	req.Header.Set(authorizationHeader, c.authToken)
+
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, err
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
 
-	req.Header.Set("Content-Type", "application/json")
 	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Authorization", c.authToken)
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
 
 	return req, nil
 }
 
-func readError(req *http.Request, resp *http.Response) error {
-	content, err := io.ReadAll(resp.Body)
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errAPI APIError
+	err := json.Unmarshal(raw, &errAPI)
 	if err != nil {
-		return errors.New(toUnreadableBodyMessage(req, content))
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 	}
 
-	var errInfo APIError
-	err = json.Unmarshal(content, &errInfo)
-	if err != nil {
-		return fmt.Errorf("unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content))
-	}
-
-	return errInfo
-}
-
-func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
-	return fmt.Sprintf("the request %s received a response with an invalid format: %q", req.URL, string(rawBody))
+	return fmt.Errorf("[status code: %d] %w", resp.StatusCode, errAPI)
 }
diff --git a/providers/dns/safedns/internal/client_test.go b/providers/dns/safedns/internal/client_test.go
index c6c493d7..6709277c 100644
--- a/providers/dns/safedns/internal/client_test.go
+++ b/providers/dns/safedns/internal/client_test.go
@@ -1,11 +1,13 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strings"
 	"testing"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
@@ -35,7 +37,7 @@ func TestClient_AddRecord(t *testing.T) {
 			return
 		}
 
-		if req.Header.Get("Authorization") != "secret" {
+		if req.Header.Get(authorizationHeader) != "secret" {
 			http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
 			return
 		}
@@ -47,7 +49,7 @@ func TestClient_AddRecord(t *testing.T) {
 		}
 
 		expectedReqBody := `{"name":"_acme-challenge.example.com","type":"TXT","content":"\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"","ttl":120}`
-		if string(reqBody) != expectedReqBody {
+		if strings.TrimSpace(string(reqBody)) != expectedReqBody {
 			http.Error(rw, `{"message":"invalid request"}`, http.StatusBadRequest)
 			return
 		}
@@ -76,7 +78,7 @@ func TestClient_AddRecord(t *testing.T) {
 		TTL:     dns01.DefaultTTL,
 	}
 
-	response, err := client.AddRecord("example.com", record)
+	response, err := client.AddRecord(context.Background(), "example.com", record)
 	require.NoError(t, err)
 
 	expected := &AddRecordResponse{
@@ -104,7 +106,7 @@ func TestClient_RemoveRecord(t *testing.T) {
 			return
 		}
 
-		if req.Header.Get("Authorization") != "secret" {
+		if req.Header.Get(authorizationHeader) != "secret" {
 			http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
 			return
 		}
@@ -112,6 +114,6 @@ func TestClient_RemoveRecord(t *testing.T) {
 		rw.WriteHeader(http.StatusNoContent)
 	})
 
-	err := client.RemoveRecord("example.com", 1234567)
+	err := client.RemoveRecord(context.Background(), "example.com", 1234567)
 	require.NoError(t, err)
 }
diff --git a/providers/dns/safedns/safedns.go b/providers/dns/safedns/safedns.go
index 9cb63ad7..8285f3a0 100644
--- a/providers/dns/safedns/safedns.go
+++ b/providers/dns/safedns/safedns.go
@@ -2,6 +2,7 @@
 package safedns
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -104,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(info.EffectiveFQDN))
 	if err != nil {
-		return fmt.Errorf("safedns: could not determine zone for domain: %q: %w", info.EffectiveFQDN, err)
+		return fmt.Errorf("safedns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	record := internal.Record{
@@ -114,7 +115,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:     d.config.TTL,
 	}
 
-	resp, err := d.client.AddRecord(zone, record)
+	resp, err := d.client.AddRecord(context.Background(), zone, record)
 	if err != nil {
 		return fmt.Errorf("safedns: %w", err)
 	}
@@ -132,7 +133,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("safedns: %w", err)
+		return fmt.Errorf("safedns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	d.recordIDsMu.Lock()
@@ -142,7 +143,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("safedns: unknown record ID for '%s'", info.EffectiveFQDN)
 	}
 
-	err = d.client.RemoveRecord(authZone, recordID)
+	err = d.client.RemoveRecord(context.Background(), authZone, recordID)
 	if err != nil {
 		return fmt.Errorf("safedns: %w", err)
 	}
diff --git a/providers/dns/sakuracloud/client.go b/providers/dns/sakuracloud/wrapper.go
similarity index 96%
rename from providers/dns/sakuracloud/client.go
rename to providers/dns/sakuracloud/wrapper.go
index ed124dd6..2bf8ac9f 100644
--- a/providers/dns/sakuracloud/client.go
+++ b/providers/dns/sakuracloud/wrapper.go
@@ -82,7 +82,7 @@ func (d *DNSProvider) cleanupTXTRecord(fqdn, value string) error {
 func (d *DNSProvider) getHostedZone(domain string) (*iaas.DNS, error) {
 	authZone, err := dns01.FindZoneByFqdn(domain)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
 	}
 
 	zoneName := dns01.UnFqdn(authZone)
diff --git a/providers/dns/sakuracloud/client_test.go b/providers/dns/sakuracloud/wrapper_test.go
similarity index 100%
rename from providers/dns/sakuracloud/client_test.go
rename to providers/dns/sakuracloud/wrapper_test.go
diff --git a/providers/dns/selectel/selectel.go b/providers/dns/selectel/selectel.go
index e9378366..933115c7 100644
--- a/providers/dns/selectel/selectel.go
+++ b/providers/dns/selectel/selectel.go
@@ -4,9 +4,11 @@
 package selectel
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
@@ -87,8 +89,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	}
 
 	client := selectel.NewClient(config.Token)
-	client.BaseURL = config.BaseURL
-	client.HTTPClient = config.HTTPClient
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	var err error
+	client.BaseURL, err = url.Parse(config.BaseURL)
+	if err != nil {
+		return nil, fmt.Errorf("selectel: %w", err)
+	}
 
 	return &DNSProvider{config: config, client: client}, nil
 }
@@ -103,8 +112,10 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
+	ctx := context.Background()
+
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	domainObj, err := d.client.GetDomainByName(domain)
+	domainObj, err := d.client.GetDomainByName(ctx, domain)
 	if err != nil {
 		return fmt.Errorf("selectel: %w", err)
 	}
@@ -115,7 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Name:    info.EffectiveFQDN,
 		Content: info.Value,
 	}
-	_, err = d.client.AddRecord(domainObj.ID, txtRecord)
+	_, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord)
 	if err != nil {
 		return fmt.Errorf("selectel: %w", err)
 	}
@@ -129,13 +140,15 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	recordName := dns01.UnFqdn(info.EffectiveFQDN)
 
+	ctx := context.Background()
+
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	domainObj, err := d.client.GetDomainByName(domain)
+	domainObj, err := d.client.GetDomainByName(ctx, domain)
 	if err != nil {
 		return fmt.Errorf("selectel: %w", err)
 	}
 
-	records, err := d.client.ListRecords(domainObj.ID)
+	records, err := d.client.ListRecords(ctx, domainObj.ID)
 	if err != nil {
 		return fmt.Errorf("selectel: %w", err)
 	}
@@ -144,7 +157,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	var lastErr error
 	for _, record := range records {
 		if record.Name == recordName {
-			err = d.client.DeleteRecord(domainObj.ID, record.ID)
+			err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID)
 			if err != nil {
 				lastErr = fmt.Errorf("selectel: %w", err)
 			}
diff --git a/providers/dns/servercow/internal/client.go b/providers/dns/servercow/internal/client.go
index 5230ebe2..8f03d9a9 100644
--- a/providers/dns/servercow/internal/client.go
+++ b/providers/dns/servercow/internal/client.go
@@ -2,59 +2,52 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const baseAPIURL = "https://api.servercow.de/dns/v1/domains"
 
 // Client the Servercow client.
 type Client struct {
-	BaseURL    string
-	HTTPClient *http.Client
-
 	username string
 	password string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient Creates a Servercow client.
 func NewClient(username, password string) *Client {
+	baseURL, _ := url.Parse(baseAPIURL)
+
 	return &Client{
-		HTTPClient: http.DefaultClient,
-		BaseURL:    baseAPIURL,
 		username:   username,
 		password:   password,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
 	}
 }
 
 // GetRecords from API.
-func (c *Client) GetRecords(domain string) ([]Record, error) {
-	req, err := c.createRequest(http.MethodGet, domain, nil)
+func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) {
+	endpoint := c.baseURL.JoinPath(domain)
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer func() { _ = resp.Body.Close() }()
-
-	// Note the API always return 200 even if the authentication failed.
-	if resp.StatusCode/100 != 2 {
-		return nil, fmt.Errorf("error: status code %d", resp.StatusCode)
-	}
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read body: %w", err)
-	}
-
 	var records []Record
-	err = unmarshal(raw, &records)
+	err = c.do(req, &records)
 	if err != nil {
 		return nil, err
 	}
@@ -63,30 +56,16 @@ func (c *Client) GetRecords(domain string) ([]Record, error) {
 }
 
 // CreateUpdateRecord creates or updates a record.
-func (c *Client) CreateUpdateRecord(domain string, data Record) (*Message, error) {
-	req, err := c.createRequest(http.MethodPost, domain, &data)
+func (c *Client) CreateUpdateRecord(ctx context.Context, domain string, data Record) (*Message, error) {
+	endpoint := c.baseURL.JoinPath(domain)
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, data)
 	if err != nil {
 		return nil, err
 	}
 
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer func() { _ = resp.Body.Close() }()
-
-	// Note the API always return 200 even if the authentication failed.
-	if resp.StatusCode/100 != 2 {
-		return nil, fmt.Errorf("error: status code %d", resp.StatusCode)
-	}
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read body: %w", err)
-	}
-
 	var msg Message
-	err = json.Unmarshal(raw, &msg)
+	err = c.do(req, &msg)
 	if err != nil {
 		return nil, err
 	}
@@ -99,33 +78,18 @@ func (c *Client) CreateUpdateRecord(domain string, data Record) (*Message, error
 }
 
 // DeleteRecord deletes a record.
-func (c *Client) DeleteRecord(domain string, data Record) (*Message, error) {
-	req, err := c.createRequest(http.MethodDelete, domain, &data)
+func (c *Client) DeleteRecord(ctx context.Context, domain string, data Record) (*Message, error) {
+	endpoint := c.baseURL.JoinPath(domain)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, data)
 	if err != nil {
 		return nil, err
 	}
 
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer func() { _ = resp.Body.Close() }()
-
-	// Note the API always return 200 even if the authentication failed.
-	if resp.StatusCode/100 != 2 {
-		return nil, fmt.Errorf("error: status code %d", resp.StatusCode)
-	}
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read body: %w", err)
-	}
-
 	var msg Message
-	err = json.Unmarshal(raw, &msg)
+	err = c.do(req, &msg)
 	if err != nil {
-		//nolint:errorlint // in this context msg is not an error, and we just get the type.
-		return nil, fmt.Errorf("unmarshaling %T error: %w: %s", msg, err, string(raw))
+		return nil, err
 	}
 
 	if msg.ErrorMsg != "" {
@@ -135,40 +99,80 @@ func (c *Client) DeleteRecord(domain string, data Record) (*Message, error) {
 	return &msg, nil
 }
 
-func (c *Client) createRequest(method, domain string, payload *Record) (*http.Request, error) {
-	body, err := json.Marshal(payload)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(method, c.BaseURL+"/"+domain, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
+func (c *Client) do(req *http.Request, result any) error {
 	req.Header.Set("X-Auth-Username", c.username)
 	req.Header.Set("X-Auth-Password", c.password)
-	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	// Note the API always return 200 even if the authentication failed.
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = unmarshal(raw, result)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
 
 	return req, nil
 }
 
-func unmarshal(raw []byte, v interface{}) error {
+func unmarshal(raw []byte, v any) error {
 	err := json.Unmarshal(raw, v)
 	if err == nil {
 		return nil
 	}
 
-	var e *json.UnmarshalTypeError
-	if errors.As(err, &e) {
-		var apiError Message
-		errU := json.Unmarshal(raw, &apiError)
-		if errU != nil {
-			return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
-		}
+	var utErr *json.UnmarshalTypeError
 
-		return apiError
+	if !errors.As(err, &utErr) {
+		return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
 	}
 
-	return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
+	var apiErr Message
+	errU := json.Unmarshal(raw, &apiErr)
+	if errU != nil {
+		return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
+	}
+
+	return apiErr
 }
diff --git a/providers/dns/servercow/internal/client_test.go b/providers/dns/servercow/internal/client_test.go
index 1c880973..8597d7e1 100644
--- a/providers/dns/servercow/internal/client_test.go
+++ b/providers/dns/servercow/internal/client_test.go
@@ -1,10 +1,12 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -20,7 +22,8 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Cleanup(server.Close)
 
 	client := NewClient("", "")
-	client.BaseURL = server.URL
+	client.HTTPClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL)
 
 	return client, mux
 }
@@ -48,7 +51,7 @@ func TestClient_GetRecords(t *testing.T) {
 		}
 	})
 
-	records, err := client.GetRecords("lego.wtf")
+	records, err := client.GetRecords(context.Background(), "lego.wtf")
 	require.NoError(t, err)
 
 	recordsJSON, err := json.Marshal(records)
@@ -76,7 +79,7 @@ func TestClient_GetRecords_error(t *testing.T) {
 		}
 	})
 
-	records, err := client.GetRecords("lego.wtf")
+	records, err := client.GetRecords(context.Background(), "lego.wtf")
 	require.Error(t, err)
 
 	assert.Nil(t, records)
@@ -118,7 +121,7 @@ func TestClient_CreateUpdateRecord(t *testing.T) {
 		Content: Value{"aaa", "bbb"},
 	}
 
-	msg, err := client.CreateUpdateRecord("lego.wtf", record)
+	msg, err := client.CreateUpdateRecord(context.Background(), "lego.wtf", record)
 	require.NoError(t, err)
 
 	expected := &Message{Message: "ok"}
@@ -145,7 +148,7 @@ func TestClient_CreateUpdateRecord_error(t *testing.T) {
 		Name: "_acme-challenge.www",
 	}
 
-	msg, err := client.CreateUpdateRecord("lego.wtf", record)
+	msg, err := client.CreateUpdateRecord(context.Background(), "lego.wtf", record)
 	require.Error(t, err)
 
 	assert.Nil(t, msg)
@@ -185,7 +188,7 @@ func TestClient_DeleteRecord(t *testing.T) {
 		Type: "TXT",
 	}
 
-	msg, err := client.DeleteRecord("lego.wtf", record)
+	msg, err := client.DeleteRecord(context.Background(), "lego.wtf", record)
 	require.NoError(t, err)
 
 	expected := &Message{Message: "ok"}
@@ -212,7 +215,7 @@ func TestClient_DeleteRecord_error(t *testing.T) {
 		Name: "_acme-challenge.www",
 	}
 
-	msg, err := client.DeleteRecord("lego.wtf", record)
+	msg, err := client.DeleteRecord(context.Background(), "lego.wtf", record)
 	require.Error(t, err)
 
 	assert.Nil(t, msg)
diff --git a/providers/dns/servercow/internal/model.go b/providers/dns/servercow/internal/types.go
similarity index 100%
rename from providers/dns/servercow/internal/model.go
rename to providers/dns/servercow/internal/types.go
diff --git a/providers/dns/servercow/internal/model_test.go b/providers/dns/servercow/internal/types_test.go
similarity index 100%
rename from providers/dns/servercow/internal/model_test.go
rename to providers/dns/servercow/internal/types_test.go
diff --git a/providers/dns/servercow/servercow.go b/providers/dns/servercow/servercow.go
index 25b8ad29..324fa660 100644
--- a/providers/dns/servercow/servercow.go
+++ b/providers/dns/servercow/servercow.go
@@ -2,6 +2,7 @@
 package servercow
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -12,8 +13,6 @@ import (
 	"github.com/go-acme/lego/v4/providers/dns/servercow/internal"
 )
 
-const defaultTTL = 120
-
 // Environment variables names.
 const (
 	envNamespace = "SERVERCOW_"
@@ -41,7 +40,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 120),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
 		HTTPClient: &http.Client{
@@ -76,12 +75,11 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("servercow: incomplete credentials, missing username and/or password")
 	}
 
-	if config.HTTPClient == nil {
-		config.HTTPClient = http.DefaultClient
-	}
-
 	client := internal.NewClient(config.Username, config.Password)
-	client.HTTPClient = config.HTTPClient
+
+	if config.HTTPClient == nil {
+		client.HTTPClient = config.HTTPClient
+	}
 
 	return &DNSProvider{
 		config: config,
@@ -104,7 +102,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("servercow: %w", err)
 	}
 
-	records, err := d.client.GetRecords(authZone)
+	ctx := context.Background()
+
+	records, err := d.client.GetRecords(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("servercow: %w", err)
 	}
@@ -129,7 +129,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 			Content: append(record.Content, info.Value),
 		}
 
-		_, err = d.client.CreateUpdateRecord(authZone, request)
+		_, err = d.client.CreateUpdateRecord(ctx, authZone, request)
 		if err != nil {
 			return fmt.Errorf("servercow: failed to update TXT records: %w", err)
 		}
@@ -143,7 +143,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Content: internal.Value{info.Value},
 	}
 
-	_, err = d.client.CreateUpdateRecord(authZone, request)
+	_, err = d.client.CreateUpdateRecord(ctx, authZone, request)
 	if err != nil {
 		return fmt.Errorf("servercow: failed to create TXT record %s: %w", info.EffectiveFQDN, err)
 	}
@@ -160,7 +160,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("servercow: %w", err)
 	}
 
-	records, err := d.client.GetRecords(authZone)
+	ctx := context.Background()
+
+	records, err := d.client.GetRecords(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("servercow: failed to get TXT records: %w", err)
 	}
@@ -181,7 +183,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	// only 1 record value, the whole record must be deleted.
 	if len(record.Content) == 1 {
-		_, err = d.client.DeleteRecord(authZone, *record)
+		_, err = d.client.DeleteRecord(ctx, authZone, *record)
 		if err != nil {
 			return fmt.Errorf("servercow: failed to delete TXT records: %w", err)
 		}
@@ -200,7 +202,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		}
 	}
 
-	_, err = d.client.CreateUpdateRecord(authZone, request)
+	_, err = d.client.CreateUpdateRecord(ctx, authZone, request)
 	if err != nil {
 		return fmt.Errorf("servercow: failed to update TXT records: %w", err)
 	}
@@ -211,7 +213,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 func getAuthZone(domain string) (string, error) {
 	authZone, err := dns01.FindZoneByFqdn(domain)
 	if err != nil {
-		return "", fmt.Errorf("could not find zone for domain %q: %w", domain, err)
+		return "", fmt.Errorf("could not find zone for FQDN %q: %w", domain, err)
 	}
 
 	zoneName := dns01.UnFqdn(authZone)
diff --git a/providers/dns/simply/internal/client.go b/providers/dns/simply/internal/client.go
index a128fd47..f4221194 100644
--- a/providers/dns/simply/internal/client.go
+++ b/providers/dns/simply/internal/client.go
@@ -2,23 +2,28 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 	"net/url"
 	"strings"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://api.simply.com/1/"
 
 // Client is a Simply.com API client.
 type Client struct {
-	HTTPClient  *http.Client
-	baseURL     *url.URL
 	accountName string
 	apiKey      string
+
+	baseURL    *url.URL
+	HTTPClient *http.Client
 }
 
 // NewClient creates a new Client.
@@ -37,98 +42,126 @@ func NewClient(accountName string, apiKey string) (*Client, error) {
 	}
 
 	return &Client{
-		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
-		baseURL:     baseURL,
 		accountName: accountName,
 		apiKey:      apiKey,
+		baseURL:     baseURL,
+		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
 	}, nil
 }
 
 // GetRecords lists all the records in the zone.
-func (c *Client) GetRecords(zoneName string) ([]Record, error) {
-	resp, err := c.do(zoneName, "/", http.MethodGet, nil)
-	if err != nil {
-		return nil, err
-	}
+func (c *Client) GetRecords(ctx context.Context, zoneName string) ([]Record, error) {
+	endpoint := c.createEndpoint(zoneName, "/")
 
-	var records []Record
-	err = json.Unmarshal(resp.Records, &records)
-	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal response result: %w", err)
-	}
-
-	return records, nil
-}
-
-// AddRecord adds a record.
-func (c *Client) AddRecord(zoneName string, record Record) (int64, error) {
-	reqBody, err := json.Marshal(record)
-	if err != nil {
-		return 0, fmt.Errorf("failed to marshall request body: %w", err)
-	}
-
-	resp, err := c.do(zoneName, "/", http.MethodPost, reqBody)
-	if err != nil {
-		return 0, err
-	}
-
-	var rcd recordHeader
-	err = json.Unmarshal(resp.Record, &rcd)
-	if err != nil {
-		return 0, fmt.Errorf("failed to unmarshal response result: %w", err)
-	}
-
-	return rcd.ID, nil
-}
-
-// EditRecord updates a record.
-func (c *Client) EditRecord(zoneName string, id int64, record Record) error {
-	reqBody, err := json.Marshal(record)
-	if err != nil {
-		return fmt.Errorf("failed to marshall request body: %w", err)
-	}
-
-	_, err = c.do(zoneName, fmt.Sprintf("%d", id), http.MethodPut, reqBody)
-	return err
-}
-
-// DeleteRecord deletes a record.
-func (c *Client) DeleteRecord(zoneName string, id int64) error {
-	_, err := c.do(zoneName, fmt.Sprintf("%d", id), http.MethodDelete, nil)
-	return err
-}
-
-func (c *Client) do(zoneName string, endpoint string, reqMethod string, reqBody []byte) (*apiResponse, error) {
-	reqURL := c.baseURL.JoinPath(c.accountName, c.apiKey, "my", "products", zoneName, "dns", "records", endpoint)
-
-	req, err := http.NewRequest(reqMethod, strings.TrimSuffix(reqURL.String(), "/"), bytes.NewReader(reqBody))
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request: %w", err)
 	}
 
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
+	result := &apiResponse[[]Record, json.RawMessage]{}
+	err = c.do(req, result)
+	if err != nil {
+		return nil, err
+	}
 
+	return result.Records, nil
+}
+
+// AddRecord adds a record.
+func (c *Client) AddRecord(ctx context.Context, zoneName string, record Record) (int64, error) {
+	endpoint := c.createEndpoint(zoneName, "/")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return 0, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	result := &apiResponse[json.RawMessage, recordHeader]{}
+	err = c.do(req, result)
+	if err != nil {
+		return 0, err
+	}
+
+	return result.Record.ID, nil
+}
+
+// EditRecord updates a record.
+func (c *Client) EditRecord(ctx context.Context, zoneName string, id int64, record Record) error {
+	endpoint := c.createEndpoint(zoneName, fmt.Sprintf("%d", id))
+
+	req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record)
+	if err != nil {
+		return fmt.Errorf("failed to create request: %w", err)
+	}
+
+	return c.do(req, &apiResponse[json.RawMessage, json.RawMessage]{})
+}
+
+// DeleteRecord deletes a record.
+func (c *Client) DeleteRecord(ctx context.Context, zoneName string, id int64) error {
+	endpoint := c.createEndpoint(zoneName, fmt.Sprintf("%d", id))
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return fmt.Errorf("failed to create request: %w", err)
+	}
+
+	return c.do(req, &apiResponse[json.RawMessage, json.RawMessage]{})
+}
+
+func (c *Client) createEndpoint(zoneName string, uri string) *url.URL {
+	return c.baseURL.JoinPath(c.accountName, c.apiKey, "my", "products", zoneName, "dns", "records", strings.TrimSuffix(uri, "/"))
+}
+
+func (c *Client) do(req *http.Request, result Response) error {
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("failed to perform request: %w", err)
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode >= http.StatusInternalServerError {
-		return nil, fmt.Errorf("unexpected error: %d", resp.StatusCode)
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 	}
 
-	response := apiResponse{}
-	err = json.NewDecoder(resp.Body).Decode(&response)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	if response.Status != http.StatusOK {
-		return nil, fmt.Errorf("unexpected error: %s", response.Message)
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
-	return &response, nil
+	if result.GetStatus() != http.StatusOK {
+		return fmt.Errorf("unexpected error: %s", result.GetMessage())
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
 }
diff --git a/providers/dns/simply/internal/client_test.go b/providers/dns/simply/internal/client_test.go
index 575ada9c..c9b97e94 100644
--- a/providers/dns/simply/internal/client_test.go
+++ b/providers/dns/simply/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -16,11 +17,11 @@ import (
 )
 
 func TestClient_GetRecords(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusOK, "get_records.json"))
 
-	records, err := client.GetRecords("azone01")
+	records, err := client.GetRecords(context.Background(), "azone01")
 	require.NoError(t, err)
 
 	expected := []Record{
@@ -62,18 +63,18 @@ func TestClient_GetRecords(t *testing.T) {
 }
 
 func TestClient_GetRecords_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusBadRequest, "bad_auth_error.json"))
 
-	records, err := client.GetRecords("azone01")
+	records, err := client.GetRecords(context.Background(), "azone01")
 	require.Error(t, err)
 
 	assert.Nil(t, records)
 }
 
 func TestClient_AddRecord(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusOK, "add_record.json"))
 
@@ -85,14 +86,14 @@ func TestClient_AddRecord(t *testing.T) {
 		Priority: 0,
 	}
 
-	recordID, err := client.AddRecord("azone01", record)
+	recordID, err := client.AddRecord(context.Background(), "azone01", record)
 	require.NoError(t, err)
 
 	assert.EqualValues(t, 123456789, recordID)
 }
 
 func TestClient_AddRecord_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusNotFound, "bad_zone_error.json"))
 
@@ -104,14 +105,14 @@ func TestClient_AddRecord_error(t *testing.T) {
 		Priority: 0,
 	}
 
-	recordID, err := client.AddRecord("azone01", record)
+	recordID, err := client.AddRecord(context.Background(), "azone01", record)
 	require.Error(t, err)
 
 	assert.Zero(t, recordID)
 }
 
 func TestClient_EditRecord(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusOK, "success.json"))
 
@@ -123,12 +124,12 @@ func TestClient_EditRecord(t *testing.T) {
 		Priority: 0,
 	}
 
-	err := client.EditRecord("azone01", 123456789, record)
+	err := client.EditRecord(context.Background(), "azone01", 123456789, record)
 	require.NoError(t, err)
 }
 
 func TestClient_EditRecord_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusNotFound, "invalid_record_id.json"))
 
@@ -140,29 +141,29 @@ func TestClient_EditRecord_error(t *testing.T) {
 		Priority: 0,
 	}
 
-	err := client.EditRecord("azone01", 123456789, record)
+	err := client.EditRecord(context.Background(), "azone01", 123456789, record)
 	require.Error(t, err)
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusOK, "success.json"))
 
-	err := client.DeleteRecord("azone01", 123456789)
+	err := client.DeleteRecord(context.Background(), "azone01", 123456789)
 	require.NoError(t, err)
 }
 
 func TestClient_DeleteRecord_error(t *testing.T) {
-	mux, client := setupTest(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusNotFound, "invalid_record_id.json"))
 
-	err := client.DeleteRecord("azone01", 123456789)
+	err := client.DeleteRecord(context.Background(), "azone01", 123456789)
 	require.Error(t, err)
 }
 
-func setupTest(t *testing.T) (*http.ServeMux, *Client) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
@@ -174,7 +175,7 @@ func setupTest(t *testing.T) (*http.ServeMux, *Client) {
 
 	client.baseURL, _ = url.Parse(server.URL)
 
-	return mux, client
+	return client, mux
 }
 
 func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) {
diff --git a/providers/dns/simply/internal/types.go b/providers/dns/simply/internal/types.go
index e2440c31..7bc53345 100644
--- a/providers/dns/simply/internal/types.go
+++ b/providers/dns/simply/internal/types.go
@@ -1,7 +1,5 @@
 package internal
 
-import "encoding/json"
-
 // Record represents the content of a DNS record.
 type Record struct {
 	ID       int64  `json:"record_id,omitempty"`
@@ -12,12 +10,25 @@ type Record struct {
 	Priority int    `json:"priority,omitempty"`
 }
 
+type Response interface {
+	GetStatus() int
+	GetMessage() string
+}
+
 // apiResponse represents an API response.
-type apiResponse struct {
-	Status  int             `json:"status"`
-	Message string          `json:"message"`
-	Records json.RawMessage `json:"records,omitempty"`
-	Record  json.RawMessage `json:"record,omitempty"`
+type apiResponse[S any, R any] struct {
+	Status  int    `json:"status"`
+	Message string `json:"message"`
+	Records S      `json:"records,omitempty"`
+	Record  R      `json:"record,omitempty"`
+}
+
+func (a apiResponse[S, R]) GetStatus() int {
+	return a.Status
+}
+
+func (a apiResponse[S, R]) GetMessage() string {
+	return a.Message
 }
 
 type recordHeader struct {
diff --git a/providers/dns/simply/simply.go b/providers/dns/simply/simply.go
index f4962d92..5376b3a4 100644
--- a/providers/dns/simply/simply.go
+++ b/providers/dns/simply/simply.go
@@ -2,6 +2,7 @@
 package simply
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -114,8 +115,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("simply: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("simply: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
+
 	authZone = dns01.UnFqdn(authZone)
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -130,7 +132,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:  d.config.TTL,
 	}
 
-	recordID, err := d.client.AddRecord(authZone, recordBody)
+	recordID, err := d.client.AddRecord(context.Background(), authZone, recordBody)
 	if err != nil {
 		return fmt.Errorf("simply: failed to add record: %w", err)
 	}
@@ -148,8 +150,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("simply: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("simply: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
+
 	authZone = dns01.UnFqdn(authZone)
 
 	// gets the record's unique ID from when we created it
@@ -160,7 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("simply: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	err = d.client.DeleteRecord(authZone, recordID)
+	err = d.client.DeleteRecord(context.Background(), authZone, recordID)
 	if err != nil {
 		return fmt.Errorf("simply: failed to delete TXT records: fqdn=%s, recordID=%d: %w", info.EffectiveFQDN, recordID, err)
 	}
diff --git a/providers/dns/sonic/internal/client.go b/providers/dns/sonic/internal/client.go
index ba52411c..aac85c63 100644
--- a/providers/dns/sonic/internal/client.go
+++ b/providers/dns/sonic/internal/client.go
@@ -2,35 +2,25 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const baseURL = "https://public-api.sonic.net/dyndns"
 
-type APIResponse struct {
-	Message string `json:"message"`
-	Result  int    `json:"result"`
-}
-
-// Record holds the Sonic API representation of a Domain Record.
-type Record struct {
-	UserID   string `json:"userid"`
-	APIKey   string `json:"apikey"`
-	Hostname string `json:"hostname"`
-	Value    string `json:"value"`
-	TTL      int    `json:"ttl"`
-	Type     string `json:"type"`
-}
-
 // Client Sonic client.
 type Client struct {
-	userID     string
-	apiKey     string
+	userID string
+	apiKey string
+
 	baseURL    string
 	HTTPClient *http.Client
 }
@@ -52,7 +42,7 @@ func NewClient(userID, apiKey string) (*Client, error) {
 // SetRecord creates or updates a TXT records.
 // Sonic does not provide a delete record API endpoint.
 // https://public-api.sonic.net/dyndns#updating_or_adding_host_records
-func (c *Client) SetRecord(hostname string, value string, ttl int) error {
+func (c *Client) SetRecord(ctx context.Context, hostname string, value string, ttl int) error {
 	payload := &Record{
 		UserID:   c.userID,
 		APIKey:   c.apiKey,
@@ -64,32 +54,38 @@ func (c *Client) SetRecord(hostname string, value string, ttl int) error {
 
 	body, err := json.Marshal(payload)
 	if err != nil {
-		return err
+		return fmt.Errorf("failed to create request JSON body: %w", err)
 	}
 
-	req, err := http.NewRequest(http.MethodPut, c.baseURL+"/host", bytes.NewReader(body))
+	endpoint, err := url.JoinPath(c.baseURL, "host")
 	if err != nil {
 		return err
 	}
 
+	req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
 	req.Header.Set("content-type", "application/json")
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return fmt.Errorf("failed to read response body: %w", err)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
 	r := APIResponse{}
 	err = json.Unmarshal(raw, &r)
 	if err != nil {
-		return fmt.Errorf("failed to unmarshal response: %w: %s", err, string(raw))
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	if r.Result != 200 {
diff --git a/providers/dns/sonic/internal/client_test.go b/providers/dns/sonic/internal/client_test.go
index 6317e16d..f4a6105e 100644
--- a/providers/dns/sonic/internal/client_test.go
+++ b/providers/dns/sonic/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -9,7 +10,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setup(t *testing.T, body string) *Client {
+func setupTest(t *testing.T, body string) *Client {
 	t.Helper()
 
 	mux := http.NewServeMux()
@@ -51,9 +52,9 @@ func TestClient_SetRecord(t *testing.T) {
 		t.Run(test.desc, func(t *testing.T) {
 			t.Parallel()
 
-			client := setup(t, test.response)
+			client := setupTest(t, test.response)
 
-			err := client.SetRecord("example.com", "txttxttxt", 10)
+			err := client.SetRecord(context.Background(), "example.com", "txttxttxt", 10)
 			test.assert(t, err)
 		})
 	}
diff --git a/providers/dns/sonic/internal/types.go b/providers/dns/sonic/internal/types.go
new file mode 100644
index 00000000..d6caed3a
--- /dev/null
+++ b/providers/dns/sonic/internal/types.go
@@ -0,0 +1,16 @@
+package internal
+
+type APIResponse struct {
+	Message string `json:"message"`
+	Result  int    `json:"result"`
+}
+
+// Record holds the Sonic API representation of a Domain Record.
+type Record struct {
+	UserID   string `json:"userid"`
+	APIKey   string `json:"apikey"`
+	Hostname string `json:"hostname"`
+	Value    string `json:"value"`
+	TTL      int    `json:"ttl"`
+	Type     string `json:"type"`
+}
diff --git a/providers/dns/sonic/sonic.go b/providers/dns/sonic/sonic.go
index 907ee7d0..19c5769b 100644
--- a/providers/dns/sonic/sonic.go
+++ b/providers/dns/sonic/sonic.go
@@ -2,6 +2,7 @@
 package sonic
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -94,7 +95,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err := d.client.SetRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL)
+	err := d.client.SetRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL)
 	if err != nil {
 		return fmt.Errorf("sonic: unable to create record for %s: %w", info.EffectiveFQDN, err)
 	}
@@ -106,7 +107,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	err := d.client.SetRecord(dns01.UnFqdn(info.EffectiveFQDN), "_", d.config.TTL)
+	err := d.client.SetRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), "_", d.config.TTL)
 	if err != nil {
 		return fmt.Errorf("sonic: unable to clean record for %s: %w", info.EffectiveFQDN, err)
 	}
diff --git a/providers/dns/stackpath/client.go b/providers/dns/stackpath/client.go
deleted file mode 100644
index f38f4e75..00000000
--- a/providers/dns/stackpath/client.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package stackpath
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-
-	"github.com/go-acme/lego/v4/challenge/dns01"
-	"golang.org/x/net/publicsuffix"
-)
-
-// Zones is the response struct from the Stackpath api GetZones.
-type Zones struct {
-	Zones []Zone `json:"zones"`
-}
-
-// Zone a DNS zone representation.
-type Zone struct {
-	ID     string
-	Domain string
-}
-
-// Records is the response struct from the Stackpath api GetZoneRecords.
-type Records struct {
-	Records []Record `json:"records"`
-}
-
-// Record a DNS record representation.
-type Record struct {
-	ID   string `json:"id,omitempty"`
-	Name string `json:"name"`
-	Type string `json:"type"`
-	TTL  int    `json:"ttl"`
-	Data string `json:"data"`
-}
-
-// ErrorResponse the API error response representation.
-type ErrorResponse struct {
-	Code    int    `json:"code"`
-	Message string `json:"error"`
-}
-
-func (e *ErrorResponse) Error() string {
-	return fmt.Sprintf("%d %s", e.Code, e.Message)
-}
-
-// https://developer.stackpath.com/en/api/dns/#operation/GetZones
-func (d *DNSProvider) getZones(domain string) (*Zone, error) {
-	tld, err := publicsuffix.EffectiveTLDPlusOne(dns01.UnFqdn(domain))
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := d.newRequest(http.MethodGet, "/zones", nil)
-	if err != nil {
-		return nil, err
-	}
-
-	query := req.URL.Query()
-	query.Add("page_request.filter", fmt.Sprintf("domain='%s'", tld))
-	req.URL.RawQuery = query.Encode()
-
-	var zones Zones
-	err = d.do(req, &zones)
-	if err != nil {
-		return nil, err
-	}
-
-	if len(zones.Zones) == 0 {
-		return nil, fmt.Errorf("did not find zone with domain %s", domain)
-	}
-
-	return &zones.Zones[0], nil
-}
-
-// https://developer.stackpath.com/en/api/dns/#operation/GetZoneRecords
-func (d *DNSProvider) getZoneRecords(name string, zone *Zone) ([]Record, error) {
-	u := fmt.Sprintf("/zones/%s/records", zone.ID)
-	req, err := d.newRequest(http.MethodGet, u, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	query := req.URL.Query()
-	query.Add("page_request.filter", fmt.Sprintf("name='%s' and type='TXT'", name))
-	req.URL.RawQuery = query.Encode()
-
-	var records Records
-	err = d.do(req, &records)
-	if err != nil {
-		return nil, err
-	}
-
-	if len(records.Records) == 0 {
-		return nil, fmt.Errorf("did not find record with name %s", name)
-	}
-
-	return records.Records, nil
-}
-
-// https://developer.stackpath.com/en/api/dns/#operation/CreateZoneRecord
-func (d *DNSProvider) createZoneRecord(zone *Zone, record Record) error {
-	u := fmt.Sprintf("/zones/%s/records", zone.ID)
-	req, err := d.newRequest(http.MethodPost, u, record)
-	if err != nil {
-		return err
-	}
-
-	return d.do(req, nil)
-}
-
-// https://developer.stackpath.com/en/api/dns/#operation/DeleteZoneRecord
-func (d *DNSProvider) deleteZoneRecord(zone *Zone, record Record) error {
-	u := fmt.Sprintf("/zones/%s/records/%s", zone.ID, record.ID)
-	req, err := d.newRequest(http.MethodDelete, u, nil)
-	if err != nil {
-		return err
-	}
-
-	return d.do(req, nil)
-}
-
-func (d *DNSProvider) newRequest(method, urlStr string, body interface{}) (*http.Request, error) {
-	u := d.BaseURL.JoinPath(d.config.StackID, urlStr)
-
-	if body == nil {
-		return http.NewRequest(method, u.String(), nil)
-	}
-
-	reqBody, err := json.Marshal(body)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(method, u.String(), bytes.NewBuffer(reqBody))
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-
-	return req, nil
-}
-
-func (d *DNSProvider) do(req *http.Request, v interface{}) error {
-	resp, err := d.client.Do(req)
-	if err != nil {
-		return err
-	}
-
-	err = checkResponse(resp)
-	if err != nil {
-		return err
-	}
-
-	if v == nil {
-		return nil
-	}
-
-	raw, err := readBody(resp)
-	if err != nil {
-		return fmt.Errorf("failed to read body: %w", err)
-	}
-
-	err = json.Unmarshal(raw, v)
-	if err != nil {
-		return fmt.Errorf("unmarshaling error: %w: %s", err, string(raw))
-	}
-
-	return nil
-}
-
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode > 299 {
-		data, err := readBody(resp)
-		if err != nil {
-			return &ErrorResponse{Code: resp.StatusCode, Message: err.Error()}
-		}
-
-		errResp := &ErrorResponse{}
-		err = json.Unmarshal(data, errResp)
-		if err != nil {
-			return &ErrorResponse{Code: resp.StatusCode, Message: fmt.Sprintf("unmarshaling error: %v: %s", err, string(data))}
-		}
-		return errResp
-	}
-
-	return nil
-}
-
-func readBody(resp *http.Response) ([]byte, error) {
-	if resp.Body == nil {
-		return nil, errors.New("response body is nil")
-	}
-
-	defer resp.Body.Close()
-
-	rawBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	return rawBody, nil
-}
diff --git a/providers/dns/stackpath/internal/client.go b/providers/dns/stackpath/internal/client.go
new file mode 100644
index 00000000..bd11bf23
--- /dev/null
+++ b/providers/dns/stackpath/internal/client.go
@@ -0,0 +1,186 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+
+	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/net/publicsuffix"
+)
+
+const defaultBaseURL = "https://gateway.stackpath.com/dns/v1/stacks/"
+
+// Client the API client for Stackpath.
+type Client struct {
+	stackID string
+
+	baseURL    *url.URL
+	httpClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(ctx context.Context, stackID, clientID, clientSecret string) *Client {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	return &Client{
+		baseURL:    baseURL,
+		stackID:    stackID,
+		httpClient: createOAuthClient(ctx, clientID, clientSecret),
+	}
+}
+
+// GetZones gets all zones.
+// https://stackpath.dev/reference/getzones
+func (c *Client) GetZones(ctx context.Context, domain string) (*Zone, error) {
+	endpoint := c.baseURL.JoinPath(c.stackID, "zones")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	tld, err := publicsuffix.EffectiveTLDPlusOne(dns01.UnFqdn(domain))
+	if err != nil {
+		return nil, err
+	}
+
+	query := req.URL.Query()
+	query.Add("page_request.filter", fmt.Sprintf("domain='%s'", tld))
+	req.URL.RawQuery = query.Encode()
+
+	var zones Zones
+	err = c.do(req, &zones)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(zones.Zones) == 0 {
+		return nil, fmt.Errorf("did not find zone with domain %s", domain)
+	}
+
+	return &zones.Zones[0], nil
+}
+
+// GetZoneRecords gets all records.
+// https://stackpath.dev/reference/getzonerecords
+func (c *Client) GetZoneRecords(ctx context.Context, name string, zone *Zone) ([]Record, error) {
+	endpoint := c.baseURL.JoinPath(c.stackID, "zones", zone.ID, "records")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	query := req.URL.Query()
+	query.Add("page_request.filter", fmt.Sprintf("name='%s' and type='TXT'", name))
+	req.URL.RawQuery = query.Encode()
+
+	var records Records
+	err = c.do(req, &records)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(records.Records) == 0 {
+		return nil, fmt.Errorf("did not find record with name %s", name)
+	}
+
+	return records.Records, nil
+}
+
+// CreateZoneRecord creates a record.
+// https://stackpath.dev/reference/createzonerecord
+func (c *Client) CreateZoneRecord(ctx context.Context, zone *Zone, record Record) error {
+	endpoint := c.baseURL.JoinPath(c.stackID, "zones", zone.ID, "records")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+// DeleteZoneRecord deletes a record.
+// https://stackpath.dev/reference/deletezonerecord
+func (c *Client) DeleteZoneRecord(ctx context.Context, zone *Zone, record Record) error {
+	endpoint := c.baseURL.JoinPath(c.stackID, "zones", zone.ID, "records", record.ID)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	errResp := &ErrorResponse{}
+	err := json.Unmarshal(raw, errResp)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return errResp
+}
diff --git a/providers/dns/stackpath/internal/client_test.go b/providers/dns/stackpath/internal/client_test.go
new file mode 100644
index 00000000..2de1d476
--- /dev/null
+++ b/providers/dns/stackpath/internal/client_test.go
@@ -0,0 +1,131 @@
+package internal
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	client := NewClient(context.Background(), "STACK_ID", "CLIENT_ID", "CLIENT_SECRET")
+	client.httpClient = server.Client()
+	client.baseURL, _ = url.Parse(server.URL + "/")
+
+	return client, mux
+}
+
+func TestClient_GetZoneRecords(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) {
+		content := `
+			{
+				"records": [
+					{"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"},
+					{"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"}
+				]
+			}`
+
+		_, err := w.Write([]byte(content))
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	records, err := client.GetZoneRecords(context.Background(), "foo1", &Zone{ID: "A", Domain: "test"})
+	require.NoError(t, err)
+
+	expected := []Record{
+		{ID: "1", Name: "foo1", Type: "TXT", TTL: 120, Data: "txtTXTtxt"},
+		{ID: "2", Name: "foo2", Type: "TXT", TTL: 121, Data: "TXTtxtTXT"},
+	}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_GetZoneRecords_apiError(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) {
+		content := `
+{
+	"code": 401,
+	"error": "an unauthorized request is attempted."
+}`
+
+		w.WriteHeader(http.StatusUnauthorized)
+		_, err := w.Write([]byte(content))
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	_, err := client.GetZoneRecords(context.Background(), "foo1", &Zone{ID: "A", Domain: "test"})
+
+	expected := &ErrorResponse{Code: 401, Message: "an unauthorized request is attempted."}
+	assert.Equal(t, expected, err)
+}
+
+func TestClient_GetZones(t *testing.T) {
+	client, mux := setupTest(t)
+
+	mux.HandleFunc("/STACK_ID/zones", func(w http.ResponseWriter, _ *http.Request) {
+		content := `
+{
+  "pageInfo": {
+    "totalCount": "5",
+    "hasPreviousPage": false,
+    "hasNextPage": false,
+    "startCursor": "1",
+    "endCursor": "1"
+  },
+  "zones": [
+    {
+      "stackId": "my_stack",
+      "accountId": "my_account",
+      "id": "A",
+      "domain": "foo.com",
+      "version": "1",
+      "labels": {
+        "property1": "val1",
+        "property2": "val2"
+      },
+      "created": "2018-10-07T02:31:49Z",
+      "updated": "2018-10-07T02:31:49Z",
+      "nameservers": [
+        "1.1.1.1"
+      ],
+      "verified": "2018-10-07T02:31:49Z",
+      "status": "ACTIVE",
+      "disabled": false
+    }
+  ]
+}`
+
+		_, err := w.Write([]byte(content))
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	zone, err := client.GetZones(context.Background(), "sub.foo.com")
+	require.NoError(t, err)
+
+	expected := &Zone{ID: "A", Domain: "foo.com"}
+
+	assert.Equal(t, expected, zone)
+}
diff --git a/providers/dns/stackpath/internal/identity.go b/providers/dns/stackpath/internal/identity.go
new file mode 100644
index 00000000..5c6e6ab1
--- /dev/null
+++ b/providers/dns/stackpath/internal/identity.go
@@ -0,0 +1,20 @@
+package internal
+
+import (
+	"context"
+	"net/http"
+
+	"golang.org/x/oauth2/clientcredentials"
+)
+
+const defaultAuthURL = "https://gateway.stackpath.com/identity/v1/oauth2/token"
+
+func createOAuthClient(ctx context.Context, clientID, clientSecret string) *http.Client {
+	config := &clientcredentials.Config{
+		TokenURL:     defaultAuthURL,
+		ClientID:     clientID,
+		ClientSecret: clientSecret,
+	}
+
+	return config.Client(ctx)
+}
diff --git a/providers/dns/stackpath/internal/types.go b/providers/dns/stackpath/internal/types.go
new file mode 100644
index 00000000..1ca29e81
--- /dev/null
+++ b/providers/dns/stackpath/internal/types.go
@@ -0,0 +1,38 @@
+package internal
+
+import "fmt"
+
+// Zones is the response struct from the Stackpath api GetZones.
+type Zones struct {
+	Zones []Zone `json:"zones"`
+}
+
+// Zone a DNS zone representation.
+type Zone struct {
+	ID     string
+	Domain string
+}
+
+// Records is the response struct from the Stackpath api GetZoneRecords.
+type Records struct {
+	Records []Record `json:"records"`
+}
+
+// Record a DNS record representation.
+type Record struct {
+	ID   string `json:"id,omitempty"`
+	Name string `json:"name"`
+	Type string `json:"type"`
+	TTL  int    `json:"ttl"`
+	Data string `json:"data"`
+}
+
+// ErrorResponse the API error response representation.
+type ErrorResponse struct {
+	Code    int    `json:"code"`
+	Message string `json:"error"`
+}
+
+func (e *ErrorResponse) Error() string {
+	return fmt.Sprintf("%d %s", e.Code, e.Message)
+}
diff --git a/providers/dns/stackpath/stackpath.go b/providers/dns/stackpath/stackpath.go
index ee56a47e..97cfd8aa 100644
--- a/providers/dns/stackpath/stackpath.go
+++ b/providers/dns/stackpath/stackpath.go
@@ -6,19 +6,12 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"net/http"
-	"net/url"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/log"
 	"github.com/go-acme/lego/v4/platform/config/env"
-	"golang.org/x/oauth2/clientcredentials"
-)
-
-const (
-	defaultBaseURL = "https://gateway.stackpath.com/dns/v1/stacks/"
-	defaultAuthURL = "https://gateway.stackpath.com/identity/v1/oauth2/token"
+	"github.com/go-acme/lego/v4/providers/dns/stackpath/internal"
 )
 
 // Environment variables names.
@@ -55,9 +48,8 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	BaseURL *url.URL
-	client  *http.Client
-	config  *Config
+	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for Stackpath.
@@ -91,30 +83,18 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("stackpath: stack id missing")
 	}
 
-	baseURL, _ := url.Parse(defaultBaseURL)
+	client := internal.NewClient(context.Background(), config.StackID, config.ClientID, config.ClientSecret)
 
-	return &DNSProvider{
-		BaseURL: baseURL,
-		client:  getOathClient(config),
-		config:  config,
-	}, nil
-}
-
-func getOathClient(config *Config) *http.Client {
-	oathConfig := &clientcredentials.Config{
-		TokenURL:     defaultAuthURL,
-		ClientID:     config.ClientID,
-		ClientSecret: config.ClientSecret,
-	}
-
-	return oathConfig.Client(context.Background())
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Present creates a TXT record to fulfill the dns-01 challenge.
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.getZones(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zone, err := d.client.GetZones(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("stackpath: %w", err)
 	}
@@ -124,21 +104,23 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("stackpath: %w", err)
 	}
 
-	record := Record{
+	record := internal.Record{
 		Name: subDomain,
 		Type: "TXT",
 		TTL:  d.config.TTL,
 		Data: info.Value,
 	}
 
-	return d.createZoneRecord(zone, record)
+	return d.client.CreateZoneRecord(ctx, zone, record)
 }
 
 // CleanUp removes the TXT record matching the specified parameters.
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	zone, err := d.getZones(info.EffectiveFQDN)
+	ctx := context.Background()
+
+	zone, err := d.client.GetZones(ctx, info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("stackpath: %w", err)
 	}
@@ -148,13 +130,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("stackpath: %w", err)
 	}
 
-	records, err := d.getZoneRecords(subDomain, zone)
+	records, err := d.client.GetZoneRecords(ctx, subDomain, zone)
 	if err != nil {
 		return err
 	}
 
 	for _, record := range records {
-		err = d.deleteZoneRecord(zone, record)
+		err = d.client.DeleteZoneRecord(ctx, zone, record)
 		if err != nil {
 			log.Printf("stackpath: failed to delete TXT record: %v", err)
 		}
diff --git a/providers/dns/stackpath/stackpath_test.go b/providers/dns/stackpath/stackpath_test.go
index 1a575bbf..a72f268a 100644
--- a/providers/dns/stackpath/stackpath_test.go
+++ b/providers/dns/stackpath/stackpath_test.go
@@ -1,9 +1,6 @@
 package stackpath
 
 import (
-	"net/http"
-	"net/http/httptest"
-	"net/url"
 	"testing"
 	"time"
 
@@ -135,132 +132,6 @@ func TestNewDNSProviderConfig(t *testing.T) {
 	}
 }
 
-func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) {
-	t.Helper()
-
-	mux := http.NewServeMux()
-	server := httptest.NewServer(mux)
-	t.Cleanup(server.Close)
-
-	config := NewDefaultConfig()
-	config.ClientID = "CLIENT_ID"
-	config.ClientSecret = "CLIENT_SECRET"
-	config.StackID = "STACK_ID"
-
-	provider, err := NewDNSProviderConfig(config)
-	require.NoError(t, err)
-
-	provider.client = http.DefaultClient
-	provider.BaseURL, _ = url.Parse(server.URL + "/")
-
-	return provider, mux
-}
-
-func TestDNSProvider_getZoneRecords(t *testing.T) {
-	provider, mux := setupTest(t)
-
-	mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) {
-		content := `
-			{
-				"records": [
-					{"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"},
-					{"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"}
-				]
-			}`
-
-		_, err := w.Write([]byte(content))
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-	})
-
-	records, err := provider.getZoneRecords("foo1", &Zone{ID: "A", Domain: "test"})
-	require.NoError(t, err)
-
-	expected := []Record{
-		{ID: "1", Name: "foo1", Type: "TXT", TTL: 120, Data: "txtTXTtxt"},
-		{ID: "2", Name: "foo2", Type: "TXT", TTL: 121, Data: "TXTtxtTXT"},
-	}
-
-	assert.Equal(t, expected, records)
-}
-
-func TestDNSProvider_getZoneRecords_apiError(t *testing.T) {
-	provider, mux := setupTest(t)
-
-	mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) {
-		content := `
-{
-	"code": 401,
-	"error": "an unauthorized request is attempted."
-}`
-
-		w.WriteHeader(http.StatusUnauthorized)
-		_, err := w.Write([]byte(content))
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-	})
-
-	_, err := provider.getZoneRecords("foo1", &Zone{ID: "A", Domain: "test"})
-
-	expected := &ErrorResponse{Code: 401, Message: "an unauthorized request is attempted."}
-	assert.Equal(t, expected, err)
-}
-
-func TestDNSProvider_getZones(t *testing.T) {
-	provider, mux := setupTest(t)
-
-	mux.HandleFunc("/STACK_ID/zones", func(w http.ResponseWriter, _ *http.Request) {
-		content := `
-{
-  "pageInfo": {
-    "totalCount": "5",
-    "hasPreviousPage": false,
-    "hasNextPage": false,
-    "startCursor": "1",
-    "endCursor": "1"
-  },
-  "zones": [
-    {
-      "stackId": "my_stack",
-      "accountId": "my_account",
-      "id": "A",
-      "domain": "foo.com",
-      "version": "1",
-      "labels": {
-        "property1": "val1",
-        "property2": "val2"
-      },
-      "created": "2018-10-07T02:31:49Z",
-      "updated": "2018-10-07T02:31:49Z",
-      "nameservers": [
-        "1.1.1.1"
-      ],
-      "verified": "2018-10-07T02:31:49Z",
-      "status": "ACTIVE",
-      "disabled": false
-    }
-  ]
-}`
-
-		_, err := w.Write([]byte(content))
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-	})
-
-	zone, err := provider.getZones("sub.foo.com")
-	require.NoError(t, err)
-
-	expected := &Zone{ID: "A", Domain: "foo.com"}
-
-	assert.Equal(t, expected, zone)
-}
-
 func TestLivePresent(t *testing.T) {
 	if !envTest.IsLiveTest() {
 		t.Skip("skipping live test")
diff --git a/providers/dns/tencentcloud/client.go b/providers/dns/tencentcloud/wrapper.go
similarity index 96%
rename from providers/dns/tencentcloud/client.go
rename to providers/dns/tencentcloud/wrapper.go
index 1c15ee38..af608bb3 100644
--- a/providers/dns/tencentcloud/client.go
+++ b/providers/dns/tencentcloud/wrapper.go
@@ -33,7 +33,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*dnspod.DomainListItem, erro
 
 	authZone, err := dns01.FindZoneByFqdn(domain)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("could not find zone for FQDN %q : %w", domain, err)
 	}
 
 	var hostedZone *dnspod.DomainListItem
diff --git a/providers/dns/transip/transip.go b/providers/dns/transip/transip.go
index 4b859bc6..e18f2f0f 100644
--- a/providers/dns/transip/transip.go
+++ b/providers/dns/transip/transip.go
@@ -95,7 +95,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("transip: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// get the subDomain
@@ -127,7 +127,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return err
+		return fmt.Errorf("transip: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// get the subDomain
diff --git a/providers/dns/ultradns/ultradns.go b/providers/dns/ultradns/ultradns.go
index 5a1fb40c..2c39e9c0 100644
--- a/providers/dns/ultradns/ultradns.go
+++ b/providers/dns/ultradns/ultradns.go
@@ -105,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("ultradns: %w", err)
+		return fmt.Errorf("ultradns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	recordService, err := record.Get(d.client)
@@ -146,7 +146,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("ultradns: %w", err)
+		return fmt.Errorf("ultradns: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	recordService, err := record.Get(d.client)
diff --git a/providers/dns/variomedia/internal/client.go b/providers/dns/variomedia/internal/client.go
index 6df23dd6..4a671e88 100644
--- a/providers/dns/variomedia/internal/client.go
+++ b/providers/dns/variomedia/internal/client.go
@@ -2,22 +2,30 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://api.variomedia.de"
 
+const authorizationHeader = "Authorization"
+
+// Client the API client for Variomedia.
 type Client struct {
-	apiToken   string
+	apiToken string
+
 	baseURL    *url.URL
 	HTTPClient *http.Client
 }
 
+// NewClient creates a new Client.
 func NewClient(apiToken string) *Client {
 	baseURL, _ := url.Parse(defaultBaseURL)
 
@@ -28,7 +36,9 @@ func NewClient(apiToken string) *Client {
 	}
 }
 
-func (c Client) CreateDNSRecord(record DNSRecord) (*CreateDNSRecordResponse, error) {
+// CreateDNSRecord creates a new DNS entry.
+// https://api.variomedia.de/docs/dns-records.html#erstellen
+func (c Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (*CreateDNSRecordResponse, error) {
 	endpoint := c.baseURL.JoinPath("dns-records")
 
 	data := CreateDNSRecordRequest{Data: Data{
@@ -36,12 +46,7 @@ func (c Client) CreateDNSRecord(record DNSRecord) (*CreateDNSRecordResponse, err
 		Attributes: record,
 	}}
 
-	body, err := json.Marshal(data)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body))
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, data)
 	if err != nil {
 		return nil, err
 	}
@@ -55,10 +60,12 @@ func (c Client) CreateDNSRecord(record DNSRecord) (*CreateDNSRecordResponse, err
 	return &result, nil
 }
 
-func (c Client) DeleteDNSRecord(id string) (*DeleteRecordResponse, error) {
+// DeleteDNSRecord deletes a DNS record.
+// https://api.variomedia.de/docs/dns-records.html#l%C3%B6schen
+func (c Client) DeleteDNSRecord(ctx context.Context, id string) (*DeleteRecordResponse, error) {
 	endpoint := c.baseURL.JoinPath("dns-records", id)
 
-	req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -72,10 +79,12 @@ func (c Client) DeleteDNSRecord(id string) (*DeleteRecordResponse, error) {
 	return &result, nil
 }
 
-func (c Client) GetJob(id string) (*GetJobResponse, error) {
+// GetJob returns a single job based on its ID.
+// https://api.variomedia.de/docs/job-queue.html
+func (c Client) GetJob(ctx context.Context, id string) (*GetJobResponse, error) {
 	endpoint := c.baseURL.JoinPath("queue-jobs", id)
 
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -89,39 +98,65 @@ func (c Client) GetJob(id string) (*GetJobResponse, error) {
 	return &result, nil
 }
 
-func (c Client) do(req *http.Request, data interface{}) error {
-	req.Header.Set("Content-Type", "application/vnd.api+json")
-	req.Header.Set("Accept", "application/vnd.variomedia.v1+json")
-	req.Header.Set("Authorization", "token "+c.apiToken)
+func (c Client) do(req *http.Request, data any) error {
+	req.Header.Set(authorizationHeader, "token "+c.apiToken)
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode/100 != 2 {
-		all, _ := io.ReadAll(resp.Body)
-
-		var e APIError
-		err = json.Unmarshal(all, &e)
-		if err != nil {
-			return fmt.Errorf("%d: %s", resp.StatusCode, string(all))
-		}
-
-		return e
+		return parseError(req, resp)
 	}
 
-	content, err := io.ReadAll(resp.Body)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return err
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	err = json.Unmarshal(content, data)
+	err = json.Unmarshal(raw, data)
 	if err != nil {
-		return fmt.Errorf("%w: %s", err, string(content))
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/vnd.variomedia.v1+json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/vnd.api+json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errAPI APIError
+	err := json.Unmarshal(raw, &errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return errAPI
+}
diff --git a/providers/dns/variomedia/internal/client_test.go b/providers/dns/variomedia/internal/client_test.go
index a01e3037..c0017f24 100644
--- a/providers/dns/variomedia/internal/client_test.go
+++ b/providers/dns/variomedia/internal/client_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -13,7 +14,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setup(t *testing.T) (*Client, *http.ServeMux) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
@@ -36,7 +37,7 @@ func mockHandler(method string, filename string) http.HandlerFunc {
 		filename = "./fixtures/" + filename
 		statusCode := http.StatusOK
 
-		if req.Header.Get("Authorization") != "token secret" {
+		if req.Header.Get(authorizationHeader) != "token secret" {
 			statusCode = http.StatusUnauthorized
 			filename = "./fixtures/error.json"
 		}
@@ -59,7 +60,7 @@ func mockHandler(method string, filename string) http.HandlerFunc {
 }
 
 func TestClient_CreateDNSRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/dns-records", mockHandler(http.MethodPost, "POST_dns-records.json"))
 
@@ -71,7 +72,7 @@ func TestClient_CreateDNSRecord(t *testing.T) {
 		TTL:        300,
 	}
 
-	resp, err := client.CreateDNSRecord(record)
+	resp, err := client.CreateDNSRecord(context.Background(), record)
 	require.NoError(t, err)
 
 	expected := &CreateDNSRecordResponse{
@@ -107,11 +108,11 @@ func TestClient_CreateDNSRecord(t *testing.T) {
 }
 
 func TestClient_DeleteDNSRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/dns-records/test", mockHandler(http.MethodDelete, "DELETE_dns-records_pending.json"))
 
-	resp, err := client.DeleteDNSRecord("test")
+	resp, err := client.DeleteDNSRecord(context.Background(), "test")
 	require.NoError(t, err)
 
 	expected := &DeleteRecordResponse{
@@ -142,11 +143,11 @@ func TestClient_DeleteDNSRecord(t *testing.T) {
 }
 
 func TestClient_GetJob(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/queue-jobs/test", mockHandler(http.MethodGet, "GET_queue-jobs.json"))
 
-	resp, err := client.GetJob("test")
+	resp, err := client.GetJob(context.Background(), "test")
 	require.NoError(t, err)
 
 	expected := &GetJobResponse{
diff --git a/providers/dns/variomedia/variomedia.go b/providers/dns/variomedia/variomedia.go
index b4f21a56..e87220f4 100644
--- a/providers/dns/variomedia/variomedia.go
+++ b/providers/dns/variomedia/variomedia.go
@@ -2,6 +2,7 @@
 package variomedia
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -16,8 +17,6 @@ import (
 	"github.com/go-acme/lego/v4/providers/dns/variomedia/internal"
 )
 
-const defaultTTL = 300
-
 // Environment variables names.
 const (
 	envNamespace = "VARIOMEDIA_"
@@ -45,7 +44,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 300),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
 		SequenceInterval:   env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
@@ -83,10 +82,6 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("variomedia: missing credentials")
 	}
 
-	if config.HTTPClient == nil {
-		config.HTTPClient = http.DefaultClient
-	}
-
 	client := internal.NewClient(config.APIToken)
 
 	if config.HTTPClient != nil {
@@ -118,7 +113,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("variomedia: %w", err)
+		return fmt.Errorf("variomedia: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -126,6 +121,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("variomedia: %w", err)
 	}
 
+	ctx := context.Background()
+
 	record := internal.DNSRecord{
 		RecordType: "TXT",
 		Name:       subDomain,
@@ -134,12 +131,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:        d.config.TTL,
 	}
 
-	cdrr, err := d.client.CreateDNSRecord(record)
+	cdrr, err := d.client.CreateDNSRecord(ctx, record)
 	if err != nil {
 		return fmt.Errorf("variomedia: %w", err)
 	}
 
-	err = d.waitJob(domain, cdrr.Data.ID)
+	err = d.waitJob(ctx, domain, cdrr.Data.ID)
 	if err != nil {
 		return fmt.Errorf("variomedia: %w", err)
 	}
@@ -155,6 +152,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
+	ctx := context.Background()
+
 	// get the record's unique ID from when we created it
 	d.recordIDsMu.Lock()
 	recordID, ok := d.recordIDs[token]
@@ -163,12 +162,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("variomedia: unknown record ID for '%s'", info.EffectiveFQDN)
 	}
 
-	ddrr, err := d.client.DeleteDNSRecord(recordID)
+	ddrr, err := d.client.DeleteDNSRecord(ctx, recordID)
 	if err != nil {
 		return fmt.Errorf("variomedia: %w", err)
 	}
 
-	err = d.waitJob(domain, ddrr.Data.ID)
+	err = d.waitJob(ctx, domain, ddrr.Data.ID)
 	if err != nil {
 		return fmt.Errorf("variomedia: %w", err)
 	}
@@ -176,9 +175,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	return nil
 }
 
-func (d *DNSProvider) waitJob(domain string, id string) error {
+func (d *DNSProvider) waitJob(ctx context.Context, domain string, id string) error {
 	return wait.For("variomedia: apply change on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) {
-		result, err := d.client.GetJob(id)
+		result, err := d.client.GetJob(ctx, id)
 		if err != nil {
 			return false, err
 		}
diff --git a/providers/dns/vercel/internal/client.go b/providers/dns/vercel/internal/client.go
index cf168edf..4bc59ba0 100644
--- a/providers/dns/vercel/internal/client.go
+++ b/providers/dns/vercel/internal/client.go
@@ -2,8 +2,8 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -11,65 +11,49 @@ import (
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"golang.org/x/oauth2"
 )
 
 const defaultBaseURL = "https://api.vercel.com"
 
 // Client Vercel client.
 type Client struct {
-	authToken  string
-	teamID     string
+	teamID string
+
 	baseURL    *url.URL
-	HTTPClient *http.Client
+	httpClient *http.Client
 }
 
 // NewClient creates a Client.
-func NewClient(authToken string, teamID string) *Client {
+func NewClient(hc *http.Client, teamID string) *Client {
 	baseURL, _ := url.Parse(defaultBaseURL)
 
+	if hc == nil {
+		hc = &http.Client{Timeout: 10 * time.Second}
+	}
+
 	return &Client{
-		authToken:  authToken,
 		teamID:     teamID,
 		baseURL:    baseURL,
-		HTTPClient: &http.Client{Timeout: 10 * time.Second},
+		httpClient: hc,
 	}
 }
 
 // CreateRecord creates a DNS record.
 // https://vercel.com/docs/rest-api#endpoints/dns/create-a-dns-record
-func (c *Client) CreateRecord(zone string, record Record) (*CreateRecordResponse, error) {
+func (c *Client) CreateRecord(ctx context.Context, zone string, record Record) (*CreateRecordResponse, error) {
 	endpoint := c.baseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records")
 
-	body, err := json.Marshal(record)
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
 		return nil, err
 	}
 
-	req, err := c.newRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return nil, readError(req, resp)
-	}
-
-	content, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.New(toUnreadableBodyMessage(req, content))
-	}
-
-	// Everything looks good; but we'll need the ID later to delete the record
 	respData := &CreateRecordResponse{}
-	err = json.Unmarshal(content, respData)
+	err = c.do(req, respData)
 	if err != nil {
-		return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(req, content))
+		return nil, err
 	}
 
 	return respData, nil
@@ -77,60 +61,97 @@ func (c *Client) CreateRecord(zone string, record Record) (*CreateRecordResponse
 
 // DeleteRecord deletes a DNS record.
 // https://vercel.com/docs/rest-api#endpoints/dns/delete-a-dns-record
-func (c *Client) DeleteRecord(zone string, recordID string) error {
+func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID string) error {
 	endpoint := c.baseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", recordID)
 
-	req, err := c.newRequest(http.MethodDelete, endpoint.String(), nil)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
 		return err
 	}
 
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		return readError(req, resp)
-	}
-
-	return nil
+	return c.do(req, nil)
 }
 
-func (c *Client) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) {
-	req, err := http.NewRequest(method, reqURL, body)
-	if err != nil {
-		return nil, err
-	}
-
+func (c *Client) do(req *http.Request, result any) error {
 	if c.teamID != "" {
 		query := req.URL.Query()
 		query.Add("teamId", c.teamID)
 		req.URL.RawQuery = query.Encode()
 	}
 
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.authToken))
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
 
 	return req, nil
 }
 
-func readError(req *http.Request, resp *http.Response) error {
-	content, err := io.ReadAll(resp.Body)
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var response APIErrorResponse
+	err := json.Unmarshal(raw, &response)
 	if err != nil {
-		return errors.New(toUnreadableBodyMessage(req, content))
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
 	}
 
-	var errInfo APIErrorResponse
-	err = json.Unmarshal(content, &errInfo)
-	if err != nil {
-		return fmt.Errorf("API Error unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content))
+	return fmt.Errorf("[status code: %d] %w", resp.StatusCode, response.Error)
+}
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
 	}
 
-	return fmt.Errorf("HTTP %d: %w", resp.StatusCode, errInfo.Error)
-}
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
 
-func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
-	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody))
+	return client
 }
diff --git a/providers/dns/vercel/internal/client_test.go b/providers/dns/vercel/internal/client_test.go
index 0d759c77..771349b2 100644
--- a/providers/dns/vercel/internal/client_test.go
+++ b/providers/dns/vercel/internal/client_test.go
@@ -1,6 +1,8 @@
 package internal
 
 import (
+	"bytes"
+	"context"
 	"fmt"
 	"io"
 	"net/http"
@@ -12,23 +14,21 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setup(t *testing.T) (*Client, *http.ServeMux) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
 	server := httptest.NewServer(mux)
 	t.Cleanup(server.Close)
 
-	client := NewClient("secret", "123")
-
-	client.HTTPClient = server.Client()
+	client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123")
 	client.baseURL, _ = url.Parse(server.URL)
 
 	return client, mux
 }
 
 func TestClient_CreateRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodPost {
@@ -55,7 +55,7 @@ func TestClient_CreateRecord(t *testing.T) {
 		}
 
 		expectedReqBody := `{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}`
-		assert.Equal(t, expectedReqBody, string(reqBody))
+		assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody)))
 
 		rw.WriteHeader(http.StatusOK)
 		_, err = fmt.Fprintf(rw, `{
@@ -75,7 +75,7 @@ func TestClient_CreateRecord(t *testing.T) {
 		TTL:   60,
 	}
 
-	resp, err := client.CreateRecord("example.com.", record)
+	resp, err := client.CreateRecord(context.Background(), "example.com.", record)
 	require.NoError(t, err)
 
 	expected := &CreateRecordResponse{
@@ -87,7 +87,7 @@ func TestClient_CreateRecord(t *testing.T) {
 }
 
 func TestClient_DeleteRecord(t *testing.T) {
-	client, mux := setup(t)
+	client, mux := setupTest(t)
 
 	mux.HandleFunc("/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodDelete {
@@ -109,6 +109,6 @@ func TestClient_DeleteRecord(t *testing.T) {
 		rw.WriteHeader(http.StatusOK)
 	})
 
-	err := client.DeleteRecord("example.com.", "1234567")
+	err := client.DeleteRecord(context.Background(), "example.com.", "1234567")
 	require.NoError(t, err)
 }
diff --git a/providers/dns/vercel/vercel.go b/providers/dns/vercel/vercel.go
index 76ed7c96..efc401c4 100644
--- a/providers/dns/vercel/vercel.go
+++ b/providers/dns/vercel/vercel.go
@@ -2,6 +2,7 @@
 package vercel
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -82,11 +83,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("vercel: credentials missing")
 	}
 
-	client := internal.NewClient(config.AuthToken, config.TeamID)
-
-	if config.HTTPClient != nil {
-		client.HTTPClient = config.HTTPClient
-	}
+	client := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), config.TeamID)
 
 	return &DNSProvider{
 		config:    config,
@@ -107,7 +104,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("vercel: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("vercel: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	record := internal.Record{
@@ -117,7 +114,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:   d.config.TTL,
 	}
 
-	respData, err := d.client.CreateRecord(authZone, record)
+	respData, err := d.client.CreateRecord(context.Background(), authZone, record)
 	if err != nil {
 		return fmt.Errorf("vercel: %w", err)
 	}
@@ -135,7 +132,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("vercel: %w", err)
+		return fmt.Errorf("vercel: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// get the record's unique ID from when we created it
@@ -146,7 +143,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("vercel: unknown record ID for '%s'", info.EffectiveFQDN)
 	}
 
-	err = d.client.DeleteRecord(authZone, recordID)
+	err = d.client.DeleteRecord(context.Background(), authZone, recordID)
 	if err != nil {
 		return fmt.Errorf("vercel: %w", err)
 	}
diff --git a/providers/dns/versio/client.go b/providers/dns/versio/client.go
deleted file mode 100644
index b7ca67da..00000000
--- a/providers/dns/versio/client.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package versio
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-)
-
-const defaultBaseURL = "https://www.versio.nl/api/v1/"
-
-type dnsRecordsResponse struct {
-	Record dnsRecord `json:"domainInfo"`
-}
-
-type dnsRecord struct {
-	DNSRecords []record `json:"dns_records"`
-}
-
-type record struct {
-	Type     string `json:"type,omitempty"`
-	Name     string `json:"name,omitempty"`
-	Value    string `json:"value,omitempty"`
-	Priority int    `json:"prio,omitempty"`
-	TTL      int    `json:"ttl,omitempty"`
-}
-
-type dnsErrorResponse struct {
-	Error errorMessage `json:"error"`
-}
-
-type errorMessage struct {
-	Code    int    `json:"code,omitempty"`
-	Message string `json:"message,omitempty"`
-}
-
-func (d *DNSProvider) postDNSRecords(domain string, msg interface{}) error {
-	reqBody := &bytes.Buffer{}
-	err := json.NewEncoder(reqBody).Encode(msg)
-	if err != nil {
-		return err
-	}
-
-	endpoint := d.config.BaseURL.JoinPath("domains", domain, "update")
-
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), reqBody)
-	if err != nil {
-		return err
-	}
-
-	return d.do(req, nil)
-}
-
-func (d *DNSProvider) getDNSRecords(domain string) (*dnsRecordsResponse, error) {
-	endpoint := d.config.BaseURL.JoinPath("domains", domain)
-
-	query := endpoint.Query()
-	query.Set("show_dns_records", "true")
-	endpoint.RawQuery = query.Encode()
-
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
-	if err != nil {
-		return nil, err
-	}
-
-	// we'll need all the dns_records to add the new TXT record
-	respData := &dnsRecordsResponse{}
-	err = d.do(req, respData)
-	if err != nil {
-		return nil, err
-	}
-
-	return respData, nil
-}
-
-func (d *DNSProvider) do(req *http.Request, result interface{}) error {
-	req.Header.Set("Content-Type", "application/json")
-
-	if len(d.config.Username) > 0 && len(d.config.Password) > 0 {
-		req.SetBasicAuth(d.config.Username, d.config.Password)
-	}
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if resp != nil {
-		defer resp.Body.Close()
-	}
-	if err != nil {
-		return err
-	}
-
-	if resp.StatusCode >= http.StatusBadRequest {
-		var body []byte
-		body, err = io.ReadAll(resp.Body)
-		if err != nil {
-			return fmt.Errorf("%d: failed to read response body: %w", resp.StatusCode, err)
-		}
-
-		respError := &dnsErrorResponse{}
-		err = json.Unmarshal(body, respError)
-		if err != nil {
-			return fmt.Errorf("%d: request failed: %s", resp.StatusCode, string(body))
-		}
-		return fmt.Errorf("%d: request failed: %s", resp.StatusCode, respError.Error.Message)
-	}
-
-	if result != nil {
-		content, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return fmt.Errorf("request failed: %w", err)
-		}
-
-		if err = json.Unmarshal(content, result); err != nil {
-			return fmt.Errorf("%w: %s", err, content)
-		}
-	}
-
-	return nil
-}
diff --git a/providers/dns/versio/internal/client.go b/providers/dns/versio/internal/client.go
new file mode 100644
index 00000000..6f70aacd
--- /dev/null
+++ b/providers/dns/versio/internal/client.go
@@ -0,0 +1,149 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// DefaultBaseURL default API endpoint.
+const DefaultBaseURL = "https://www.versio.nl/api/v1/"
+
+// Client the API client for Versio DNS.
+type Client struct {
+	username string
+	password string
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(username string, password string) *Client {
+	baseURL, _ := url.Parse(DefaultBaseURL)
+
+	return &Client{
+		username:   username,
+		password:   password,
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// UpdateDomain updates domain information.
+// https://www.versio.nl/RESTapidoc/#api-Domains-Update
+func (c *Client) UpdateDomain(ctx context.Context, domain string, msg *DomainInfo) (*DomainInfoResponse, error) {
+	endpoint := c.BaseURL.JoinPath("domains", domain, "update")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, msg)
+	if err != nil {
+		return nil, err
+	}
+
+	respData := &DomainInfoResponse{}
+	err = c.do(req, respData)
+	if err != nil {
+		return nil, err
+	}
+
+	return respData, nil
+}
+
+// GetDomain gets domain information.
+// https://www.versio.nl/RESTapidoc/#api-Domains-Domain
+func (c *Client) GetDomain(ctx context.Context, domain string) (*DomainInfoResponse, error) {
+	endpoint := c.BaseURL.JoinPath("domains", domain)
+
+	query := endpoint.Query()
+	query.Set("show_dns_records", "true")
+	endpoint.RawQuery = query.Encode()
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	respData := &DomainInfoResponse{}
+	err = c.do(req, respData)
+	if err != nil {
+		return nil, err
+	}
+
+	return respData, nil
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	if c.username != "" && c.password != "" {
+		req.SetBasicAuth(c.username, c.password)
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if resp != nil {
+		defer func() { _ = resp.Body.Close() }()
+	}
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	if resp.StatusCode/100 != 2 {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	if err = json.Unmarshal(raw, result); err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	response := &ErrorResponse{}
+	err := json.Unmarshal(raw, response)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return fmt.Errorf("[status code: %d] %w", resp.StatusCode, response.Message)
+}
diff --git a/providers/dns/versio/internal/client_test.go b/providers/dns/versio/internal/client_test.go
new file mode 100644
index 00000000..f1015d28
--- /dev/null
+++ b/providers/dns/versio/internal/client_test.go
@@ -0,0 +1,179 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, pattern string, h http.HandlerFunc) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, h)
+
+	client := NewClient("user", "secret")
+	client.HTTPClient = server.Client()
+	client.BaseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func writeFixture(rw http.ResponseWriter, filename string) {
+	file, err := os.Open(filepath.Join("fixtures", filename))
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer func() { _ = file.Close() }()
+
+	_, _ = io.Copy(rw, file)
+}
+
+func TestClient_GetDomain(t *testing.T) {
+	client := setupTest(t, "/domains/example.com", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodGet {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		auth := req.Header.Get("Authorization")
+		if auth != "Basic dXNlcjpzZWNyZXQ=" {
+			http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
+			return
+		}
+
+		writeFixture(rw, "get-domain.json")
+	})
+
+	records, err := client.GetDomain(context.Background(), "example.com")
+	require.NoError(t, err)
+
+	expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{
+		{Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600},
+		{Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400},
+	}}}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_GetDomain_error(t *testing.T) {
+	client := setupTest(t, "/domains/example.com", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodGet {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		rw.WriteHeader(http.StatusUnauthorized)
+
+		writeFixture(rw, "get-domain-error.json")
+	})
+
+	_, err := client.GetDomain(context.Background(), "example.com")
+	require.ErrorAs(t, err, &ErrorMessage{})
+}
+
+func TestClient_UpdateDomain(t *testing.T) {
+	client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		auth := req.Header.Get("Authorization")
+		if auth != "Basic dXNlcjpzZWNyZXQ=" {
+			http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized)
+			return
+		}
+
+		writeFixture(rw, "update-domain.json")
+	})
+
+	msg := &DomainInfo{DNSRecords: []Record{
+		{Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600},
+		{Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400},
+	}}
+
+	records, err := client.UpdateDomain(context.Background(), "example.com", msg)
+	require.NoError(t, err)
+
+	expected := &DomainInfoResponse{DomainInfo: DomainInfo{DNSRecords: []Record{
+		{Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600},
+		{Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400},
+	}}}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_UpdateDomain_error(t *testing.T) {
+	client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != http.MethodPost {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		rw.WriteHeader(http.StatusUnauthorized)
+
+		writeFixture(rw, "update-domain.json")
+	})
+
+	msg := &DomainInfo{DNSRecords: []Record{
+		{Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600},
+		{Type: "TXT", Name: "example.com", Value: "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "ftp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "localhost.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "pop.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "smtp.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "www.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "dev.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "A", Name: "_domainkey.domain.com.example.com", Value: "185.13.227.159", Priority: 0, TTL: 14400},
+		{Type: "MX", Name: "example.com", Value: "spamfilter2.axc.eu", Priority: 0, TTL: 3600},
+		{Type: "A", Name: "redirect.example.com", Value: "localhost", Priority: 10, TTL: 14400},
+	}}
+
+	_, err := client.UpdateDomain(context.Background(), "example.com", msg)
+	require.ErrorAs(t, err, &ErrorMessage{})
+}
diff --git a/providers/dns/versio/internal/fixtures/README.md b/providers/dns/versio/internal/fixtures/README.md
new file mode 100644
index 00000000..b9564b0e
--- /dev/null
+++ b/providers/dns/versio/internal/fixtures/README.md
@@ -0,0 +1,5 @@
+
+Note: the snippets from the API documentation are wrong:
+invalid field type (ex: prio, TTL), and JSON format contains errors.
+
+So the files inside the fixtures have been partially adapted to fit the reality.
diff --git a/providers/dns/versio/internal/fixtures/get-domain-error.json b/providers/dns/versio/internal/fixtures/get-domain-error.json
new file mode 100644
index 00000000..3250fbc7
--- /dev/null
+++ b/providers/dns/versio/internal/fixtures/get-domain-error.json
@@ -0,0 +1,6 @@
+{
+  "error": {
+    "code": 401,
+    "message": "You are not authorized to access this resource. Did you supply the correct credentials and have you IP (xxxx:xxxx:xxx:xxxx:xxxx:xxxx:xxxx:xxxx) whitelisted for API use?"
+  }
+}
diff --git a/providers/dns/versio/internal/fixtures/get-domain.json b/providers/dns/versio/internal/fixtures/get-domain.json
new file mode 100644
index 00000000..40bb0b91
--- /dev/null
+++ b/providers/dns/versio/internal/fixtures/get-domain.json
@@ -0,0 +1,120 @@
+{
+  "domainInfo": {
+    "domain": "example.com",
+    "status": "OK",
+    "expire-date": "2020-10-01",
+    "registrant_id": "4334",
+    "reseller_id": "3253",
+    "category_id": "674",
+    "dnstemplate_id": "674",
+    "lock": false,
+    "auto_renew": false,
+    "epp_code": "3fFerggEg",
+    "ns": [],
+    "dns_management": true,
+    "dns_records": [
+      {
+        "type": "MX",
+        "name": "example.com",
+        "value": "fallback.axc.eu",
+        "prio": 20,
+        "ttl": 3600
+      },
+      {
+        "type": "TXT",
+        "name": "example.com",
+        "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"",
+        "prio": 0,
+        "ttl": 3600
+      },
+      {
+        "type": "A",
+        "name": "example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "ftp.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "localhost.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "pop.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "smtp.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "www.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "dev.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "_domainkey.domain.com.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "MX",
+        "name": "example.com",
+        "value": "spamfilter2.axc.eu",
+        "prio": 0,
+        "ttl": 3600
+      },
+      {
+        "type": "A",
+        "name": "redirect.example.com",
+        "value": "localhost",
+        "prio": 10,
+        "ttl": 14400
+      }
+    ],
+    "dns_redirections": [
+      {
+        "from": "redirect.example.com",
+        "destination": "http:\/\/www.google.nl"
+      }
+    ],
+    "dnssec_keys": [
+      {
+        "flags": 256,
+        "algorithm": 3,
+        "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8="
+      },
+      {
+        "flags": 257,
+        "algorithm": 8,
+        "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8="
+      }
+    ]
+  }
+}
diff --git a/providers/dns/versio/internal/fixtures/update-domain-error.json b/providers/dns/versio/internal/fixtures/update-domain-error.json
new file mode 100644
index 00000000..3250fbc7
--- /dev/null
+++ b/providers/dns/versio/internal/fixtures/update-domain-error.json
@@ -0,0 +1,6 @@
+{
+  "error": {
+    "code": 401,
+    "message": "You are not authorized to access this resource. Did you supply the correct credentials and have you IP (xxxx:xxxx:xxx:xxxx:xxxx:xxxx:xxxx:xxxx) whitelisted for API use?"
+  }
+}
diff --git a/providers/dns/versio/internal/fixtures/update-domain.json b/providers/dns/versio/internal/fixtures/update-domain.json
new file mode 100644
index 00000000..40bb0b91
--- /dev/null
+++ b/providers/dns/versio/internal/fixtures/update-domain.json
@@ -0,0 +1,120 @@
+{
+  "domainInfo": {
+    "domain": "example.com",
+    "status": "OK",
+    "expire-date": "2020-10-01",
+    "registrant_id": "4334",
+    "reseller_id": "3253",
+    "category_id": "674",
+    "dnstemplate_id": "674",
+    "lock": false,
+    "auto_renew": false,
+    "epp_code": "3fFerggEg",
+    "ns": [],
+    "dns_management": true,
+    "dns_records": [
+      {
+        "type": "MX",
+        "name": "example.com",
+        "value": "fallback.axc.eu",
+        "prio": 20,
+        "ttl": 3600
+      },
+      {
+        "type": "TXT",
+        "name": "example.com",
+        "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"",
+        "prio": 0,
+        "ttl": 3600
+      },
+      {
+        "type": "A",
+        "name": "example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "ftp.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "localhost.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "pop.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "smtp.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "www.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "dev.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "A",
+        "name": "_domainkey.domain.com.example.com",
+        "value": "185.13.227.159",
+        "prio": 0,
+        "ttl": 14400
+      },
+      {
+        "type": "MX",
+        "name": "example.com",
+        "value": "spamfilter2.axc.eu",
+        "prio": 0,
+        "ttl": 3600
+      },
+      {
+        "type": "A",
+        "name": "redirect.example.com",
+        "value": "localhost",
+        "prio": 10,
+        "ttl": 14400
+      }
+    ],
+    "dns_redirections": [
+      {
+        "from": "redirect.example.com",
+        "destination": "http:\/\/www.google.nl"
+      }
+    ],
+    "dnssec_keys": [
+      {
+        "flags": 256,
+        "algorithm": 3,
+        "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8="
+      },
+      {
+        "flags": 257,
+        "algorithm": 8,
+        "public_key": "AwEAAZKsuPDwO1+Usao2X1rgdFhdT3LAxy5cbRNFNEy1qsauwSIYov5SU4GlG6ylXIVQwHF5AWfbD7lcZzw1IlNegvaLnoirJjcYZhz4ppQU5+M/1hfH7aNZIsyz7AhHwX7gpOeUdGBXTiXQ3m7ksGccVQ79h7yl2fiBDCryBSf49vOTqo3dI7KZM48vmeqOxPth3ANMXzt6osHENGIchdGgIOVy5Y7AsVecL4V+lbn2t47fFfJ2O9PwuuDBzO0HCCT/mmYVsvZ33kgc7QPFKB3LojoXdHFHl1jCsC98phIVGzJR54H2xRohQvfC2WAXFEx+YNDW1yv7zQFrUVVMFwCe/E8="
+      }
+    ]
+  }
+}
diff --git a/providers/dns/versio/internal/types.go b/providers/dns/versio/internal/types.go
new file mode 100644
index 00000000..44a5c53d
--- /dev/null
+++ b/providers/dns/versio/internal/types.go
@@ -0,0 +1,32 @@
+package internal
+
+import "fmt"
+
+type DomainInfoResponse struct {
+	DomainInfo DomainInfo `json:"domainInfo"`
+}
+
+type DomainInfo struct {
+	DNSRecords []Record `json:"dns_records"`
+}
+
+type Record struct {
+	Type     string `json:"type,omitempty"`
+	Name     string `json:"name,omitempty"`
+	Value    string `json:"value,omitempty"`
+	Priority int    `json:"prio,omitempty"`
+	TTL      int    `json:"ttl,omitempty"`
+}
+
+type ErrorResponse struct {
+	Message ErrorMessage `json:"error"`
+}
+
+type ErrorMessage struct {
+	Code    int    `json:"code,omitempty"`
+	Message string `json:"message,omitempty"`
+}
+
+func (e ErrorMessage) Error() string {
+	return fmt.Sprintf("%d: %s", e.Code, e.Message)
+}
diff --git a/providers/dns/versio/versio.go b/providers/dns/versio/versio.go
index 6b84cf5d..bee7e526 100644
--- a/providers/dns/versio/versio.go
+++ b/providers/dns/versio/versio.go
@@ -2,6 +2,7 @@
 package versio
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -11,6 +12,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/versio/internal"
 )
 
 // Environment variables names.
@@ -42,9 +44,9 @@ type Config struct {
 
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
-	baseURL, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, defaultBaseURL))
+	baseURL, err := url.Parse(env.GetOrDefaultString(EnvEndpoint, internal.DefaultBaseURL))
 	if err != nil {
-		baseURL, _ = url.Parse(defaultBaseURL)
+		baseURL, _ = url.Parse(internal.DefaultBaseURL)
 	}
 
 	return &Config{
@@ -61,7 +63,9 @@ func NewDefaultConfig() *Config {
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
-	config       *Config
+	config *Config
+	client *internal.Client
+
 	dnsEntriesMu sync.Mutex
 }
 
@@ -91,7 +95,17 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("versio: the versio password is missing")
 	}
 
-	return &DNSProvider{config: config}, nil
+	client := internal.NewClient(config.Username, config.Password)
+
+	if config.BaseURL != nil {
+		client.BaseURL = config.BaseURL
+	}
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -106,30 +120,35 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("versio: %w", err)
+		return fmt.Errorf("versio: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// use mutex to prevent race condition from getDNSRecords until postDNSRecords
 	d.dnsEntriesMu.Lock()
 	defer d.dnsEntriesMu.Unlock()
 
+	ctx := context.Background()
+
 	zoneName := dns01.UnFqdn(authZone)
-	domains, err := d.getDNSRecords(zoneName)
+
+	domains, err := d.client.GetDomain(ctx, zoneName)
 	if err != nil {
 		return fmt.Errorf("versio: %w", err)
 	}
 
-	txtRecord := record{
+	txtRecord := internal.Record{
 		Type:  "TXT",
 		Name:  info.EffectiveFQDN,
 		Value: `"` + info.Value + `"`,
 		TTL:   d.config.TTL,
 	}
-	// Add new txtRercord to existing array of DNSRecords
-	msg := &domains.Record
+
+	// Add new txtRecord to existing array of DNSRecords.
+	// We'll need all the dns_records to add a new TXT record.
+	msg := &domains.DomainInfo
 	msg.DNSRecords = append(msg.DNSRecords, txtRecord)
 
-	err = d.postDNSRecords(zoneName, msg)
+	_, err = d.client.UpdateDomain(ctx, zoneName, msg)
 	if err != nil {
 		return fmt.Errorf("versio: %w", err)
 	}
@@ -142,28 +161,31 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("versio: %w", err)
+		return fmt.Errorf("versio: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// use mutex to prevent race condition from getDNSRecords until postDNSRecords
 	d.dnsEntriesMu.Lock()
 	defer d.dnsEntriesMu.Unlock()
 
+	ctx := context.Background()
+
 	zoneName := dns01.UnFqdn(authZone)
-	domains, err := d.getDNSRecords(zoneName)
+
+	domains, err := d.client.GetDomain(ctx, zoneName)
 	if err != nil {
 		return fmt.Errorf("versio: %w", err)
 	}
 
 	// loop through the existing entries and remove the specific record
-	msg := &dnsRecord{}
-	for _, e := range domains.Record.DNSRecords {
+	msg := &internal.DomainInfo{}
+	for _, e := range domains.DomainInfo.DNSRecords {
 		if e.Name != info.EffectiveFQDN {
 			msg.DNSRecords = append(msg.DNSRecords, e)
 		}
 	}
 
-	err = d.postDNSRecords(zoneName, msg)
+	_, err = d.client.UpdateDomain(ctx, zoneName, msg)
 	if err != nil {
 		return fmt.Errorf("versio: %w", err)
 	}
diff --git a/providers/dns/versio/versio_test.go b/providers/dns/versio/versio_test.go
index 7144d43a..09040ab4 100644
--- a/providers/dns/versio/versio_test.go
+++ b/providers/dns/versio/versio_test.go
@@ -135,12 +135,12 @@ func TestDNSProvider_Present(t *testing.T) {
 		{
 			desc:          "FailToFindZone",
 			handler:       muxFailToFindZone(),
-			expectedError: `versio: 401: request failed: ObjectDoesNotExist|Domain not found`,
+			expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`,
 		},
 		{
 			desc:          "FailToCreateTXT",
 			handler:       muxFailToCreateTXT(),
-			expectedError: `versio: 400: request failed: ProcessError|DNS record invalid type _acme-challenge.example.eu. TST`,
+			expectedError: `versio: [status code: 400] 400: ProcessError|DNS record invalid type _acme-challenge.example.eu. TST`,
 		},
 	}
 
@@ -182,7 +182,7 @@ func TestDNSProvider_CleanUp(t *testing.T) {
 		{
 			desc:          "FailToFindZone",
 			handler:       muxFailToFindZone(),
-			expectedError: `versio: 401: request failed: ObjectDoesNotExist|Domain not found`,
+			expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`,
 		},
 	}
 
diff --git a/providers/dns/vinyldns/mock_test.go b/providers/dns/vinyldns/mock_test.go
index b7f1e241..54fd8e21 100644
--- a/providers/dns/vinyldns/mock_test.go
+++ b/providers/dns/vinyldns/mock_test.go
@@ -11,7 +11,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func setup(t *testing.T) (*http.ServeMux, *DNSProvider) {
+func setupTest(t *testing.T) (*http.ServeMux, *DNSProvider) {
 	t.Helper()
 
 	mux := http.NewServeMux()
diff --git a/providers/dns/vinyldns/vinyldns.go b/providers/dns/vinyldns/vinyldns.go
index 56ce273d..dca58fb9 100644
--- a/providers/dns/vinyldns/vinyldns.go
+++ b/providers/dns/vinyldns/vinyldns.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
-	"github.com/go-acme/lego/v4/platform/wait"
 	"github.com/vinyldns/go-vinyldns/vinyldns"
 )
 
@@ -172,122 +171,3 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 	return d.config.PropagationTimeout, d.config.PollingInterval
 }
-
-func (d *DNSProvider) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) {
-	zoneName, hostName, err := splitDomain(fqdn)
-	if err != nil {
-		return nil, err
-	}
-
-	zone, err := d.client.ZoneByName(zoneName)
-	if err != nil {
-		return nil, err
-	}
-
-	allRecordSets, err := d.client.RecordSetsListAll(zone.ID, vinyldns.ListFilter{NameFilter: hostName})
-	if err != nil {
-		return nil, err
-	}
-
-	var recordSets []vinyldns.RecordSet
-	for _, i := range allRecordSets {
-		if i.Type == "TXT" {
-			recordSets = append(recordSets, i)
-		}
-	}
-
-	switch {
-	case len(recordSets) > 1:
-		return nil, fmt.Errorf("ambiguous recordset definition of %s", fqdn)
-	case len(recordSets) == 1:
-		return &recordSets[0], nil
-	default:
-		return nil, nil
-	}
-}
-
-func (d *DNSProvider) createRecordSet(fqdn string, records []vinyldns.Record) error {
-	zoneName, hostName, err := splitDomain(fqdn)
-	if err != nil {
-		return err
-	}
-
-	zone, err := d.client.ZoneByName(zoneName)
-	if err != nil {
-		return err
-	}
-
-	recordSet := vinyldns.RecordSet{
-		Name:    hostName,
-		ZoneID:  zone.ID,
-		Type:    "TXT",
-		TTL:     d.config.TTL,
-		Records: records,
-	}
-
-	resp, err := d.client.RecordSetCreate(&recordSet)
-	if err != nil {
-		return err
-	}
-
-	return d.waitForChanges("CreateRS", resp)
-}
-
-func (d *DNSProvider) updateRecordSet(recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error {
-	operation := "delete"
-	if len(recordSet.Records) < len(newRecords) {
-		operation = "add"
-	}
-
-	recordSet.Records = newRecords
-	recordSet.TTL = d.config.TTL
-
-	resp, err := d.client.RecordSetUpdate(recordSet)
-	if err != nil {
-		return err
-	}
-
-	return d.waitForChanges("UpdateRS - "+operation, resp)
-}
-
-func (d *DNSProvider) deleteRecordSet(existingRecord *vinyldns.RecordSet) error {
-	resp, err := d.client.RecordSetDelete(existingRecord.ZoneID, existingRecord.ID)
-	if err != nil {
-		return err
-	}
-
-	return d.waitForChanges("DeleteRS", resp)
-}
-
-func (d *DNSProvider) waitForChanges(operation string, resp *vinyldns.RecordSetUpdateResponse) error {
-	return wait.For("vinyldns", d.config.PropagationTimeout, d.config.PollingInterval,
-		func() (bool, error) {
-			change, err := d.client.RecordSetChange(resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID)
-			if err != nil {
-				return false, fmt.Errorf("failed to query change status: %w", err)
-			}
-
-			if change.Status == "Complete" {
-				return true, nil
-			}
-
-			return false, fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s",
-				operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID)
-		},
-	)
-}
-
-// splitDomain splits the hostname from the authoritative zone, and returns both parts.
-func splitDomain(fqdn string) (string, string, error) {
-	zone, err := dns01.FindZoneByFqdn(fqdn)
-	if err != nil {
-		return "", "", err
-	}
-
-	subDomain, err := dns01.ExtractSubDomain(fqdn, zone)
-	if err != nil {
-		return "", "", err
-	}
-
-	return zone, subDomain, nil
-}
diff --git a/providers/dns/vinyldns/vinyldns_test.go b/providers/dns/vinyldns/vinyldns_test.go
index 1f7e7d5a..c05b5a73 100644
--- a/providers/dns/vinyldns/vinyldns_test.go
+++ b/providers/dns/vinyldns/vinyldns_test.go
@@ -192,7 +192,7 @@ func TestDNSProvider_Present(t *testing.T) {
 		t.Run(test.desc, func(t *testing.T) {
 			t.Parallel()
 
-			mux, p := setup(t)
+			mux, p := setupTest(t)
 			mux.Handle("/", test.handler)
 
 			err := p.Present(targetDomain, "token"+test.keyAuth, test.keyAuth)
@@ -202,7 +202,7 @@ func TestDNSProvider_Present(t *testing.T) {
 }
 
 func TestDNSProvider_CleanUp(t *testing.T) {
-	mux, p := setup(t)
+	mux, p := setupTest(t)
 
 	mux.Handle("/", newMockRouter().
 		Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName").
diff --git a/providers/dns/vinyldns/wrapper.go b/providers/dns/vinyldns/wrapper.go
new file mode 100644
index 00000000..34b93e9e
--- /dev/null
+++ b/providers/dns/vinyldns/wrapper.go
@@ -0,0 +1,128 @@
+package vinyldns
+
+import (
+	"fmt"
+
+	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/platform/wait"
+	"github.com/vinyldns/go-vinyldns/vinyldns"
+)
+
+func (d *DNSProvider) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) {
+	zoneName, hostName, err := splitDomain(fqdn)
+	if err != nil {
+		return nil, err
+	}
+
+	zone, err := d.client.ZoneByName(zoneName)
+	if err != nil {
+		return nil, err
+	}
+
+	allRecordSets, err := d.client.RecordSetsListAll(zone.ID, vinyldns.ListFilter{NameFilter: hostName})
+	if err != nil {
+		return nil, err
+	}
+
+	var recordSets []vinyldns.RecordSet
+	for _, i := range allRecordSets {
+		if i.Type == "TXT" {
+			recordSets = append(recordSets, i)
+		}
+	}
+
+	switch {
+	case len(recordSets) > 1:
+		return nil, fmt.Errorf("ambiguous recordset definition of %s", fqdn)
+	case len(recordSets) == 1:
+		return &recordSets[0], nil
+	default:
+		return nil, nil
+	}
+}
+
+func (d *DNSProvider) createRecordSet(fqdn string, records []vinyldns.Record) error {
+	zoneName, hostName, err := splitDomain(fqdn)
+	if err != nil {
+		return err
+	}
+
+	zone, err := d.client.ZoneByName(zoneName)
+	if err != nil {
+		return err
+	}
+
+	recordSet := vinyldns.RecordSet{
+		Name:    hostName,
+		ZoneID:  zone.ID,
+		Type:    "TXT",
+		TTL:     d.config.TTL,
+		Records: records,
+	}
+
+	resp, err := d.client.RecordSetCreate(&recordSet)
+	if err != nil {
+		return err
+	}
+
+	return d.waitForChanges("CreateRS", resp)
+}
+
+func (d *DNSProvider) updateRecordSet(recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error {
+	operation := "delete"
+	if len(recordSet.Records) < len(newRecords) {
+		operation = "add"
+	}
+
+	recordSet.Records = newRecords
+	recordSet.TTL = d.config.TTL
+
+	resp, err := d.client.RecordSetUpdate(recordSet)
+	if err != nil {
+		return err
+	}
+
+	return d.waitForChanges("UpdateRS - "+operation, resp)
+}
+
+func (d *DNSProvider) deleteRecordSet(existingRecord *vinyldns.RecordSet) error {
+	resp, err := d.client.RecordSetDelete(existingRecord.ZoneID, existingRecord.ID)
+	if err != nil {
+		return err
+	}
+
+	return d.waitForChanges("DeleteRS", resp)
+}
+
+func (d *DNSProvider) waitForChanges(operation string, resp *vinyldns.RecordSetUpdateResponse) error {
+	return wait.For("vinyldns", d.config.PropagationTimeout, d.config.PollingInterval,
+		func() (bool, error) {
+			change, err := d.client.RecordSetChange(resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID)
+			if err != nil {
+				return false, fmt.Errorf("failed to query change status: %w", err)
+			}
+
+			if change.Status == "Complete" {
+				return true, nil
+			}
+
+			return false, fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s",
+				operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID)
+		},
+	)
+}
+
+// splitDomain splits the hostname from the authoritative zone, and returns both parts.
+func splitDomain(fqdn string) (string, string, error) {
+	zone, err := dns01.FindZoneByFqdn(fqdn)
+	if err != nil {
+		return "", "", fmt.Errorf("could not find zone for FDQN %q: %w", fqdn, err)
+	}
+
+	subDomain, err := dns01.ExtractSubDomain(fqdn, zone)
+	if err != nil {
+		return "", "", err
+	}
+
+	return zone, subDomain, nil
+}
diff --git a/providers/dns/vkcloud/internal/client.go b/providers/dns/vkcloud/internal/client.go
index a76293c3..5ced88d2 100644
--- a/providers/dns/vkcloud/internal/client.go
+++ b/providers/dns/vkcloud/internal/client.go
@@ -12,10 +12,10 @@ import (
 
 // Client VK client.
 type Client struct {
-	baseURL       *url.URL
 	openstack     *gophercloud.ProviderClient
 	authOpts      gophercloud.AuthOptions
 	authenticated bool
+	baseURL       *url.URL
 }
 
 // NewClient creates a Client.
@@ -36,18 +36,18 @@ func NewClient(endpoint string, authOpts gophercloud.AuthOptions) (*Client, erro
 	}
 
 	return &Client{
-		baseURL:   baseURL,
 		openstack: openstackClient,
 		authOpts:  authOpts,
+		baseURL:   baseURL,
 	}, nil
 }
 
 func (c *Client) ListZones() ([]DNSZone, error) {
+	endpoint := c.baseURL.JoinPath("/")
+
 	var zones []DNSZone
 	opts := &gophercloud.RequestOpts{JSONResponse: &zones}
 
-	endpoint := c.baseURL.JoinPath("/")
-
 	err := c.request(http.MethodGet, endpoint, opts)
 	if err != nil {
 		return nil, err
@@ -57,11 +57,11 @@ func (c *Client) ListZones() ([]DNSZone, error) {
 }
 
 func (c *Client) ListTXTRecords(zoneUUID string) ([]DNSTXTRecord, error) {
+	endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/")
+
 	var records []DNSTXTRecord
 	opts := &gophercloud.RequestOpts{JSONResponse: &records}
 
-	endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/")
-
 	err := c.request(http.MethodGet, endpoint, opts)
 	if err != nil {
 		return nil, err
@@ -71,13 +71,13 @@ func (c *Client) ListTXTRecords(zoneUUID string) ([]DNSTXTRecord, error) {
 }
 
 func (c *Client) CreateTXTRecord(zoneUUID string, record *DNSTXTRecord) error {
+	endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/")
+
 	opts := &gophercloud.RequestOpts{
 		JSONBody:     record,
 		JSONResponse: record,
 	}
 
-	endpoint := c.baseURL.JoinPath(zoneUUID, "txt", "/")
-
 	return c.request(http.MethodPost, endpoint, opts)
 }
 
diff --git a/providers/dns/vkcloud/vkcloud.go b/providers/dns/vkcloud/vkcloud.go
index 9d1b03d9..775f4005 100644
--- a/providers/dns/vkcloud/vkcloud.go
+++ b/providers/dns/vkcloud/vkcloud.go
@@ -17,8 +17,6 @@ const (
 	defaultDNSEndpoint      = "https://mcs.mail.ru/public-dns/v2/dns"
 )
 
-const defaultTTL = 60
-
 const defaultDomainName = "users"
 
 // Environment variables names.
@@ -58,7 +56,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 60),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
 	}
@@ -123,7 +121,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("vkcloud: %w", err)
+		return fmt.Errorf("vkcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	authZone = dns01.UnFqdn(authZone)
@@ -163,7 +161,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("vkcloud: %w", err)
+		return fmt.Errorf("vkcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	authZone = dns01.UnFqdn(authZone)
diff --git a/providers/dns/vscale/vscale.go b/providers/dns/vscale/vscale.go
index 31a2de04..fa81f58d 100644
--- a/providers/dns/vscale/vscale.go
+++ b/providers/dns/vscale/vscale.go
@@ -4,9 +4,11 @@
 package vscale
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
@@ -87,8 +89,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	}
 
 	client := selectel.NewClient(config.Token)
-	client.BaseURL = config.BaseURL
-	client.HTTPClient = config.HTTPClient
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+
+	var err error
+	client.BaseURL, err = url.Parse(config.BaseURL)
+	if err != nil {
+		return nil, fmt.Errorf("vscale: %w", err)
+	}
 
 	return &DNSProvider{config: config, client: client}, nil
 }
@@ -103,8 +112,10 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
+	ctx := context.Background()
+
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	domainObj, err := d.client.GetDomainByName(domain)
+	domainObj, err := d.client.GetDomainByName(ctx, domain)
 	if err != nil {
 		return fmt.Errorf("vscale: %w", err)
 	}
@@ -115,7 +126,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Name:    info.EffectiveFQDN,
 		Content: info.Value,
 	}
-	_, err = d.client.AddRecord(domainObj.ID, txtRecord)
+	_, err = d.client.AddRecord(ctx, domainObj.ID, txtRecord)
 	if err != nil {
 		return fmt.Errorf("vscale: %w", err)
 	}
@@ -129,13 +140,15 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	recordName := dns01.UnFqdn(info.EffectiveFQDN)
 
+	ctx := context.Background()
+
 	// TODO(ldez) replace domain by FQDN to follow CNAME.
-	domainObj, err := d.client.GetDomainByName(domain)
+	domainObj, err := d.client.GetDomainByName(ctx, domain)
 	if err != nil {
 		return fmt.Errorf("vscale: %w", err)
 	}
 
-	records, err := d.client.ListRecords(domainObj.ID)
+	records, err := d.client.ListRecords(ctx, domainObj.ID)
 	if err != nil {
 		return fmt.Errorf("vscale: %w", err)
 	}
@@ -144,7 +157,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	var lastErr error
 	for _, record := range records {
 		if record.Name == recordName {
-			err = d.client.DeleteRecord(domainObj.ID, record.ID)
+			err = d.client.DeleteRecord(ctx, domainObj.ID, record.ID)
 			if err != nil {
 				lastErr = fmt.Errorf("vscale: %w", err)
 			}
diff --git a/providers/dns/vultr/vultr.go b/providers/dns/vultr/vultr.go
index c238e6cb..f63abc5f 100644
--- a/providers/dns/vultr/vultr.go
+++ b/providers/dns/vultr/vultr.go
@@ -78,17 +78,10 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("vultr: credentials missing")
 	}
 
-	httpClient := config.HTTPClient
-	if httpClient == nil {
-		httpClient = &http.Client{
-			Timeout: config.HTTPTimeout,
-			Transport: &oauth2.Transport{
-				Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.APIKey}),
-			},
-		}
-	}
+	authClient := OAuthStaticAccessToken(config.HTTPClient, config.APIKey)
+	authClient.Timeout = config.HTTPTimeout
 
-	client := govultr.NewClient(httpClient)
+	client := govultr.NewClient(authClient)
 
 	return &DNSProvider{client: client, config: config}, nil
 }
@@ -228,3 +221,16 @@ func (d *DNSProvider) findTxtRecords(ctx context.Context, domain, fqdn string) (
 
 	return zoneDomain, records, nil
 }
+
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
+}
diff --git a/providers/dns/websupport/internal/client.go b/providers/dns/websupport/internal/client.go
index 4cb803a4..cc40e9de 100644
--- a/providers/dns/websupport/internal/client.go
+++ b/providers/dns/websupport/internal/client.go
@@ -2,6 +2,7 @@ package internal
 
 import (
 	"bytes"
+	"context"
 	"crypto/hmac"
 	"crypto/sha1"
 	"encoding/hex"
@@ -13,6 +14,8 @@ import (
 	"net/url"
 	"strconv"
 	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const defaultBaseURL = "https://rest.websupport.sk"
@@ -22,9 +25,10 @@ const StatusSuccess = "success"
 
 // Client a Websupport DNS API client.
 type Client struct {
-	apiKey     string
-	secretKey  string
-	BaseURL    string
+	apiKey    string
+	secretKey string
+
+	baseURL    *url.URL
 	HTTPClient *http.Client
 }
 
@@ -34,23 +38,22 @@ func NewClient(apiKey, secretKey string) (*Client, error) {
 		return nil, errors.New("credentials missing")
 	}
 
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
 		apiKey:     apiKey,
 		secretKey:  secretKey,
-		BaseURL:    defaultBaseURL,
+		baseURL:    baseURL,
 		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}, nil
 }
 
 // GetUser gets a user detail.
 // https://rest.websupport.sk/docs/v1.user#user
-func (c *Client) GetUser(userID string) (*User, error) {
-	endpoint, err := url.JoinPath(c.BaseURL, "v1", "user", userID)
-	if err != nil {
-		return nil, fmt.Errorf("base url parsing: %w", err)
-	}
+func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) {
+	endpoint := c.baseURL.JoinPath("v1", "user", userID)
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, http.NoBody)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("request payload: %w", err)
 	}
@@ -67,13 +70,10 @@ func (c *Client) GetUser(userID string) (*User, error) {
 
 // ListRecords lists all records.
 // https://rest.websupport.sk/docs/v1.zone#records
-func (c *Client) ListRecords(domainName string) (*ListResponse, error) {
-	endpoint, err := url.JoinPath(c.BaseURL, "v1", "user", "self", "zone", domainName, "record")
-	if err != nil {
-		return nil, fmt.Errorf("base url parsing: %w", err)
-	}
+func (c *Client) ListRecords(ctx context.Context, domainName string) (*ListResponse, error) {
+	endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record")
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, http.NoBody)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, fmt.Errorf("request payload: %w", err)
 	}
@@ -89,13 +89,10 @@ func (c *Client) ListRecords(domainName string) (*ListResponse, error) {
 }
 
 // GetRecords gets a DNS record.
-func (c *Client) GetRecords(domainName string, recordID int) (*Record, error) {
-	endpoint, err := url.JoinPath(c.BaseURL, "v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID))
-	if err != nil {
-		return nil, fmt.Errorf("base url parsing: %w", err)
-	}
+func (c *Client) GetRecords(ctx context.Context, domainName string, recordID int) (*Record, error) {
+	endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID))
 
-	req, err := http.NewRequest(http.MethodGet, endpoint, http.NoBody)
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -112,20 +109,12 @@ func (c *Client) GetRecords(domainName string, recordID int) (*Record, error) {
 
 // AddRecord adds a DNS record.
 // https://rest.websupport.sk/docs/v1.zone#post-record
-func (c *Client) AddRecord(domainName string, record Record) (*Response, error) {
-	endpoint, err := url.JoinPath(c.BaseURL, "v1", "user", "self", "zone", domainName, "record")
-	if err != nil {
-		return nil, fmt.Errorf("base url parsing: %w", err)
-	}
+func (c *Client) AddRecord(ctx context.Context, domainName string, record Record) (*Response, error) {
+	endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record")
 
-	payload, err := json.Marshal(record)
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
 	if err != nil {
-		return nil, fmt.Errorf("request payload: %w", err)
-	}
-
-	req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
-	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("create request: %w", err)
 	}
 
 	result := &Response{}
@@ -140,15 +129,12 @@ func (c *Client) AddRecord(domainName string, record Record) (*Response, error)
 
 // DeleteRecord deletes a DNS record.
 // https://rest.websupport.sk/docs/v1.zone#delete-record
-func (c *Client) DeleteRecord(domainName string, recordID int) (*Response, error) {
-	endpoint, err := url.JoinPath(c.BaseURL, "v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID))
-	if err != nil {
-		return nil, fmt.Errorf("base url parsing: %w", err)
-	}
+func (c *Client) DeleteRecord(ctx context.Context, domainName string, recordID int) (*Response, error) {
+	endpoint := c.baseURL.JoinPath("v1", "user", "self", "zone", domainName, "record", strconv.Itoa(recordID))
 
-	req, err := http.NewRequest(http.MethodDelete, endpoint, http.NoBody)
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 	if err != nil {
-		return nil, fmt.Errorf("request payload: %w", err)
+		return nil, fmt.Errorf("create request: %w", err)
 	}
 
 	result := &Response{}
@@ -162,8 +148,6 @@ func (c *Client) DeleteRecord(domainName string, recordID int) (*Response, error
 }
 
 func (c *Client) do(req *http.Request, result any) error {
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Accept", "application/json")
 	req.Header.Set("Accept-Language", "en_us")
 
 	location, err := time.LoadLocation("GMT")
@@ -178,31 +162,23 @@ func (c *Client) do(req *http.Request, result any) error {
 
 	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
 	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode > http.StatusBadRequest {
-		all, _ := io.ReadAll(resp.Body)
-
-		var e APIError
-		err = json.Unmarshal(all, &e)
-		if err != nil {
-			return fmt.Errorf("%d: %s", resp.StatusCode, string(all))
-		}
-
-		return &e
+		return parseError(req, resp)
 	}
 
-	all, err := io.ReadAll(resp.Body)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return fmt.Errorf("read response body: %w", err)
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	err = json.Unmarshal(all, result)
+	err = json.Unmarshal(raw, result)
 	if err != nil {
-		return fmt.Errorf("unmarshal response body: %w", err)
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 	}
 
 	return nil
@@ -230,3 +206,39 @@ func (c *Client) sign(req *http.Request, now time.Time) error {
 
 	return nil
 }
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errAPI APIError
+	err := json.Unmarshal(raw, &errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return &errAPI
+}
diff --git a/providers/dns/websupport/internal/client_test.go b/providers/dns/websupport/internal/client_test.go
index 312d74f3..9612f609 100644
--- a/providers/dns/websupport/internal/client_test.go
+++ b/providers/dns/websupport/internal/client_test.go
@@ -1,11 +1,13 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 
@@ -46,7 +48,7 @@ func setupTest(t *testing.T, method, pattern string, status int, file string) *C
 	require.NoError(t, err)
 
 	client.HTTPClient = server.Client()
-	client.BaseURL = server.URL
+	client.baseURL, _ = url.Parse(server.URL)
 
 	return client
 }
@@ -54,7 +56,7 @@ func setupTest(t *testing.T, method, pattern string, status int, file string) *C
 func TestClient_GetUser(t *testing.T) {
 	client := setupTest(t, http.MethodGet, "/v1/user/self", http.StatusOK, "./fixtures/get-user.json")
 
-	user, err := client.GetUser("self")
+	user, err := client.GetUser(context.Background(), "self")
 	require.NoError(t, err)
 
 	expected := &User{
@@ -89,7 +91,7 @@ func TestClient_GetUser(t *testing.T) {
 func TestClient_ListRecords(t *testing.T) {
 	client := setupTest(t, http.MethodGet, "/v1/user/self/zone/example.com/record", http.StatusOK, "./fixtures/list-records.json")
 
-	resp, err := client.ListRecords("example.com")
+	resp, err := client.ListRecords(context.Background(), "example.com")
 	require.NoError(t, err)
 
 	expected := &ListResponse{
@@ -124,7 +126,7 @@ func TestClient_AddRecord(t *testing.T) {
 		TTL:     600,
 	}
 
-	resp, err := client.AddRecord("example.com", record)
+	resp, err := client.AddRecord(context.Background(), "example.com", record)
 	require.NoError(t, err)
 
 	expected := &Response{
@@ -157,7 +159,7 @@ func TestClient_AddRecord_error_400(t *testing.T) {
 		TTL:     600,
 	}
 
-	resp, err := client.AddRecord("example.com", record)
+	resp, err := client.AddRecord(context.Background(), "example.com", record)
 	require.NoError(t, err)
 
 	assert.Equal(t, "error", resp.Status)
@@ -190,7 +192,7 @@ func TestClient_AddRecord_error_404(t *testing.T) {
 		TTL:     600,
 	}
 
-	resp, err := client.AddRecord("example.com", record)
+	resp, err := client.AddRecord(context.Background(), "example.com", record)
 	require.Error(t, err)
 
 	assert.Nil(t, resp)
@@ -199,7 +201,7 @@ func TestClient_AddRecord_error_404(t *testing.T) {
 func TestClient_DeleteRecord(t *testing.T) {
 	client := setupTest(t, http.MethodDelete, "/v1/user/self/zone/example.com/record/123", http.StatusOK, "./fixtures/delete-record.json")
 
-	resp, err := client.DeleteRecord("example.com", 123)
+	resp, err := client.DeleteRecord(context.Background(), "example.com", 123)
 	require.NoError(t, err)
 
 	expected := &Response{
@@ -225,7 +227,7 @@ func TestClient_DeleteRecord(t *testing.T) {
 func TestClient_DeleteRecord_error(t *testing.T) {
 	client := setupTest(t, http.MethodDelete, "/v1/user/self/zone/example.com/record/123", http.StatusNotFound, "./fixtures/delete-record-error-404.json")
 
-	resp, err := client.DeleteRecord("example.com", 123)
+	resp, err := client.DeleteRecord(context.Background(), "example.com", 123)
 	require.Error(t, err)
 
 	assert.Nil(t, resp)
diff --git a/providers/dns/websupport/internal/types.go b/providers/dns/websupport/internal/types.go
index cada90ce..0923282a 100644
--- a/providers/dns/websupport/internal/types.go
+++ b/providers/dns/websupport/internal/types.go
@@ -30,8 +30,8 @@ type Zone struct {
 }
 
 type Response struct {
-	Status string          `json:"status"`
 	Item   *Record         `json:"item"`
+	Status string          `json:"status"`
 	Errors json.RawMessage `json:"errors"`
 }
 
@@ -72,13 +72,13 @@ func (e *Errors) Error() string {
 
 // ParseError extract error from Response.
 func ParseError(resp *Response) error {
-	var apiError Errors
-	err := json.Unmarshal(resp.Errors, &apiError)
+	var errAPI Errors
+	err := json.Unmarshal(resp.Errors, &errAPI)
 	if err != nil {
 		return err
 	}
 
-	return &apiError
+	return &errAPI
 }
 
 type User struct {
diff --git a/providers/dns/websupport/websupport.go b/providers/dns/websupport/websupport.go
index c74cd142..a8b74010 100644
--- a/providers/dns/websupport/websupport.go
+++ b/providers/dns/websupport/websupport.go
@@ -2,6 +2,7 @@
 package websupport
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -13,8 +14,6 @@ import (
 	"github.com/go-acme/lego/v4/providers/dns/websupport/internal"
 )
 
-const defaultTTL = 600
-
 // Environment variables names.
 const (
 	envNamespace = "WEBSUPPORT_"
@@ -44,7 +43,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 600),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
 		SequenceInterval:   env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
@@ -106,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("websupport: %w", err)
+		return fmt.Errorf("websupport: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -121,7 +120,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		TTL:     d.config.TTL,
 	}
 
-	resp, err := d.client.AddRecord(dns01.UnFqdn(authZone), record)
+	resp, err := d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record)
 	if err != nil {
 		return fmt.Errorf("websupport: add record: %w", err)
 	}
@@ -148,7 +147,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("websupport: %w", err)
+		return fmt.Errorf("websupport: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	// gets the record's unique ID
@@ -159,7 +158,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("websupport: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
 	}
 
-	resp, err := d.client.DeleteRecord(dns01.UnFqdn(authZone), recordID)
+	resp, err := d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
 	if err != nil {
 		return fmt.Errorf("websupport: delete record: %w", err)
 	}
diff --git a/providers/dns/wedos/internal/client.go b/providers/dns/wedos/internal/client.go
index 2d0f94ac..defcabf6 100644
--- a/providers/dns/wedos/internal/client.go
+++ b/providers/dns/wedos/internal/client.go
@@ -11,61 +11,21 @@ import (
 	"time"
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 )
 
 const baseURL = "https://api.wedos.com/wapi/json"
 
-const codeOk = 1000
-
-const (
-	commandPing            = "ping"
-	commandDNSDomainCommit = "dns-domain-commit"
-	commandDNSRowsList     = "dns-rows-list"
-	commandDNSRowDelete    = "dns-row-delete"
-	commandDNSRowAdd       = "dns-row-add"
-	commandDNSRowUpdate    = "dns-row-update"
-)
-
-type ResponsePayload struct {
-	Code      int             `json:"code,omitempty"`
-	Result    string          `json:"result,omitempty"`
-	Timestamp int             `json:"timestamp,omitempty"`
-	SvTRID    string          `json:"svTRID,omitempty"`
-	Command   string          `json:"command,omitempty"`
-	Data      json.RawMessage `json:"data"`
-}
-
-type DNSRow struct {
-	ID   string      `json:"ID,omitempty"`
-	Name string      `json:"name,omitempty"`
-	TTL  json.Number `json:"ttl,omitempty" type:"integer"`
-	Type string      `json:"rdtype,omitempty"`
-	Data string      `json:"rdata"`
-}
-
-type DNSRowRequest struct {
-	ID     string      `json:"row_id,omitempty"`
-	Domain string      `json:"domain,omitempty"`
-	Name   string      `json:"name,omitempty"`
-	TTL    json.Number `json:"ttl,omitempty" type:"integer"`
-	Type   string      `json:"type,omitempty"`
-	Data   string      `json:"rdata"`
-}
-
-type APIRequest struct {
-	User    string      `json:"user,omitempty"`
-	Auth    string      `json:"auth,omitempty"`
-	Command string      `json:"command,omitempty"`
-	Data    interface{} `json:"data,omitempty"`
-}
-
+// Client the API client for Webos.
 type Client struct {
-	username   string
-	password   string
+	username string
+	password string
+
 	baseURL    string
 	HTTPClient *http.Client
 }
 
+// NewClient creates a new Client.
 func NewClient(username string, password string) *Client {
 	return &Client{
 		username:   username,
@@ -78,25 +38,23 @@ func NewClient(username string, password string) *Client {
 // GetRecords lists all the records in the zone.
 // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-rows-list/
 func (c *Client) GetRecords(ctx context.Context, zone string) ([]DNSRow, error) {
-	payload := map[string]interface{}{
+	payload := map[string]any{
 		"domain": dns01.UnFqdn(zone),
 	}
 
-	resp, err := c.do(ctx, commandDNSRowsList, payload)
+	req, err := c.newRequest(ctx, commandDNSRowsList, payload)
 	if err != nil {
 		return nil, err
 	}
 
-	arrayWrapper := struct {
-		Rows []DNSRow `json:"row"`
-	}{}
+	result := APIResponse[Rows]{}
 
-	err = json.Unmarshal(resp.Data, &arrayWrapper)
+	err = c.do(req, &result)
 	if err != nil {
 		return nil, err
 	}
 
-	return arrayWrapper.Rows, err
+	return result.Response.Data.Rows, err
 }
 
 // AddRecord adds a record in the zone, either by updating existing records or creating new ones.
@@ -118,12 +76,12 @@ func (c *Client) AddRecord(ctx context.Context, zone string, record DNSRow) erro
 		payload.ID = record.ID
 	}
 
-	_, err := c.do(ctx, cmd, payload)
+	req, err := c.newRequest(ctx, cmd, payload)
 	if err != nil {
 		return err
 	}
 
-	return nil
+	return c.do(req, &APIResponse[json.RawMessage]{})
 }
 
 // DeleteRecord deletes a record from the zone.
@@ -135,40 +93,67 @@ func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID string)
 		ID:     recordID,
 	}
 
-	_, err := c.do(ctx, commandDNSRowDelete, payload)
+	req, err := c.newRequest(ctx, commandDNSRowDelete, payload)
 	if err != nil {
 		return err
 	}
 
-	return nil
+	return c.do(req, &APIResponse[json.RawMessage]{})
 }
 
 // Commit not really required, all changes will be auto-committed after 5 minutes.
 // https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-domain-commit/
 func (c *Client) Commit(ctx context.Context, zone string) error {
-	payload := map[string]interface{}{
+	payload := map[string]any{
 		"name": dns01.UnFqdn(zone),
 	}
 
-	_, err := c.do(ctx, commandDNSDomainCommit, payload)
+	req, err := c.newRequest(ctx, commandDNSDomainCommit, payload)
 	if err != nil {
 		return err
 	}
 
-	return nil
+	return c.do(req, &APIResponse[json.RawMessage]{})
 }
 
 func (c *Client) Ping(ctx context.Context) error {
-	_, err := c.do(ctx, commandPing, nil)
+	req, err := c.newRequest(ctx, commandPing, nil)
 	if err != nil {
 		return err
 	}
 
-	return nil
+	return c.do(req, &APIResponse[json.RawMessage]{})
 }
 
-func (c *Client) do(ctx context.Context, command string, payload interface{}) (*ResponsePayload, error) {
-	requestObject := map[string]interface{}{
+func (c *Client) do(req *http.Request, result Response) error {
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	if result.GetCode() != codeOk {
+		return fmt.Errorf("error %d: %s", result.GetCode(), result.GetResult())
+	}
+
+	return err
+}
+
+func (c *Client) newRequest(ctx context.Context, command string, payload any) (*http.Request, error) {
+	requestObject := map[string]any{
 		"request": APIRequest{
 			User:    c.username,
 			Auth:    authToken(c.username, c.password),
@@ -177,46 +162,20 @@ func (c *Client) do(ctx context.Context, command string, payload interface{}) (*
 		},
 	}
 
-	jsonBytes, err := json.Marshal(requestObject)
+	object, err := json.Marshal(requestObject)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 	}
 
 	form := url.Values{}
-	form.Add("request", string(jsonBytes))
+	form.Add("request", string(object))
 
 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(form.Encode()))
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("unable to create request: %w", err)
 	}
+
 	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
 
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	if resp.StatusCode/100 != 2 {
-		return nil, fmt.Errorf("API error, status code: %d", resp.StatusCode)
-	}
-
-	responseWrapper := struct {
-		Response ResponsePayload `json:"response"`
-	}{}
-
-	err = json.Unmarshal(body, &responseWrapper)
-	if err != nil {
-		return nil, err
-	}
-
-	if responseWrapper.Response.Code != codeOk {
-		return nil, fmt.Errorf("wedos responded with error code %d = %s", responseWrapper.Response.Code, responseWrapper.Response.Result)
-	}
-
-	return &responseWrapper.Response, err
+	return req, nil
 }
diff --git a/providers/dns/wedos/internal/types.go b/providers/dns/wedos/internal/types.go
new file mode 100644
index 00000000..bb8194b8
--- /dev/null
+++ b/providers/dns/wedos/internal/types.go
@@ -0,0 +1,68 @@
+package internal
+
+import "encoding/json"
+
+const codeOk = 1000
+
+const (
+	commandPing            = "ping"
+	commandDNSDomainCommit = "dns-domain-commit"
+	commandDNSRowsList     = "dns-rows-list"
+	commandDNSRowDelete    = "dns-row-delete"
+	commandDNSRowAdd       = "dns-row-add"
+	commandDNSRowUpdate    = "dns-row-update"
+)
+
+type Response interface {
+	GetCode() int
+	GetResult() string
+}
+
+type APIResponse[D any] struct {
+	Response ResponsePayload[D] `json:"response"`
+}
+
+func (a APIResponse[D]) GetCode() int {
+	return a.Response.Code
+}
+
+func (a APIResponse[D]) GetResult() string {
+	return a.Response.Result
+}
+
+type ResponsePayload[D any] struct {
+	Code      int    `json:"code,omitempty"`
+	Result    string `json:"result,omitempty"`
+	Timestamp int    `json:"timestamp,omitempty"`
+	SvTRID    string `json:"svTRID,omitempty"`
+	Command   string `json:"command,omitempty"`
+	Data      D      `json:"data"`
+}
+
+type Rows struct {
+	Rows []DNSRow `json:"row"`
+}
+
+type DNSRow struct {
+	ID   string      `json:"ID,omitempty"`
+	Name string      `json:"name,omitempty"`
+	TTL  json.Number `json:"ttl,omitempty"`
+	Type string      `json:"rdtype,omitempty"`
+	Data string      `json:"rdata"`
+}
+
+type DNSRowRequest struct {
+	ID     string      `json:"row_id,omitempty"`
+	Domain string      `json:"domain,omitempty"`
+	Name   string      `json:"name,omitempty"`
+	TTL    json.Number `json:"ttl,omitempty"`
+	Type   string      `json:"type,omitempty"`
+	Data   string      `json:"rdata"`
+}
+
+type APIRequest struct {
+	User    string `json:"user,omitempty"`
+	Auth    string `json:"auth,omitempty"`
+	Command string `json:"command,omitempty"`
+	Data    any    `json:"data,omitempty"`
+}
diff --git a/providers/dns/wedos/wedos.go b/providers/dns/wedos/wedos.go
index 95e17302..8fffd3ad 100644
--- a/providers/dns/wedos/wedos.go
+++ b/providers/dns/wedos/wedos.go
@@ -108,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("wedos: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("wedos: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
@@ -156,7 +156,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("wedos: could not determine zone for domain %q: %w", domain, err)
+		return fmt.Errorf("wedos: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
diff --git a/providers/dns/yandex/internal/client.go b/providers/dns/yandex/internal/client.go
index acf98dc3..5d7e6bff 100644
--- a/providers/dns/yandex/internal/client.go
+++ b/providers/dns/yandex/internal/client.go
@@ -1,12 +1,17 @@
 package internal
 
 import (
+	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
-	"strings"
+	"net/url"
+	"time"
 
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
 	"github.com/google/go-querystring/query"
 )
 
@@ -17,119 +22,139 @@ const successCode = "ok"
 const pddTokenHeader = "PddToken"
 
 type Client struct {
+	pddToken string
+
+	baseURL    *url.URL
 	HTTPClient *http.Client
-	BaseURL    string
-	pddToken   string
 }
 
 func NewClient(pddToken string) (*Client, error) {
 	if pddToken == "" {
 		return nil, errors.New("PDD token is required")
 	}
+
+	baseURL, _ := url.Parse(defaultBaseURL)
+
 	return &Client{
-		HTTPClient: &http.Client{},
-		BaseURL:    defaultBaseURL,
 		pddToken:   pddToken,
+		baseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 10 * time.Second},
 	}, nil
 }
 
-func (c *Client) AddRecord(data Record) (*Record, error) {
-	resp, err := c.postForm("/add", data)
+func (c *Client) AddRecord(ctx context.Context, payload Record) (*Record, error) {
+	endpoint := c.baseURL.JoinPath("add")
+
+	req, err := newRequest(ctx, http.MethodPost, endpoint, payload)
 	if err != nil {
 		return nil, err
 	}
-	defer func() { _ = resp.Body.Close() }()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("API response error: %d", resp.StatusCode)
-	}
 
 	r := AddResponse{}
-	err = json.NewDecoder(resp.Body).Decode(&r)
+	err = c.do(req, &r)
 	if err != nil {
 		return nil, err
 	}
 
-	if r.Success != successCode {
-		return nil, fmt.Errorf("error during record addition: %s", r.Error)
-	}
-
 	return r.Record, nil
 }
 
-func (c *Client) RemoveRecord(data Record) (int, error) {
-	resp, err := c.postForm("/del", data)
+func (c *Client) RemoveRecord(ctx context.Context, payload Record) (int, error) {
+	endpoint := c.baseURL.JoinPath("del")
+
+	req, err := newRequest(ctx, http.MethodPost, endpoint, payload)
 	if err != nil {
 		return 0, err
 	}
-	defer func() { _ = resp.Body.Close() }()
 
 	r := RemoveResponse{}
-	err = json.NewDecoder(resp.Body).Decode(&r)
+	err = c.do(req, &r)
 	if err != nil {
 		return 0, err
 	}
 
-	if r.Success != successCode {
-		return 0, fmt.Errorf("error during record addition: %s", r.Error)
-	}
-
 	return r.RecordID, nil
 }
 
-func (c *Client) GetRecords(domain string) ([]Record, error) {
-	resp, err := c.get("/list", struct {
+func (c *Client) GetRecords(ctx context.Context, domain string) ([]Record, error) {
+	endpoint := c.baseURL.JoinPath("list")
+
+	payload := struct {
 		Domain string `url:"domain"`
-	}{Domain: domain})
+	}{Domain: domain}
+
+	req, err := newRequest(ctx, http.MethodGet, endpoint, payload)
 	if err != nil {
 		return nil, err
 	}
-	defer func() { _ = resp.Body.Close() }()
 
 	r := ListResponse{}
-	err = json.NewDecoder(resp.Body).Decode(&r)
+	err = c.do(req, &r)
 	if err != nil {
 		return nil, err
 	}
 
-	if r.Success != successCode {
-		return nil, fmt.Errorf("error during record addition: %s", r.Error)
-	}
-
 	return r.Records, nil
 }
 
-func (c *Client) postForm(uri string, data interface{}) (*http.Response, error) {
-	values, err := query.Values(data)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest(http.MethodPost, c.BaseURL+uri, strings.NewReader(values.Encode()))
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+func (c *Client) do(req *http.Request, result Response) error {
 	req.Header.Set(pddTokenHeader, c.pddToken)
 
-	return c.HTTPClient.Do(req)
-}
-
-func (c *Client) get(uri string, data interface{}) (*http.Response, error) {
-	req, err := http.NewRequest(http.MethodGet, c.BaseURL+uri, nil)
+	resp, err := c.HTTPClient.Do(req)
 	if err != nil {
-		return nil, err
+		return errutils.NewHTTPDoError(req, err)
 	}
 
-	req.Header.Set(pddTokenHeader, c.pddToken)
+	defer func() { _ = resp.Body.Close() }()
 
-	values, err := query.Values(data)
+	raw, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, err
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 	}
 
-	req.URL.RawQuery = values.Encode()
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
 
-	return c.HTTPClient.Do(req)
+	if result.GetSuccess() != successCode {
+		return fmt.Errorf("error during operation: %s %s", result.GetSuccess(), result.GetError())
+	}
+
+	return nil
+}
+
+func newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		switch method {
+		case http.MethodPost:
+			values, err := query.Values(payload)
+			if err != nil {
+				return nil, err
+			}
+
+			buf.WriteString(values.Encode())
+
+		case http.MethodGet:
+			values, err := query.Values(payload)
+			if err != nil {
+				return nil, err
+			}
+
+			endpoint.RawQuery = values.Encode()
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	if method == http.MethodPost {
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	}
+
+	return req, nil
 }
diff --git a/providers/dns/yandex/internal/client_test.go b/providers/dns/yandex/internal/client_test.go
index 7b7bb589..346b1ff6 100644
--- a/providers/dns/yandex/internal/client_test.go
+++ b/providers/dns/yandex/internal/client_test.go
@@ -1,16 +1,18 @@
 package internal
 
 import (
+	"context"
 	"encoding/json"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func setupTest(t *testing.T) (*http.ServeMux, *Client) {
+func setupTest(t *testing.T) (*Client, *http.ServeMux) {
 	t.Helper()
 
 	mux := http.NewServeMux()
@@ -21,9 +23,9 @@ func setupTest(t *testing.T) (*http.ServeMux, *Client) {
 	require.NoError(t, err)
 
 	client.HTTPClient = server.Client()
-	client.BaseURL = server.URL
+	client.baseURL, _ = url.Parse(server.URL)
 
-	return mux, client
+	return client, mux
 }
 
 func TestAddRecord(t *testing.T) {
@@ -58,7 +60,9 @@ func TestAddRecord(t *testing.T) {
 						Content:   "txtTXTtxtTXTtxtTXT",
 						TTL:       300,
 					},
-					Success: "ok",
+					BaseResponse: BaseResponse{
+						Success: "ok",
+					},
 				}
 
 				err = json.NewEncoder(w).Encode(response)
@@ -90,9 +94,11 @@ func TestAddRecord(t *testing.T) {
 				assert.Equal(t, `content=txtTXTtxtTXTtxtTXT&domain=example.com&subdomain=foo&ttl=300&type=TXT`, r.PostForm.Encode())
 
 				response := AddResponse{
-					Domain:  "example.com",
-					Success: "error",
-					Error:   "bad things",
+					Domain: "example.com",
+					BaseResponse: BaseResponse{
+						Success: "error",
+						Error:   "bad things",
+					},
 				}
 
 				err = json.NewEncoder(w).Encode(response)
@@ -114,11 +120,11 @@ func TestAddRecord(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			mux, client := setupTest(t)
+			client, mux := setupTest(t)
 
 			mux.HandleFunc("/add", test.handler)
 
-			record, err := client.AddRecord(test.data)
+			record, err := client.AddRecord(context.Background(), test.data)
 			if test.expectError {
 				require.Error(t, err)
 				require.Nil(t, record)
@@ -154,7 +160,9 @@ func TestRemoveRecord(t *testing.T) {
 				response := RemoveResponse{
 					Domain:   "example.com",
 					RecordID: 6,
-					Success:  "ok",
+					BaseResponse: BaseResponse{
+						Success: "ok",
+					},
 				}
 
 				err = json.NewEncoder(w).Encode(response)
@@ -185,8 +193,10 @@ func TestRemoveRecord(t *testing.T) {
 				response := RemoveResponse{
 					Domain:   "example.com",
 					RecordID: 6,
-					Success:  "error",
-					Error:    "bad things",
+					BaseResponse: BaseResponse{
+						Success: "error",
+						Error:   "bad things",
+					},
 				}
 
 				err = json.NewEncoder(w).Encode(response)
@@ -205,11 +215,11 @@ func TestRemoveRecord(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.desc, func(t *testing.T) {
-			mux, client := setupTest(t)
+			client, mux := setupTest(t)
 
 			mux.HandleFunc("/del", test.handler)
 
-			id, err := client.RemoveRecord(test.data)
+			id, err := client.RemoveRecord(context.Background(), test.data)
 			if test.expectError {
 				require.Error(t, err)
 				require.Equal(t, 0, id)
@@ -258,7 +268,9 @@ func TestGetRecords(t *testing.T) {
 							TTL:       300,
 						},
 					},
-					Success: "ok",
+					BaseResponse: BaseResponse{
+						Success: "ok",
+					},
 				}
 
 				err := json.NewEncoder(w).Encode(response)
@@ -278,9 +290,11 @@ func TestGetRecords(t *testing.T) {
 				assert.Equal(t, "domain=example.com", r.URL.RawQuery)
 
 				response := ListResponse{
-					Domain:  "example.com",
-					Success: "error",
-					Error:   "bad things",
+					Domain: "example.com",
+					BaseResponse: BaseResponse{
+						Success: "error",
+						Error:   "bad things",
+					},
 				}
 
 				err := json.NewEncoder(w).Encode(response)
@@ -298,11 +312,11 @@ func TestGetRecords(t *testing.T) {
 		test := test
 		t.Run(test.desc, func(t *testing.T) {
 			t.Parallel()
-			mux, client := setupTest(t)
+			client, mux := setupTest(t)
 
 			mux.HandleFunc("/list", test.handler)
 
-			records, err := client.GetRecords(test.domain)
+			records, err := client.GetRecords(context.Background(), test.domain)
 			if test.expectError {
 				require.Error(t, err)
 				require.Empty(t, records)
diff --git a/providers/dns/yandex/internal/types.go b/providers/dns/yandex/internal/types.go
index 3432d7eb..ed1873ce 100644
--- a/providers/dns/yandex/internal/types.go
+++ b/providers/dns/yandex/internal/types.go
@@ -10,23 +10,38 @@ type Record struct {
 	Content   string `json:"content,omitempty" url:"content,omitempty"`
 }
 
+type Response interface {
+	GetSuccess() string
+	GetError() string
+}
+
+type BaseResponse struct {
+	Success string `json:"success"`
+	Error   string `json:"error,omitempty"`
+}
+
+func (r BaseResponse) GetSuccess() string {
+	return r.Success
+}
+
+func (r BaseResponse) GetError() string {
+	return r.Error
+}
+
 type AddResponse struct {
-	Domain  string  `json:"domain,omitempty"`
-	Record  *Record `json:"record,omitempty"`
-	Success string  `json:"success"`
-	Error   string  `json:"error,omitempty"`
+	BaseResponse
+	Domain string  `json:"domain,omitempty"`
+	Record *Record `json:"record,omitempty"`
 }
 
 type RemoveResponse struct {
+	BaseResponse
 	Domain   string `json:"domain,omitempty"`
 	RecordID int    `json:"record_id,omitempty"`
-	Success  string `json:"success"`
-	Error    string `json:"error,omitempty"`
 }
 
 type ListResponse struct {
+	BaseResponse
 	Domain  string   `json:"domain,omitempty"`
 	Records []Record `json:"records,omitempty"`
-	Success string   `json:"success"`
-	Error   string   `json:"error,omitempty"`
 }
diff --git a/providers/dns/yandex/yandex.go b/providers/dns/yandex/yandex.go
index 3b1ac04a..e747be33 100644
--- a/providers/dns/yandex/yandex.go
+++ b/providers/dns/yandex/yandex.go
@@ -2,6 +2,7 @@
 package yandex
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -13,8 +14,6 @@ import (
 	"github.com/miekg/dns"
 )
 
-const defaultTTL = 21600
-
 // Environment variables names.
 const (
 	envNamespace = "YANDEX_"
@@ -39,7 +38,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 21600),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
 		HTTPClient: &http.Client{
@@ -106,7 +105,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		Content:   info.Value,
 	}
 
-	_, err = d.client.AddRecord(data)
+	_, err = d.client.AddRecord(context.Background(), data)
 	if err != nil {
 		return fmt.Errorf("yandex: %w", err)
 	}
@@ -123,7 +122,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("yandex: %w", err)
 	}
 
-	records, err := d.client.GetRecords(rootDomain)
+	ctx := context.Background()
+
+	records, err := d.client.GetRecords(ctx, rootDomain)
 	if err != nil {
 		return fmt.Errorf("yandex: %w", err)
 	}
@@ -146,7 +147,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		Domain: rootDomain,
 	}
 
-	_, err = d.client.RemoveRecord(data)
+	_, err = d.client.RemoveRecord(ctx, data)
 	if err != nil {
 		return fmt.Errorf("yandex: %w", err)
 	}
diff --git a/providers/dns/yandexcloud/yandexcloud.go b/providers/dns/yandexcloud/yandexcloud.go
index 22f77570..f30aef76 100644
--- a/providers/dns/yandexcloud/yandexcloud.go
+++ b/providers/dns/yandexcloud/yandexcloud.go
@@ -17,8 +17,6 @@ import (
 	"github.com/yandex-cloud/go-sdk/iamkey"
 )
 
-const defaultTTL = 60
-
 // Environment variables names.
 const (
 	envNamespace = "YANDEX_CLOUD_"
@@ -44,7 +42,7 @@ type Config struct {
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
 	return &Config{
-		TTL:                env.GetOrDefaultInt(EnvTTL, defaultTTL),
+		TTL:                env.GetOrDefaultInt(EnvTTL, 60),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
 	}
@@ -106,7 +104,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("yandexcloud: %w", err)
+		return fmt.Errorf("yandexcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	ctx := context.Background()
@@ -147,7 +145,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("yandexcloud: %w", err)
+		return fmt.Errorf("yandexcloud: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
 	ctx := context.Background()
diff --git a/providers/dns/zoneee/client.go b/providers/dns/zoneee/client.go
deleted file mode 100644
index a2f340d6..00000000
--- a/providers/dns/zoneee/client.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package zoneee
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-)
-
-const defaultEndpoint = "https://api.zone.eu/v2/dns/"
-
-type txtRecord struct {
-	// Identifier (identificator)
-	ID string `json:"id,omitempty"`
-	// Hostname
-	Name string `json:"name"`
-	// TXT content value
-	Destination string `json:"destination"`
-	// Can this record be deleted
-	Delete bool `json:"delete,omitempty"`
-	// Can this record be modified
-	Modify bool `json:"modify,omitempty"`
-	// API url to get this entity
-	ResourceURL string `json:"resource_url,omitempty"`
-}
-
-func (d *DNSProvider) addTxtRecord(domain string, record txtRecord) ([]txtRecord, error) {
-	reqBody := &bytes.Buffer{}
-	if err := json.NewEncoder(reqBody).Encode(record); err != nil {
-		return nil, err
-	}
-
-	endpoint := d.config.Endpoint.JoinPath(domain, "txt")
-
-	req, err := http.NewRequest(http.MethodPost, endpoint.String(), reqBody)
-	if err != nil {
-		return nil, err
-	}
-
-	var resp []txtRecord
-	if err := d.sendRequest(req, &resp); err != nil {
-		return nil, err
-	}
-	return resp, nil
-}
-
-func (d *DNSProvider) getTxtRecords(domain string) ([]txtRecord, error) {
-	endpoint := d.config.Endpoint.JoinPath(domain, "txt")
-
-	req, err := http.NewRequest(http.MethodGet, endpoint.String(), http.NoBody)
-	if err != nil {
-		return nil, err
-	}
-
-	var resp []txtRecord
-	if err := d.sendRequest(req, &resp); err != nil {
-		return nil, err
-	}
-	return resp, nil
-}
-
-func (d *DNSProvider) removeTxtRecord(domain, id string) error {
-	endpoint := d.config.Endpoint.JoinPath(domain, "txt", id)
-
-	req, err := http.NewRequest(http.MethodDelete, endpoint.String(), http.NoBody)
-	if err != nil {
-		return err
-	}
-
-	return d.sendRequest(req, nil)
-}
-
-func (d *DNSProvider) sendRequest(req *http.Request, result interface{}) error {
-	req.Header.Set("Content-Type", "application/json")
-	req.SetBasicAuth(d.config.Username, d.config.APIKey)
-
-	resp, err := d.config.HTTPClient.Do(req)
-	if err != nil {
-		return err
-	}
-
-	if err = checkResponse(resp); err != nil {
-		return err
-	}
-
-	defer resp.Body.Close()
-
-	if result == nil {
-		return nil
-	}
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return err
-	}
-
-	err = json.Unmarshal(raw, result)
-	if err != nil {
-		return fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", result, resp.StatusCode, err, string(raw))
-	}
-	return err
-}
-
-func checkResponse(resp *http.Response) error {
-	if resp.StatusCode < http.StatusBadRequest {
-		return nil
-	}
-
-	if resp.Body == nil {
-		return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode)
-	}
-
-	defer resp.Body.Close()
-
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err)
-	}
-
-	return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw))
-}
diff --git a/providers/dns/zoneee/internal/client.go b/providers/dns/zoneee/internal/client.go
new file mode 100644
index 00000000..e4463b83
--- /dev/null
+++ b/providers/dns/zoneee/internal/client.go
@@ -0,0 +1,142 @@
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+// DefaultEndpoint the default API endpoint.
+const DefaultEndpoint = "https://api.zone.eu/v2/"
+
+// Client the API client for Zoneee.
+type Client struct {
+	username string
+	apiKey   string
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(username string, apiKey string) *Client {
+	baseURL, _ := url.Parse(DefaultEndpoint)
+
+	return &Client{
+		username:   username,
+		apiKey:     apiKey,
+		BaseURL:    baseURL,
+		HTTPClient: &http.Client{Timeout: 5 * time.Second},
+	}
+}
+
+// GetTxtRecords get TXT records.
+// https://api.zone.eu/v2#operation/getdnstxtrecords
+func (c *Client) GetTxtRecords(ctx context.Context, domain string) ([]TXTRecord, error) {
+	endpoint := c.BaseURL.JoinPath("dns", domain, "txt")
+
+	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, http.NoBody)
+	if err != nil {
+		return nil, err
+	}
+
+	var records []TXTRecord
+	if err := c.do(req, &records); err != nil {
+		return nil, err
+	}
+
+	return records, nil
+}
+
+// AddTxtRecord creates a TXT records.
+// https://api.zone.eu/v2#operation/creatednstxtrecord
+func (c *Client) AddTxtRecord(ctx context.Context, domain string, record TXTRecord) ([]TXTRecord, error) {
+	endpoint := c.BaseURL.JoinPath("dns", domain, "txt")
+
+	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return nil, err
+	}
+
+	var records []TXTRecord
+	if err := c.do(req, &records); err != nil {
+		return nil, err
+	}
+
+	return records, nil
+}
+
+// RemoveTxtRecord deletes a TXT record.
+// https://api.zone.eu/v2#operation/deletednstxtrecord
+func (c *Client) RemoveTxtRecord(ctx context.Context, domain, id string) error {
+	endpoint := c.BaseURL.JoinPath("dns", domain, "txt", id)
+
+	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	req.SetBasicAuth(c.username, c.apiKey)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	return req, nil
+}
diff --git a/providers/dns/zoneee/internal/client_test.go b/providers/dns/zoneee/internal/client_test.go
new file mode 100644
index 00000000..9e53117a
--- /dev/null
+++ b/providers/dns/zoneee/internal/client_test.go
@@ -0,0 +1,90 @@
+package internal
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, method, pattern string, status int, file string) *Client {
+	t.Helper()
+
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+	t.Cleanup(server.Close)
+
+	mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest)
+			return
+		}
+
+		if file == "" {
+			rw.WriteHeader(status)
+			return
+		}
+
+		open, err := os.Open(filepath.Join("fixtures", file))
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		defer func() { _ = open.Close() }()
+
+		rw.WriteHeader(status)
+		_, err = io.Copy(rw, open)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+
+	client := NewClient("user", "secret")
+	client.HTTPClient = server.Client()
+	client.BaseURL, _ = url.Parse(server.URL)
+
+	return client
+}
+
+func TestClient_GetTxtRecords(t *testing.T) {
+	client := setupTest(t, http.MethodGet, "/dns/example.com/txt", http.StatusOK, "get-txt-records.json")
+
+	records, err := client.GetTxtRecords(context.Background(), "example.com")
+	require.NoError(t, err)
+
+	expected := []TXTRecord{
+		{ID: "123", Name: "prefix.example.com", Destination: "server.example.com", Delete: true, Modify: true, ResourceURL: "string"},
+	}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_AddTxtRecord(t *testing.T) {
+	client := setupTest(t, http.MethodPost, "/dns/example.com/txt", http.StatusCreated, "create-txt-record.json")
+
+	records, err := client.AddTxtRecord(context.Background(), "example.com", TXTRecord{Name: "prefix.example.com", Destination: "server.example.com"})
+	require.NoError(t, err)
+
+	expected := []TXTRecord{
+		{ID: "123", Name: "prefix.example.com", Destination: "server.example.com", Delete: true, Modify: true, ResourceURL: "string"},
+	}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_RemoveTxtRecord(t *testing.T) {
+	client := setupTest(t, http.MethodDelete, "/dns/example.com/txt/123", http.StatusNoContent, "")
+
+	err := client.RemoveTxtRecord(context.Background(), "example.com", "123")
+	require.NoError(t, err)
+}
diff --git a/providers/dns/zoneee/internal/fixtures/create-txt-record.json b/providers/dns/zoneee/internal/fixtures/create-txt-record.json
new file mode 100644
index 00000000..53af789a
--- /dev/null
+++ b/providers/dns/zoneee/internal/fixtures/create-txt-record.json
@@ -0,0 +1,10 @@
+[
+  {
+    "resource_url": "string",
+    "destination": "server.example.com",
+    "id": "123",
+    "name": "prefix.example.com",
+    "delete": true,
+    "modify": true
+  }
+]
diff --git a/providers/dns/zoneee/internal/fixtures/get-txt-records.json b/providers/dns/zoneee/internal/fixtures/get-txt-records.json
new file mode 100644
index 00000000..53af789a
--- /dev/null
+++ b/providers/dns/zoneee/internal/fixtures/get-txt-records.json
@@ -0,0 +1,10 @@
+[
+  {
+    "resource_url": "string",
+    "destination": "server.example.com",
+    "id": "123",
+    "name": "prefix.example.com",
+    "delete": true,
+    "modify": true
+  }
+]
diff --git a/providers/dns/zoneee/internal/types.go b/providers/dns/zoneee/internal/types.go
new file mode 100644
index 00000000..f086a85c
--- /dev/null
+++ b/providers/dns/zoneee/internal/types.go
@@ -0,0 +1,16 @@
+package internal
+
+type TXTRecord struct {
+	// Identifier (identificator)
+	ID string `json:"id,omitempty"`
+	// Hostname
+	Name string `json:"name"`
+	// TXT content value
+	Destination string `json:"destination"`
+	// Can this record be deleted
+	Delete bool `json:"delete,omitempty"`
+	// Can this record be modified
+	Modify bool `json:"modify,omitempty"`
+	// API url to get this entity
+	ResourceURL string `json:"resource_url,omitempty"`
+}
diff --git a/providers/dns/zoneee/zoneee.go b/providers/dns/zoneee/zoneee.go
index 2d764bfb..b0f0e5ab 100644
--- a/providers/dns/zoneee/zoneee.go
+++ b/providers/dns/zoneee/zoneee.go
@@ -2,6 +2,7 @@
 package zoneee
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/zoneee/internal"
 )
 
 // Environment variables names.
@@ -37,7 +39,7 @@ type Config struct {
 
 // NewDefaultConfig returns a default configuration for the DNSProvider.
 func NewDefaultConfig() *Config {
-	endpoint, _ := url.Parse(defaultEndpoint)
+	endpoint, _ := url.Parse(internal.DefaultEndpoint)
 
 	return &Config{
 		Endpoint: endpoint,
@@ -53,6 +55,7 @@ func NewDefaultConfig() *Config {
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance.
@@ -62,7 +65,7 @@ func NewDNSProvider() (*DNSProvider, error) {
 		return nil, fmt.Errorf("zoneee: %w", err)
 	}
 
-	rawEndpoint := env.GetOrDefaultString(EnvEndpoint, defaultEndpoint)
+	rawEndpoint := env.GetOrDefaultString(EnvEndpoint, internal.DefaultEndpoint)
 	endpoint, err := url.Parse(rawEndpoint)
 	if err != nil {
 		return nil, fmt.Errorf("zoneee: %w", err)
@@ -94,7 +97,16 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 		return nil, errors.New("zoneee: the endpoint is missing")
 	}
 
-	return &DNSProvider{config: config}, nil
+	client := internal.NewClient(config.Username, config.APIKey)
+
+	if config.HTTPClient != nil {
+		client.HTTPClient = config.HTTPClient
+	}
+	if config.Endpoint != nil {
+		client.BaseURL = config.Endpoint
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
 }
 
 // Timeout returns the timeout and interval to use when checking for DNS propagation.
@@ -107,17 +119,19 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	record := txtRecord{
+	record := internal.TXTRecord{
 		Name:        dns01.UnFqdn(info.EffectiveFQDN),
 		Destination: info.Value,
 	}
 
-	authZone, err := getHostedZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("zoneee: %w", err)
+		return fmt.Errorf("zoneee: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	_, err = d.addTxtRecord(authZone, record)
+	authZone = dns01.UnFqdn(authZone)
+
+	_, err = d.client.AddTxtRecord(context.Background(), authZone, record)
 	if err != nil {
 		return fmt.Errorf("zoneee: %w", err)
 	}
@@ -128,12 +142,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	authZone, err := getHostedZone(info.EffectiveFQDN)
+	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
-		return fmt.Errorf("zoneee: %w", err)
+		return fmt.Errorf("zoneee: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
 	}
 
-	records, err := d.getTxtRecords(authZone)
+	authZone = dns01.UnFqdn(authZone)
+
+	ctx := context.Background()
+
+	records, err := d.client.GetTxtRecords(ctx, authZone)
 	if err != nil {
 		return fmt.Errorf("zoneee: %w", err)
 	}
@@ -149,18 +167,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("zoneee: txt record does not exist for %s", info.Value)
 	}
 
-	if err = d.removeTxtRecord(authZone, id); err != nil {
+	if err = d.client.RemoveTxtRecord(ctx, authZone, id); err != nil {
 		return fmt.Errorf("zoneee: %w", err)
 	}
 
 	return nil
 }
-
-func getHostedZone(domain string) (string, error) {
-	authZone, err := dns01.FindZoneByFqdn(domain)
-	if err != nil {
-		return "", err
-	}
-
-	return dns01.UnFqdn(authZone), nil
-}
diff --git a/providers/dns/zoneee/zoneee_test.go b/providers/dns/zoneee/zoneee_test.go
index cc67fac7..7a284266 100644
--- a/providers/dns/zoneee/zoneee_test.go
+++ b/providers/dns/zoneee/zoneee_test.go
@@ -6,10 +6,12 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"path"
 	"testing"
 	"time"
 
 	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/go-acme/lego/v4/providers/dns/zoneee/internal"
 	"github.com/stretchr/testify/require"
 )
 
@@ -122,7 +124,7 @@ func TestNewDNSProviderConfig(t *testing.T) {
 			config.APIKey = test.apiKey
 			config.Username = test.apiUser
 
-			if len(test.endpoint) > 0 {
+			if test.endpoint != "" {
 				config.Endpoint = mustParse(test.endpoint)
 			}
 
@@ -155,7 +157,7 @@ func TestDNSProvider_Present(t *testing.T) {
 			username: "bar",
 			apiKey:   "foo",
 			handlers: map[string]http.HandlerFunc{
-				"/" + hostedZone + "/txt": mockHandlerCreateRecord,
+				path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord,
 			},
 		},
 		{
@@ -163,15 +165,15 @@ func TestDNSProvider_Present(t *testing.T) {
 			username: "nope",
 			apiKey:   "foo",
 			handlers: map[string]http.HandlerFunc{
-				"/" + hostedZone + "/txt": mockHandlerCreateRecord,
+				path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord,
 			},
-			expectedError: "zoneee: status code=401: Unauthorized\n",
+			expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized",
 		},
 		{
 			desc:          "error",
 			username:      "bar",
 			apiKey:        "foo",
-			expectedError: "zoneee: status code=404: 404 page not found\n",
+			expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found",
 		},
 	}
 
@@ -181,13 +183,14 @@ func TestDNSProvider_Present(t *testing.T) {
 			t.Parallel()
 
 			mux := http.NewServeMux()
-			for uri, handler := range test.handlers {
-				mux.HandleFunc(uri, handler)
-			}
-
 			server := httptest.NewServer(mux)
 			t.Cleanup(server.Close)
 
+			for uri, handler := range test.handlers {
+				handler := handler
+				mux.HandleFunc(uri, handler)
+			}
+
 			config := NewDefaultConfig()
 			config.Endpoint = mustParse(server.URL)
 			config.Username = test.username
@@ -222,14 +225,14 @@ func TestDNSProvider_Cleanup(t *testing.T) {
 			username: "bar",
 			apiKey:   "foo",
 			handlers: map[string]http.HandlerFunc{
-				"/" + hostedZone + "/txt": mockHandlerGetRecords([]txtRecord{{
+				path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{
 					ID:          "1234",
 					Name:        domain,
 					Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM",
 					Delete:      true,
 					Modify:      true,
 				}}),
-				"/" + hostedZone + "/txt/1234": mockHandlerDeleteRecord,
+				path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord,
 			},
 		},
 		{
@@ -237,8 +240,8 @@ func TestDNSProvider_Cleanup(t *testing.T) {
 			username: "bar",
 			apiKey:   "foo",
 			handlers: map[string]http.HandlerFunc{
-				"/" + hostedZone + "/txt":      mockHandlerGetRecords([]txtRecord{}),
-				"/" + hostedZone + "/txt/1234": mockHandlerDeleteRecord,
+				path.Join("/", "dns", hostedZone, "txt"):         mockHandlerGetRecords([]internal.TXTRecord{}),
+				path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord,
 			},
 			expectedError: "zoneee: txt record does not exist for LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM",
 		},
@@ -247,22 +250,22 @@ func TestDNSProvider_Cleanup(t *testing.T) {
 			username: "nope",
 			apiKey:   "foo",
 			handlers: map[string]http.HandlerFunc{
-				"/" + hostedZone + "/txt": mockHandlerGetRecords([]txtRecord{{
+				path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{
 					ID:          "1234",
 					Name:        domain,
 					Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM",
 					Delete:      true,
 					Modify:      true,
 				}}),
-				"/" + hostedZone + "/txt/1234": mockHandlerDeleteRecord,
+				path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord,
 			},
-			expectedError: "zoneee: status code=401: Unauthorized\n",
+			expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized",
 		},
 		{
 			desc:          "error",
 			username:      "bar",
 			apiKey:        "foo",
-			expectedError: "zoneee: status code=404: 404 page not found\n",
+			expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found",
 		},
 	}
 
@@ -272,13 +275,14 @@ func TestDNSProvider_Cleanup(t *testing.T) {
 			t.Parallel()
 
 			mux := http.NewServeMux()
-			for uri, handler := range test.handlers {
-				mux.HandleFunc(uri, handler)
-			}
-
 			server := httptest.NewServer(mux)
 			t.Cleanup(server.Close)
 
+			for uri, handler := range test.handlers {
+				handler := handler
+				mux.HandleFunc(uri, handler)
+			}
+
 			config := NewDefaultConfig()
 			config.Endpoint = mustParse(server.URL)
 			config.Username = test.username
@@ -346,7 +350,7 @@ func mockHandlerCreateRecord(rw http.ResponseWriter, req *http.Request) {
 		return
 	}
 
-	record := txtRecord{}
+	record := internal.TXTRecord{}
 	err := json.NewDecoder(req.Body).Decode(&record)
 	if err != nil {
 		http.Error(rw, err.Error(), http.StatusBadRequest)
@@ -358,7 +362,7 @@ func mockHandlerCreateRecord(rw http.ResponseWriter, req *http.Request) {
 	record.Modify = true
 	record.ResourceURL = req.URL.String() + "/1234"
 
-	bytes, err := json.Marshal([]txtRecord{record})
+	bytes, err := json.Marshal([]internal.TXTRecord{record})
 	if err != nil {
 		http.Error(rw, err.Error(), http.StatusInternalServerError)
 		return
@@ -370,7 +374,7 @@ func mockHandlerCreateRecord(rw http.ResponseWriter, req *http.Request) {
 	}
 }
 
-func mockHandlerGetRecords(records []txtRecord) http.HandlerFunc {
+func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc {
 	return func(rw http.ResponseWriter, req *http.Request) {
 		if req.Method != http.MethodGet {
 			http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
diff --git a/providers/dns/zonomi/zonomi.go b/providers/dns/zonomi/zonomi.go
index 740d32a0..5d1a2c79 100644
--- a/providers/dns/zonomi/zonomi.go
+++ b/providers/dns/zonomi/zonomi.go
@@ -2,6 +2,7 @@
 package zonomi
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -96,20 +97,22 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	records, err := d.client.FindTXTRecords(dns01.UnFqdn(info.EffectiveFQDN))
+	ctx := context.Background()
+
+	records, err := d.client.FindTXTRecords(ctx, dns01.UnFqdn(info.EffectiveFQDN))
 	if err != nil {
 		return fmt.Errorf("zonomi: failed to find record(s) for %s: %w", domain, err)
 	}
 
 	actions := []rimuhosting.ActionParameter{
-		rimuhosting.AddRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL),
+		rimuhosting.NewAddRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value, d.config.TTL),
 	}
 
 	for _, record := range records {
-		actions = append(actions, rimuhosting.AddRecord(record.Name, record.Content, d.config.TTL))
+		actions = append(actions, rimuhosting.NewAddRecordAction(record.Name, record.Content, d.config.TTL))
 	}
 
-	_, err = d.client.DoActions(actions...)
+	_, err = d.client.DoActions(ctx, actions...)
 	if err != nil {
 		return fmt.Errorf("zonomi: failed to add record(s) for %s: %w", domain, err)
 	}
@@ -121,9 +124,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
-	action := rimuhosting.DeleteRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
+	action := rimuhosting.NewDeleteRecordAction(dns01.UnFqdn(info.EffectiveFQDN), info.Value)
 
-	_, err := d.client.DoActions(action)
+	_, err := d.client.DoActions(context.Background(), action)
 	if err != nil {
 		return fmt.Errorf("zonomi: failed to delete record for %s: %w", domain, err)
 	}