diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 60b1512..37b2d46 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -51,10 +51,11 @@ jobs: steps: - name: Install MSSql Client Tools run: | - curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc - curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + curl https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/microsoft-prod.gpg + echo "deb [signed-by=/usr/share/keyrings/microsoft-prod.gpg arch=amd64,arm64,armhf] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo apt-get install mssql-tools18 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 unixodbc-dev - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2098ba0..2739e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Improvements +* Surface the last MSSQL error token alongside connection-close errors. When the server sends an error token immediately before closing the connection (login failure, fatal severity, kill-by-DBA), the token detail is preserved on the protocol state and combined with the transport-level reason in the resulting `Tds.Error`, so callers no longer see only "Connection closed." / "tcp closed". + ## v2.3.8 (2026-05-18) ### Improvements * Relax decimal dependency diff --git a/lib/tds/error.ex b/lib/tds/error.ex index 0125fa7..331e6c3 100644 --- a/lib/tds/error.ex +++ b/lib/tds/error.ex @@ -5,8 +5,13 @@ defmodule Tds.Error do The struct has two fields: * `:message`: expected to be a string - * `:mssql`: expected to be a keyword list with the fields `line_number`, - `number` and `msg_text` + * `:mssql`: expected to be a map carrying the fields decoded from a + SQL Server error token: `number`, `state`, `class`, `msg_text`, + `server_name`, `proc_name`, and `line_number` + + When both fields are populated — for example, when a connection-close is + preceded by a server-side error token — `message/1` combines them so the + underlying server error is surfaced alongside the transport-level reason. ## Usage @@ -21,8 +26,16 @@ defmodule Tds.Error do """ - @type error_details :: %{line_number: integer(), number: integer(), msg_text: String.t()} - @type t :: %__MODULE__{message: String.t(), mssql: error_details} + @type error_details :: %{ + optional(:state) => integer(), + optional(:class) => integer(), + optional(:server_name) => String.t(), + optional(:proc_name) => String.t(), + required(:line_number) => integer(), + required(:number) => integer(), + required(:msg_text) => String.t() + } + @type t :: %__MODULE__{message: String.t() | nil, mssql: error_details | nil} defexception [:message, :mssql] @@ -45,8 +58,13 @@ defmodule Tds.Error do end @spec message(%__MODULE__{}) :: String.t() + def message(%__MODULE__{mssql: mssql, message: message}) + when is_map(mssql) and is_binary(message) do + "#{message} Line #{mssql.line_number} (Error #{mssql.number}): #{mssql.msg_text}" + end + def message(%__MODULE__{mssql: mssql}) when is_map(mssql) do - "Line #{mssql[:line_number]} (Error #{mssql[:number]}): #{mssql[:msg_text]}" + "Line #{mssql.line_number} (Error #{mssql.number}): #{mssql.msg_text}" end def message(%__MODULE__{message: message}) when is_binary(message) do diff --git a/lib/tds/protocol.ex b/lib/tds/protocol.ex index 2fc918e..ef4a8a8 100644 --- a/lib/tds/protocol.ex +++ b/lib/tds/protocol.ex @@ -42,7 +42,8 @@ defmodule Tds.Protocol do result: nil | list(), query: nil | String.t(), transaction: transaction, - env: env + env: env, + last_mssql_error: nil | Tds.Error.error_details() } defstruct sock: nil, @@ -59,7 +60,8 @@ defmodule Tds.Protocol do savepoint: 0, collation: %Tds.Protocol.Collation{}, packetsize: 4096 - } + }, + last_mssql_error: nil @spec connect(opts :: Keyword.t()) :: {:ok, state :: t()} | {:error, Exception.t()} def connect(opts) do @@ -100,7 +102,7 @@ defmodule Tds.Protocol do {:ok, s} {:disconnect, :closed, s} -> - {:disconnect, %Tds.Error{message: "Connection closed."}, s} + {:disconnect, disconnect_error("Connection closed.", s), s} {:error, err, s} -> err = @@ -126,6 +128,7 @@ defmodule Tds.Protocol do def checkout(%{sock: {mod, _sock}} = s) do sock_mod = inspect(mod) + s = %{s | last_mssql_error: nil} case setopts(s.sock, active: false) do :ok -> @@ -460,11 +463,11 @@ defmodule Tds.Protocol do end def handle_info({tag, _}, s) when tag in [:tcp_closed, :ssl_closed] do - {:stop, Tds.Error.exception("tcp closed"), s} + {:stop, disconnect_error("tcp closed", s), s} end def handle_info({tag, _, reason}, s) when tag in [:tcp_error, :ssl_error] do - {:stop, Tds.Error.exception("tcp error: #{reason}"), s} + {:stop, disconnect_error("tcp error: #{reason}", s), s} end def handle_info(msg, s) do @@ -505,10 +508,10 @@ defmodule Tds.Protocol do {:ok, s} {:tcp_closed, ^sock} -> - {:disconnect, %Tds.Error{message: "tcp closed"}, s} + {:disconnect, disconnect_error("tcp closed", s), s} {:tcp_error, ^sock, reason} -> - {:disconnect, %Tds.Error{message: "tcp error: #{reason}"}, s} + {:disconnect, disconnect_error("tcp error: #{reason}", s), s} after 0 -> # There might not be any socket messages. @@ -796,7 +799,7 @@ defmodule Tds.Protocol do ## Error def message(_, msg_error(error: e), %{} = s) do error = %Tds.Error{mssql: e} - {:error, error, mark_ready(s)} + {:error, error, mark_ready(%{s | last_mssql_error: e})} end ## ATTN Ack @@ -810,6 +813,15 @@ defmodule Tds.Protocol do %{s | state: :ready} end + @spec disconnect_error(String.t(), t()) :: Tds.Error.t() + defp disconnect_error(message, %__MODULE__{last_mssql_error: nil}) do + %Tds.Error{message: message} + end + + defp disconnect_error(message, %__MODULE__{last_mssql_error: %{} = mssql}) do + %Tds.Error{message: message, mssql: mssql} + end + # Send Command To Sql Server defp login_send(msg, %{sock: {mod, sock}, env: env, opts: opts} = s) do paks = encode_msg(msg, env) @@ -866,9 +878,7 @@ defmodule Tds.Protocol do {:error, error} -> {:disconnect, - %Tds.Error{ - message: "Connection failed to receive packet due #{inspect(error)}" - }, s} + disconnect_error("Connection failed to receive packet due #{inspect(error)}", s), s} end catch {:error, error} -> {:disconnect, error, s} diff --git a/test/error_test.exs b/test/error_test.exs index 8dceb83..9215fe8 100644 --- a/test/error_test.exs +++ b/test/error_test.exs @@ -13,6 +13,16 @@ defmodule ErrorTest do end end + test "combines :message and :mssql when both are populated" do + error = %Tds.Error{ + message: "Connection closed.", + mssql: %{line_number: 1, number: 18_456, msg_text: "Login failed for user 'sa'"} + } + + assert Tds.Error.message(error) == + "Connection closed. Line 1 (Error 18456): Login failed for user 'sa'" + end + test "raises a Tds.Error with a default message as a fallback" do # no arguments assert_raise Tds.Error, "An error occured.", fn ->