Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions lib/tds/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]

Expand All @@ -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
Expand Down
32 changes: 21 additions & 11 deletions lib/tds/protocol.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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 ->
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
10 changes: 10 additions & 0 deletions test/error_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Loading