Skip to content

Provide structured errors for invalid connection parameters #28

Open

Description

@PingoLee

parse_dsn reports every connection-string problem as an ArgumentError whose only content is the
message. A caller that wants to know which parameter failed, for example to show its own message
without repeating user input, has to parse that text. That breaks whenever the wording changes. 2.3.0
added connection parameter "<key>" is set to a value Postgres.jl does not support and cannot safely ignore, for example. It can also be misled today, because the invalid value messages insert the
value without escaping it:

julia> Postgres.parse_dsn("host=h port='1\" for connection parameter \"password'")
ERROR: ArgumentError: invalid value "1" for connection parameter "password" for connection parameter "port"; expected an integer

I'd like to propose three small changes. Each could be its own PR, and none changes the exception type
parse_dsn throws.

1. Escape the echoed value (2.x patch)

In parse_optional_int and parse_bool_param, write "\"$(escape_string(value))\"" instead of
"\"$value\"". The message above becomes
invalid value "1\" for connection parameter \"password" for connection parameter "port"; ….
The output only changes for a value that contains ", \ or a control character. escape_string
leaves $ alone, so an unexpanded ${PGPORT} still prints as is.

2. An additive, non-throwing check (2.x minor)

"""
    Postgres.validate_dsn(dsn) -> Union{Nothing, Postgres.ConnectionParameterError}

Return `nothing` if `parse_dsn(dsn)` would succeed, otherwise the error describing why it would not.
"""

struct ConnectionParameterError <: Exception
    key::Union{String, Nothing}   # a recognized parameter name, never text from a value
    reason::Symbol                # :unknown_key, :invalid_value, :unsupported_ignored, :malformed
    msg::String                   # exactly the message parse_dsn throws today
end
Base.showerror(io::IO, e::ConnectionParameterError) = print(io, e.msg)
  • parse_dsn and all of its ArgumentErrors stay exactly as they are. REPL output, catch e::ArgumentError
    and @test_throws ArgumentError all keep working.
  • showerror prints today's message unchanged, so a caller that shows the returned error gets the same
    text parse_dsn would have thrown.
  • key comes only from the parser's key side, after the key has been recognized. The parser already
    checks every key before it echoes one, so key is always one of the known or ignored keywords. It is
    nothing for an unknown key, an empty key, and an unparseable URI. The numeric_overflow check
    reports key = "numeric_overflow".
  • Mapping of the current throw sites: unknown key → :unknown_key; integer, boolean and
    numeric_overflow values → :invalid_value; the SECURITY_SENSITIVE_IGNORED check →
    :unsupported_ignored; the keyword-parser syntax errors and the URI error → :malformed.
  • Implementation: one internal function parses and returns either the parsed result or a
    ConnectionParameterError, without throwing. validate_dsn returns nothing or that error.
    parse_dsn throws ArgumentError(e.msg) when it gets an error. Neither path wraps the other in a
    try/catch, so a successful parse never pays for an exception, and there is no exception chain
    that could carry input.

If you'd prefer one function to two, a per-call keyword, parse_dsn(dsn; structured = true), that
throws ConnectionParameterError instead of ArgumentError would work too.

Every current message shape, with a minimal input
input message echoes key / reason
"host=h ssl_mode=verify-full" unrecognized connection parameter; recognized parameters are … — nothing / :unknown_key
"host=h channel_binding=require" connection parameter "channel_binding" is set to a value Postgres.jl does not support and cannot safely ignore key "channel_binding" / :unsupported_ignored
"host=h port=abc" invalid value "abc" for connection parameter "port"; expected an integer key, value "port" / :invalid_value
"host=h reconnect=ture" invalid value "ture" for connection parameter "reconnect"; expected a boolean (on/off, true/false, yes/no, 1/0) key, value "reconnect" / :invalid_value
"host=h numeric_overflow=ignore" numeric_overflow must be :warn or :error — "numeric_overflow" / :invalid_value
"=x" empty connection parameter name — nothing / :malformed
"host=h user" connection parameter "user" is missing '=' key "user" / :malformed
"host=abc\\" dangling escape in value for connection parameter "host" key "host" / :malformed
"host='unterminated" unterminated quoted value for connection parameter "host" key "host" / :malformed
"host='h'trailing" unexpected text after quoted value for connection parameter "host" key "host" / :malformed
"postgresql://u:p@h:notaport/db" invalid PostgreSQL URI; check syntax and percent-encoding — nothing / :malformed

Question: value-free text. Only :invalid_value messages contain a value, and only for integer and
boolean parameters, so key + reason already let a caller decide what is safe to show. If you'd like
it built in, showerror could honor IOContext(io, :redact => true) and print
invalid value for connection parameter "port"; expected an integer. I'm equally happy to leave that out.

3. Validate sslmode and gssencmode while parsing (2.x minor)

Today an invalid value passes parse_dsn and is only rejected when connecting:

julia> p = Postgres.parse_dsn("host=h sslmode=verfy-full gssencmode=bogus");   # succeeds

julia> Postgres.Connection(host = "127.0.0.1", gssencmode = "bogus")
ERROR: Postgres.Error: … Message: invalid gssencmode: bogus

The gssencmode check runs before any dial. The sslmode check runs only after the TCP connection is up
(API.jl:801). libpq rejects both while it parses the options. Proposal:

  • One list of accepted values (disable, prefer, require, verify-full for sslmode; disable,
    prefer, require for gssencmode), shared by the parser and the connect path. The lists are constant
    tuples, so the parse-time check allocates nothing. The only added cost is one lookup per connection,
    next to a network round trip.
  • Check it in the ConnectionParams keyword constructor, which runs after PGSSLMODE/PGGSSENCMODE
    have been merged in. Check it in the Connection keyword constructor too, as numeric_overflow already is.
  • Keep today's case-insensitivity, and add no new values. The message matches the other value errors,
    with the escaped value:
    invalid value "verfy-full" for connection parameter "sslmode"; expected one of disable, prefer, require, verify-full.
    validate_dsn would report key = "sslmode" / "gssencmode" and reason = :invalid_value.
  • Tests: every value the connect path accepts today, in any case and from either the DSN or the
    environment, is still accepted by parse_dsn, and each invalid value is refused at parse time.

Inputs that connect today are unaffected, with one exception (case 3). Three cases do change:

  1. A caller that only calls parse_dsn, for example to check a config without connecting. It gets an
    ArgumentError where it got a ConnectionParams before. This also applies when the bad value comes
    from PGSSLMODE or PGGSSENCMODE.
  2. A caller that catches Postgres.Error for invalid sslmode / invalid gssencmode at connect time.
    It now gets an ArgumentError, earlier, from parse_dsn or from the Connection constructor.
  3. An invalid sslmode over GSS encryption. When GSS encryption is negotiated, API.connect returns
    at API.jl:760 before the sslmode check, so gssencmode=require sslmode=bogus currently connects on
    a machine with a Kerberos ticket. The same config already fails on a machine without one, and libpq
    rejects it everywhere. The PR would also move the connect-time sslmode check next to the gssencmode
    check, so no path skips it.

Because of these cases I'd mention it in the release notes, as a minor-version change.

Possible future 3.0 (not part of this request)

parse_dsn itself could throw ConnectionParameterError. That would be breaking, because ArgumentError
is concrete and cannot be subtyped, so it only makes sense if a 3.0 happens for other reasons.

Context: I maintain PormG.jl, which uses Postgres.jl as a backend. Its handling of these errors matched on
the message text, and the new 2.3.0 message broke it the same day (PingoLee/PormG.jl#1233, fixed in
PingoLee/PormG.jl#1234). That is what prompted this issue.

I'm happy to send all three as separate PRs, starting with the escaping fix, once you're happy with
the shape.

This issue was drafted with Claude Code (Claude Opus 5.5). I reviewed the analysis, and the snippets were run against v2.3.0.

Activity

  1. added a commit that references this issue on Oct 27, 2026
  2. quinnj commented on Oct 27, 2026

    @quinnj
    Member

    Thanks for the detailed proposal. The escaping fix is already merged in #32. Please send separate PRs for the remaining two changes:

    • An additive Postgres.validate_dsn API returning structured error information, while preserving parse_dsn's existing ArgumentError behavior.
    • Shared, early validation of sslmode and gssencmode, before connecting, including environment-derived values and the GSS path. Preserve the currently accepted values and case-insensitivity, and document the earlier errors as a behavior change.

    AI disclosure: Codex investigated the current code and drafted this response; I approve the suggestions above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions