diff --git a/dns/exception.py b/dns/exception.py
index 60b4912..cbcdb57 100644
--- a/dns/exception.py
+++ b/dns/exception.py
@@ -15,14 +15,86 @@
 
 """Common DNS Exceptions."""
 
+
 class DNSException(Exception):
-    """Abstract base class shared by all dnspython exceptions."""
-    def __init__(self, *args):
+    """Abstract base class shared by all dnspython exceptions.
+
+    It supports two basic modes of operation:
+
+    a) Old/compatible mode is used if __init__ was called with
+       empty **kwargs.
+    In compatible mode all *args are passed to standard Python Exception class
+    as before and all *args are printed by standard __str__ implementation.
+    Class variable msg (or doc string if msg is None) is returned from str()
+    if *args is empty.
+
+    b) New/parametrized mode is used if __init__ was called with
+       non-empty **kwargs.
+    In the new mode *args has to be empty and all kwargs has to exactly match
+    set in class variable self.supp_kwargs. All kwargs are stored inside
+    self.kwargs and used in new __str__ implementation to construct
+    formated message based on self.fmt string.
+
+    In the simplest case it is enough to override supp_kwargs and fmt
+    class variables to get nice parametrized messages.
+    """
+    msg = None  # non-parametrized message
+    supp_kwargs = set()  # accepted parameters for _fmt_kwargs (sanity check)
+    fmt = None  # message parametrized with results from _fmt_kwargs
+
+    def __init__(self, *args, **kwargs):
+        self._check_params(*args, **kwargs)
+        self._check_kwargs(**kwargs)
+        self.kwargs = kwargs
+        if self.msg is None:
+            # doc string is better implicit message than empty string
+            self.msg = self.__doc__
         if args:
             super(DNSException, self).__init__(*args)
         else:
-            # doc string is better implicit message than empty string
-            super(DNSException, self).__init__(self.__doc__)
+            super(DNSException, self).__init__(self.msg)
+
+    def _check_params(self, *args, **kwargs):
+        """Old exceptions supported only args and not kwargs.
+
+        For sanity we do not allow to mix old and new behavior."""
+        if args or kwargs:
+            assert bool(args) != bool(kwargs), \
+                'keyword arguments are mutually exclusive with positional args'
+
+    def _check_kwargs(self, **kwargs):
+        if kwargs:
+            assert set(kwargs.keys()) == self.supp_kwargs, \
+                'following set of keyword args is required: %s' % (
+                    self.supp_kwargs)
+
+    def _fmt_kwargs(self, **kwargs):
+        """Format kwargs before printing them.
+
+        Resulting dictionary has to have keys necessary for str.format call
+        on fmt class variable.
+        """
+        fmtargs = {}
+        for kw, data in kwargs.items():
+            if isinstance(data, (list, set)):
+                # convert list of <someobj> to list of str(<someobj>)
+                fmtargs[kw] = list(map(str, data))
+                if len(fmtargs[kw]) == 1:
+                    # remove list brackets [] from single-item lists
+                    fmtargs[kw] = fmtargs[kw].pop()
+            else:
+                fmtargs[kw] = data
+        return fmtargs
+
+    def __str__(self):
+        if self.kwargs and self.fmt:
+            # provide custom message constructed from keyword arguments
+            fmtargs = self._fmt_kwargs(**self.kwargs)
+            return self.fmt.format(**fmtargs)
+        else:
+            # print *args directly in the same way as old DNSException
+            return super(DNSException, self).__str__()
+
 
 class FormError(DNSException):
     """DNS message is malformed."""
@@ -38,3 +110,5 @@ class TooBig(DNSException):
 
 class Timeout(DNSException):
     """The DNS operation timed out."""
+    supp_kwargs = set(['timeout'])
+    fmt = "%s after {timeout} seconds" % __doc__[:-1]
diff --git a/dns/resolver.py b/dns/resolver.py
index 387bd50..443e1d0 100644
--- a/dns/resolver.py
+++ b/dns/resolver.py
@@ -48,6 +48,21 @@ if sys.platform == 'win32':
 
 class NXDOMAIN(dns.exception.DNSException):
     """The DNS query name does not exist."""
+    supp_kwargs = set(['qname'])
+
+    def __str__(self):
+        if not 'qname' in self.kwargs:
+            return super(NXDOMAIN, self).__str__()
+
+        qname = self.kwargs['qname']
+        msg = self.__doc__[:-1]
+        if isinstance(qname, (list, set)):
+            if len(qname) > 1:
+                msg = 'None of DNS query names exist'
+                qname = list(map(str, qname))
+            else:
+                qname = qname[0]
+        return "%s: %s" % (msg, (str(qname)))
 
 class YXDOMAIN(dns.exception.DNSException):
     """The DNS query name is too long after DNAME substitution."""
@@ -58,40 +73,36 @@ class YXDOMAIN(dns.exception.DNSException):
 
 Timeout = dns.exception.Timeout
 
+
 class NoAnswer(dns.exception.DNSException):
     """The DNS response does not contain an answer to the question."""
-    def __init__(self, question=None):
-        super(dns.exception.DNSException, self).__init__()
-        self.question = question
-
-    def __str__(self):
-        message = self.__doc__
-        if self.question:
-            message = message[0:-1]
-            for q in self.question:
-                message += ' %s' % q
-        return message
+    fmt = '%s: {query}' % __doc__[:-1]
+    supp_kwargs = set(['response'])
 
+    def _fmt_kwargs(self, **kwargs):
+        return super(NoAnswer, self)._fmt_kwargs(
+            query=kwargs['response'].question)
 
 class NoNameservers(dns.exception.DNSException):
-    """No non-broken nameservers are available to answer the query."""
-    def __init__(self, errors=[]):
-        """Optionally construct message with list of servers and errors.
+    """All nameservers failed to answer the query.
 
-        @param errors: list of servers and respective errors
-        @type errors: [(server ip address, any object convertible to string)]
-        """
-        super(dns.exception.DNSException, self).__init__()
-        self.errors = errors
+    @param errors: list of servers and respective errors
+    @type errors: [(server ip address, any object convertible to string)]
+    Non-empty errors list will add explanatory message ()
+    """
+
+    msg = "All nameservers failed to answer the query."
+    fmt = "%s {query}: {errors}" % msg[:-1]
+    supp_kwargs = set(['request', 'errors'])
+
+    def _fmt_kwargs(self, **kwargs):
+        srv_msgs = []
+        for err in kwargs['errors']:
+            srv_msgs.append('Server %s %s port %s anwered %s' % (err[0],
+                            'TCP' if err[1] else 'UDP', err[2], err[3]))
+        return super(NoNameservers, self)._fmt_kwargs(
+            query=kwargs['request'].question, errors='; '.join(srv_msgs))
 
-    def __str__(self):
-        message = self.__doc__
-        if self.errors:
-            srv_msgs = []
-            for err in self.errors:
-                srv_msgs.append('Server %s %s' % (err[0], err[1]))
-            message += ' %s' % '; '.join(srv_msgs)
-        return message
 
 class NotAbsolute(dns.exception.DNSException):
     """An absolute domain name is required but a relative name was provided."""
@@ -164,9 +175,9 @@ class Answer(object):
                         if raise_on_no_answer:
                             raise NoAnswer(question=response.question)
                 if raise_on_no_answer:
-                    raise NoAnswer(question=response.question)
+                    raise NoAnswer(response=response)
         if rrset is None and raise_on_no_answer:
-            raise NoAnswer(question=response.question)
+            raise NoAnswer(response=response)
         self.canonical_name = qname
         self.rrset = rrset
         if rrset is None:
@@ -742,18 +753,18 @@ class Resolver(object):
 
     def _compute_timeout(self, start):
         now = time.time()
-        if now < start:
-            if start - now > 1:
+        duration = now - start
+        if duration < 0:
+            if duration < -1:
                 # Time going backwards is bad.  Just give up.
-                raise Timeout
+                raise Timeout(timeout=duration)
             else:
                 # Time went backwards, but only a little.  This can
                 # happen, e.g. under vmware with older linux kernels.
                 # Pretend it didn't happen.
                 now = start
-        duration = now - start
         if duration >= self.lifetime:
-            raise Timeout
+            raise Timeout(timeout=duration)
         return min(self.lifetime - duration, self.timeout)
 
     def query(self, qname, rdtype=dns.rdatatype.A, rdclass=dns.rdataclass.IN,
@@ -839,10 +850,11 @@ class Resolver(object):
             backoff = 0.10
             while response is None:
                 if len(nameservers) == 0:
-                    raise NoNameservers(errors)
+                    raise NoNameservers(request=request, errors=errors)
                 for nameserver in nameservers[:]:
                     timeout = self._compute_timeout(start)
                     try:
+                        tcp_attempt = tcp
                         if tcp:
                             response = dns.query.tcp(request, nameserver,
                                                      timeout, self.port,
@@ -855,6 +867,7 @@ class Resolver(object):
                                                      source_port=source_port)
                             if response.flags & dns.flags.TC:
                                 # Response truncated; retry with TCP.
+                                tcp_attempt = True
                                 timeout = self._compute_timeout(start)
                                 response = dns.query.tcp(request, nameserver,
                                                        timeout, self.port,
@@ -865,15 +878,17 @@ class Resolver(object):
                         # Communication failure or timeout.  Go to the
                         # next server
                         #
+                        errors.append((nameserver, tcp_attempt, self.port, ex,
+                                       response))
                         response = None
-                        errors.append((nameserver, ex))
                         continue
                     except dns.query.UnexpectedSource as ex:
                         #
                         # Who knows?  Keep going.
                         #
+                        errors.append((nameserver, tcp_attempt, self.port, ex,
+                                       response))
                         response = None
-                        errors.append((nameserver, ex))
                         continue
                     except dns.exception.FormError as ex:
                         #
@@ -882,8 +897,9 @@ class Resolver(object):
                         # continue.
                         #
                         nameservers.remove(nameserver)
+                        errors.append((nameserver, tcp_attempt, self.port, ex,
+                                       response))
                         response = None
-                        errors.append((nameserver, ex))
                         continue
                     except EOFError as ex:
                         #
@@ -893,13 +909,15 @@ class Resolver(object):
                         # mix and continue.
                         #
                         nameservers.remove(nameserver)
+                        errors.append((nameserver, tcp_attempt, self.port, ex,
+                                       response))
                         response = None
-                        errors.append((nameserver, ex))
                         continue
                     rcode = response.rcode()
                     if rcode == dns.rcode.YXDOMAIN:
                         ex = YXDOMAIN()
-                        errors.append((nameserver, ex))
+                        errors.append((nameserver, tcp_attempt, self.port, ex,
+                                       response))
                         raise ex
                     if rcode == dns.rcode.NOERROR or \
                            rcode == dns.rcode.NXDOMAIN:
@@ -911,7 +929,8 @@ class Resolver(object):
                     #
                     if rcode != dns.rcode.SERVFAIL or not self.retry_servfail:
                         nameservers.remove(nameserver)
-                    errors.append((nameserver, dns.rcode.to_text(rcode)))
+                    errors.append((nameserver, tcp_attempt, self.port,
+                                   dns.rcode.to_text(rcode), response))
                     response = None
                 if not response is None:
                     break
@@ -932,7 +951,7 @@ class Resolver(object):
             all_nxdomain = False
             break
         if all_nxdomain:
-            raise NXDOMAIN
+            raise NXDOMAIN(qname=qnames_to_try)
         answer = Answer(qname, rdtype, rdclass, response,
                         raise_on_no_answer)
         if self.cache:

Python3 branch is missing commits from pull #92
Please be so kind and merge #92 to python3 branch too. Currently Python2 and Python3 exceptions are very different when it comes to str().

Thank you!
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 3fd7331..90a6af4 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -18,6 +18,12 @@ import unittest
 
 from dns.exception import DNSException
 
+
+class FormatedError(DNSException):
+    fmt = "Custom format: {parameter}"
+    supp_kwargs = set(['parameter'])
+
+
 class ExceptionTestCase(unittest.TestCase):
 
     def test_custom_message(self):
@@ -33,6 +39,24 @@ class ExceptionTestCase(unittest.TestCase):
         except DNSException as ex:
             self.assertEqual(ex.__class__.__doc__, str(ex))
 
+    def test_formatted_error(self):
+        """Exceptions with explicit format has to respect it."""
+        params = {'parameter': 'value'}
+        try:
+            raise FormatedError(**params)
+        except FormatedError as ex:
+            msg = FormatedError.fmt.format(**params)
+            self.assertEqual(msg, str(ex))
+
+    def test_kwargs_only(self):
+        """Kwargs cannot be combined with args."""
+        with self.assertRaises(AssertionError):
+            raise FormatedError(1, a=2)
+
+    def test_kwargs_unsupported(self):
+        """Only supported kwargs are accepted."""
+        with self.assertRaises(AssertionError):
+            raise FormatedError(unsupported=2)
 
 if __name__ == '__main__':
     unittest.main()

pip install -e .
parse_log_pytest
pytest
pytest
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
-e git+https://github.com/rthalley/dnspython.git@0b8ae7fd1107f330f94afc03311f233d51968a66#egg=dnspython3
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
packaging @ file:///croot/packaging_1734472117206/work
pluggy @ file:///croot/pluggy_1733169602837/work
pytest @ file:///croot/pytest_1738938843180/work
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work

name: dnspython
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
prefix: /opt/conda/envs/dnspython

tests/test_exceptions.py::ExceptionTestCase::test_formatted_error tests/test_exceptions.py::ExceptionTestCase::test_kwargs_only tests/test_exceptions.py::ExceptionTestCase::test_kwargs_unsupported
tests/test_exceptions.py::ExceptionTestCase::test_custom_message tests/test_exceptions.py::ExceptionTestCase::test_implicit_message
dns/resolver.py dns/exception.py
dns/resolver.py dns/exception.py