Skip to content

fetch_types: Connection killed during startup replays its 57P01 onto the first query after reconnect and an unhandled rejection #1223

Closed

Description

@cat-a-guerra

Versions

postgres 3.4.9 (the code is unchanged on master 411429e) · Node 24.19.0 · PostgreSQL 18.3

What happens

If a new connection's backend is terminated while the driver fetches array types (fetch_types, on by default), the driver reconnects, and the reconnect succeeds. But the first query on the new connection still fails with the old backend's error, and the same error also escapes as an unhandled rejection:

select 1 rejected: 57P01 terminating connection due to administrator command
unhandledRejection: 57P01 terminating connection due to administrator command

On Node 15+ that unhandled rejection exits the process by default. We hit it during pool warm-up after a database restart/failover, when pg_terminate_backend lands on a connection that is still starting up.

Reproduction

A TCP proxy in front of a real Postgres. The first time the driver sends the array-type query, the proxy answers with the exact ErrorResponse that pg_terminate_backend produces and closes the socket, the same as a real termination at that moment. Every later connection is passed through untouched.

import net from 'node:net'
import postgres from 'postgres'

const PGHOST = process.env.PGHOST || '127.0.0.1'
const PGPORT = Number(process.env.PGPORT || 5432)

// The ErrorResponse Postgres sends before closing a session killed by pg_terminate_backend.
function terminated() {
  const field = (code, value) => Buffer.from(code + value + '\0')
  const body = Buffer.concat([
    field('S', 'FATAL'), field('V', 'FATAL'), field('C', '57P01'),
    field('M', 'terminating connection due to administrator command'),
    Buffer.from('\0')
  ])
  const header = Buffer.alloc(5)
  header.write('E')
  header.writeInt32BE(body.length + 4, 1)
  return Buffer.concat([header, body])
}

// A TCP proxy to Postgres that kills the first connection while it fetches array types (fetch_types).
let killed = false
const proxy = net.createServer(client => {
  const server = net.connect(PGPORT, PGHOST)
  client.on('data', chunk => {
    if (!killed && chunk.includes('typarray')) {
      killed = true
      client.end(terminated())
      server.destroy()
      return
    }
    server.write(chunk)
  })
  server.on('data', chunk => client.writable && client.write(chunk))
  client.on('error', () => server.destroy())
  server.on('error', () => client.destroy())
  client.on('close', () => server.destroy())
  server.on('close', () => client.destroy())
})
await new Promise(r => proxy.listen(0, '127.0.0.1', r))

process.on('unhandledRejection', e => console.log('unhandledRejection:', e.code, e.message))

const sql = postgres({ host: '127.0.0.1', port: proxy.address().port, max: 1 })

try {
  const [{ ok }] = await sql`select 1 as ok`
  console.log('select 1 ->', ok)
} catch (e) {
  console.log('select 1 rejected:', e.code, e.message)
}

console.log(await Promise.race([
  sql.end({ timeout: 1 }).then(() => 'sql.end() resolved'),
  new Promise(r => setTimeout(r, 3000, 'sql.end() still pending after 3s'))
]))
proxy.close()
process.exit(0)

Run it against any Postgres; credentials come from the usual PGUSER / PGPASSWORD / PGDATABASE:

docker run --rm -d --name pg-startup-kill -p 127.0.0.1:5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:18
PGUSER=postgres node repro.mjs
Output
3.4.9 / master select 1 rejected: 57P01 … + unhandledRejection: 57P01 …
3.4.9 + #1215 same as above (#1215 does not cover this path)
3.4.9 + the patch below select 1 -> 1

Deterministic: the same result on every run.

Why

  1. During startup, ReadyForQuery calls fetchArrayTypes(), which runs its own internal query. The user's query is parked as initial. The promise from fetchArrayTypes() is not awaited by anyone.
  2. The FATAL ErrorResponse arrives while that internal query is query, so it is stored in errorResponse and waits for a ReadyForQuery that never comes, because the backend closes the socket.
  3. In closed(), if (initial) return reconnect() returns before anything clears query or errorResponse.
  4. On the new socket, the first ReadyForQuery (end of authentication) finds the stale query + errorResponse and calls errored(errorResponse). That rejects the dead internal query, which nobody handles (the unhandled rejection), and then rejects initial (the user's select 1) with the old backend's 57P01.

connected() resets needsTypes, so simply discarding the stale state is enough: the new connection fetches the types again and the parked query runs normally.

Suggested fix

Clear the per-query state in the initial branch too, the same state #1215 clears on the non-initial path:

-    if (initial)
+    if (initial) {
+      query = errorResponse = null
       return reconnect()
+    }

This is the same branch #1193 changes (for a different symptom: a clean close with a pending initial loops forever). Whatever shape the fix for #1193 takes, it would need to clear this state too.

Related, already reported

While investigating this we also hit the other connection-close bugs that are already open, and we fixed all of them together in one local patch (attached / below):

Attachment: postgres@3.4.9.patch

Happy to open a PR for the startup-path fix (with a regression test based on the proxy above) if that helps, either standalone or on top of #1215.

Activity

  1. changed the title [-]A connection killed during startup (fetch_types) replays its 57P01 onto the first query after reconnect, plus an unhandled rejection[/-] [+]fetch_types: Connection killed during startup replays its 57P01 onto the first query after reconnect and an unhandled rejection[/+] on Sep 23, 2026
  2. JakeThomson commented on Sep 29, 2026

    @JakeThomson

    We hit this in production on postgres 3.4.8 / Node 24, behind PgBouncer in transaction mode. When our Postgres crash-restarted, PgBouncer answered the array-type fetch with server_login_retry and closed. The unhandled rejection took down every API process at once.

    The suggested fix on its own makes #1193 reachable

    On the startup path, the stale replay you describe is the only thing that ever settles initial after a server error followed by a close. Once closed() clears it, a peer that keeps failing leaves the query parked, and reconnect() runs with zero delay: the loop from #1193.

    Against a fake PgBouncer that answers every type fetch with FATAL 08P01 and closes, the suggested fix alone made 1,540 connection attempts in 3 seconds, and the query never settled. PgBouncer does exactly this for as long as server_login_retry lasts, so the two fixes need to land together.

    What we're running

    Your fix, plus the time-bounded reconnect from the #1193 thread (backoff, with a run budget of connect_timeout, reset on any successful connect including reserve()). We also kept a .catch on fetchArrayTypes() for a non-fatal failure such as statement_timeout: the connection stays up, ReadyForQuery still calls errored(errorResponse), and nothing else handles that promise.

    --- a/src/connection.js
    +++ b/src/connection.js
    @@ -87,6 +87,7 @@ function Connection(...)
         , statementId = Math.random().toString(36).slice(2)
         , statementCount = 1
         , closedTime = 0
    +    , closeRunStart = 0
         , remaining = 0
         , hostIndex = 0
         , retries = 0
    @@ -447,8 +448,26 @@ function closed(hadError)
         socket.removeAllListeners()
         socket = null
     
    -    if (initial)
    -      return reconnect()
    +    if (initial) {
    +      // The dead backend's query and error would otherwise survive to the next
    +      // socket, whose first ReadyForQuery replays them: the internal type fetch
    +      // rejects with nothing to catch it, and the parked initial query fails
    +      // with an error from a backend it never ran on (#1223).
    +      const err = errorResponse
    +      query = errorResponse = null
    +      // Without the replay, a peer that keeps closing (PgBouncer during
    +      // server_login_retry) would spin zero-delay reconnects forever (#1193).
    +      // Pace them with the shared backoff and give up after connect_timeout.
    +      closedTime = performance.now()
    +      closeRunStart || (closeRunStart = closedTime)
    +      options.shared.retries++
    +      delay = (typeof backoff === 'function' ? backoff(options.shared.retries) : backoff) * 1000
    +      if (closedTime + delay <= closeRunStart + (options.connect_timeout || 30) * 1000)
    +        return reconnect()
    +      closeRunStart = 0
    +      errored(err || Errors.connection('CONNECTION_CLOSED', options, socket))
    +      return onclose(connection, Errors.connection('CONNECTION_CLOSED', options, socket))
    +    }
     
         !hadError && (query || sent.length) && error(Errors.connection('CONNECTION_CLOSED', options, socket))
         closedTime = performance.now()
    @@ -560,12 +579,15 @@ function ReadyForQuery(x)
           }
     
           if (needsTypes) {
    -        initial.reserve && (initial = null)
    -        return fetchArrayTypes()
    +        initial.reserve && (closeRunStart = 0, initial = null)
    +        // Nothing awaits this. If the fetch fails while the connection stays
    +        // up (a statement_timeout, say), errored() has already settled initial,
    +        // and connected() re-arms needsTypes on the next connect.
    +        return fetchArrayTypes().catch(noop)
           }
     
           initial && !initial.reserve && execute(initial)
    -      options.shared.retries = retries = 0
    +      options.shared.retries = retries = closeRunStart = 0
           initial = null
           return
         }

    Results

    Fake PgBouncer, connect_timeout: 10, default backoff:

    Scenario Outcome
    Backend dies once during the type fetch Query runs on the new connection and resolves; no unhandled rejection
    Pooler keeps failing (FATAL 08P01 + close) 7 attempts, then rejects with the pooler's own 08P01 at 9.4s
    Pooler fails for 3s, then recovers 6 attempts, query resolves at 3.1s
    Non-fatal error on the type fetch (57014) Connection stays usable; no unhandled rejection

    You offered to open a PR. I can contribute regression tests for all four cases, or happy to open the PR myself if you'd rather not.

  3. cat-a-guerra commented on Sep 30, 2026

    @cat-a-guerra
    Author

    @JakeThomson

    Confirmed, and thanks — the one-line fix on its own does strand the query. Reproduced against a fake peer that completes the handshake and then answers every array-type fetch with FATAL 08P01 + close, max: 1, connect_timeout: 10, postgres 3.4.9, Node 24.19.0:

    Outcome
    3.4.9 unpatched select 1 rejected 08P01 in 7ms, 2 connection attempts
    3.4.9 + the one-line fix from this issue query never settled after 12s, 5,601 connection attempts

    So on this path the stale replay is indeed the only thing that settles initial after a server error followed by a close, and removing it exposes #1193 exactly as you describe. Agreed the two need to land together.

    Your fetchArrayTypes().catch(noop) is a good catch too: ReadyForQuery already settles initial through errored(errorResponse), so the fetch's own promise has nobody waiting on it either way.

    The always-failing-peer script (no Postgres needed: npm i postgres@3.4.9, then node always-fails.mjs)
    // A fake peer that completes the handshake, then answers every array-type fetch with FATAL and closes.
    // Stands in for PgBouncer during server_login_retry.
    import net from 'node:net'
    import postgres from 'postgres'
    
    const msg = (type, body) => {
      const header = Buffer.alloc(5)
      header.write(type)
      header.writeInt32BE(body.length + 4, 1)
      return Buffer.concat([header, body])
    }
    const int32 = n => { const b = Buffer.alloc(4); b.writeInt32BE(n); return b }
    const authOk = msg('R', int32(0))
    const readyIdle = msg('Z', Buffer.from('I'))
    const fatal = msg('E', Buffer.concat([
      Buffer.from('SFATAL\0'), Buffer.from('VFATAL\0'), Buffer.from('C08P01\0'),
      Buffer.from('Mserver login retry\0'), Buffer.from('\0')
    ]))
    
    let attempts = 0
    const server = net.createServer(sock => {
      attempts++
      let greeted = false
      sock.on('error', () => {})
      sock.on('data', chunk => {
        if (!greeted) {                       // startup packet (no type byte)
          greeted = true
          return sock.write(Buffer.concat([authOk, readyIdle]))
        }
        if (chunk.includes('typarray'))       // the fetch_types query: fail it, like the pooler does
          return sock.end(fatal)
      })
    })
    await new Promise(r => server.listen(0, '127.0.0.1', r))
    
    let unhandled = 0
    process.on('unhandledRejection', () => unhandled++)
    
    const sql = postgres({
      host: '127.0.0.1', port: server.address().port,
      max: 1, connect_timeout: 10, user: 'u', pass: 'p', database: 'd'
    })
    
    const started = Date.now()
    const settled = sql`select 1 as ok`.then(
      () => `resolved after ${Date.now() - started}ms`,
      e => `rejected ${e.code} after ${Date.now() - started}ms`
    )
    console.log(await Promise.race([settled, new Promise(r => setTimeout(r, 12000, 'NEVER SETTLED (12s)'))]))
    console.log('connection attempts:', attempts, '| unhandled rejections:', unhandled)
    process.exit(0)

    Please do open the PR — that's very welcome, and your version (bound by connect_timeout with the reset on any successful connect, including reserve()) is what we'd want. Happy to review it and to contribute the proxy-based reproduction from this issue plus the always-failing-peer script above as regression tests, whichever is more useful to you.

    One note for anyone else landing here from a patch: we are carrying the stale-state reset alone in production, so we're taking the same bound into our local patch until a release has both.

  4. JakeThomson commented on Sep 30, 2026

    @JakeThomson

    @cat-a-guerra thanks for confirming, and for the always-failing-peer script. Opened #1241.

    It's the stale-state reset from this issue plus the connect_timeout-bounded reconnect from #1193 (reset on any successful connect, including reserve()), and the .catch on fetchArrayTypes(). The first commit adds four tests, each of which fails on master:

    The full suite passes, ESM and CJS, on PostgreSQL 14 / Node 24. A review would be very welcome, thanks!

  5. garrytan commented on Oct 10, 2026

    @garrytan

    Another independent reproduction, from garrytan/gbrain on postgres 3.4.9 under Bun 1.4.0. Under Bun the discarded rejection from the internal array-types query is fatal by default, so one backend terminated during the startup type fetch takes the whole process down.

    Our minimal scripted-server test: the server answers startup, replies FATAL 57P01 and closes at the first statement, then serves the second connection normally. It's the first case in test/postgres-driver-startup-death.test.ts (MIT), if it's useful as an extra regression case. On 3.4.9, sql`select 1` rejects with the stale 57P01 and an unhandled rejection fires. With the stale-state reset plus a catch on fetchArrayTypes() (the approach in #1241), it resolves on the second connection.

    The same fix also covers #1192.

  6. added a commit that references this issue on Oct 29, 2026
    06cf284
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions