feat(postgres-wire-features): add Postgres v3 wire-protocol sample#229
feat(postgres-wire-features): add Postgres v3 wire-protocol sample#229slayerjain merged 7 commits intomainfrom
Conversation
A stdlib-only Go HTTP service that drives a PostgreSQL connection directly over the v3 frontend/backend protocol (no driver), exposing ten scenarios under GET /run/all: - setup (DDL) - dml (INSERT ON CONFLICT, UPDATE, DELETE, MERGE) - catalog-cte / catalog-sub / catalog-setop (pg_catalog queries via CTE, subselect, UNION) - copy (COPY FROM STDIN + COPY TO STDOUT) - prepare (PREPARE, EXECUTE, DEALLOCATE) - cursor (BEGIN, DECLARE CURSOR, FETCH, CLOSE, COMMIT) - admin (ANALYZE, REINDEX, LOCK TABLE, DO, CREATE PROCEDURE, CALL) - validation-ping (SELECT 'ok', SELECT true, SELECT NULL, SELECT 1) Standard sample layout: Dockerfile, docker-compose.yml with api+db, test.sh traffic script that hits /run/all and each /case/<name>, and a README with record/test instructions. The wire-protocol traffic is intentionally low-level so Keploy's proxy sees every message class (CopyData, CopyDone, Parse/Bind/Execute, cursor portals, etc.) as the server sends them, rather than what a driver would hide behind its API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Aditya-eddy <aditya282003@gmail.com>
a2de363 to
7d89cc7
Compare
…only Dropping the per-scenario /case/<name> loop from test.sh. /run/all already exercises every scenario inside one HTTP request, which is all a record pass needs; firing /case/* endpoints in quick succession afterwards created many back-to-back raw-socket Postgres connections that could push the recording proxy into an EOF regime in docker mode, producing noisy captures whose replay diffs drowned out the real signal. Signed-off-by: Aditya-eddy <aditya282003@gmail.com>
…un/all Refactor: runXxx scenario functions now take *pgClient instead of opening their own. /run/all dials once and walks all ten scenarios on that single connection; /case/<name> keeps its own withClient wrapper so individual scenario endpoints still run standalone. Opening a fresh connection per scenario pushed Keploy's docker-mode proxy into a back-to-back connect/close regime where the agent dropped handshakes (visible in CI logs as 'failed to read client auth response: EOF' on every other connection during record, and 'replayer: session RecordedIndex missing PostgresV3Session mock' during replay). Sharing one connection drops the connect count from ~10 per /run/all to 1, which keeps the v3 wire surface exercised — CopyData/CopyDone frames are still sent on the wire; validation-ping SELECTs still collide on sqlAstHash — without tripping that unrelated instability. Signed-off-by: Aditya-eddy <aditya282003@gmail.com>
…ndshake Postgres 16 defaults to scram-sha-256 (auth code 10) when a password is set. This sample's pure-stdlib client only implements codes 0/trust, 3/cleartext, and 5/md5 — SASL is out of scope for a wire-protocol repro. Pinning POSTGRES_HOST_AUTH_METHOD=trust keeps auth on code 0, which is what the client actually speaks. Observed in CI: dial was failing with 'unsupported postgres authentication code 10' before any scenario query ran, so the recorded test body was just the auth error — masking the real Postgres wire surface the sample is meant to exercise. Signed-off-by: Aditya-eddy <aditya282003@gmail.com>
…ios and assertions
Extend the sample with two new scenarios that exercise specific v3
recorder edges not covered by the existing suite:
- /case/multistatement sends BEGIN; SELECT 1 AS a; SELECT 2 AS b; COMMIT
as a SINGLE simple-Query packet. A recorder that splits the batch with
pg_query emits four invocations; a recorder that tracks per-packet
emits one.
- /case/empty-query sends a Query packet with empty SQL body. The server
answers with EmptyQueryResponse ('I') and ReadyForQuery only. A
correct recorder suppresses this as a no-op; a naive recorder emits a
ghost invocation with empty sqlNormalized and class=UNKNOWN.
Both scenarios are added to /run/all so the single-connection path
exercises them, and are also exposed as standalone /case endpoints so
their recorded testcases are isolated by connection boundary.
simpleQuery grows an 'I' (EmptyQueryResponse) case so the empty-query
path returns cleanly rather than erroring on the unhandled tag.
assertions.sh validates recorded mocks.yaml after a Keploy record run:
1. no invocation with class: UNKNOWN
2. no invocation with empty sqlNormalized (ghost event)
3. multistatement Q packet produced >=1 each of BEGIN, COMMIT,
SELECT \$1 AS a, SELECT \$1 AS b invocations
4. at least some readable SQL is present (mocks aren't entirely base64)
Use ./assertions.sh keploy after keploy record to fail the pipeline if
the recorder regressed on any of those invariants.
Signed-off-by: slayerjain <shubhamkjain@outlook.com>
There was a problem hiding this comment.
Pull request overview
Adds a new postgres-wire-features/ Go sample: a stdlib-only HTTP service that speaks the PostgreSQL v3 wire protocol directly to exercise a broad set of message types/scenarios for Keploy record/replay.
Changes:
- Introduces a minimal HTTP server (
/health,/run/all,/case/<name>) that runs multiple Postgres scenarios over a raw TCP v3 protocol client. - Adds Docker Compose + Dockerfile for running the API alongside Postgres 16, plus traffic-generation and post-record invariants scripts.
- Documents usage (record/replay, scenarios, environment variables) and adds local ignores for Keploy artifacts.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| postgres-wire-features/main.go | Implements HTTP endpoints, scenario suite, and a raw Postgres v3 frontend/backend client. |
| postgres-wire-features/docker-compose.yml | Defines api + db services with healthcheck and environment wiring. |
| postgres-wire-features/Dockerfile | Builds and packages the sample as a small container image. |
| postgres-wire-features/go.mod | Declares the module and Go version for the new sample. |
| postgres-wire-features/README.md | Documents scenarios, endpoints, record/replay flow, and configuration. |
| postgres-wire-features/test.sh | Generates HTTP traffic to exercise /run/all and key edge cases. |
| postgres-wire-features/assertions.sh | Validates recorded mocks against expected v3 recorder invariants. |
| postgres-wire-features/.gitignore | Ignores Keploy outputs and local build artifacts for this sample. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # record/replay cycle is actually exercising. | ||
| POSTGRES_HOST_AUTH_METHOD: trust | ||
| ports: | ||
| - "5433:5432" |
There was a problem hiding this comment.
POSTGRES_HOST_AUTH_METHOD: trust combined with publishing the DB port (5433:5432) makes the database accept unauthenticated connections from the host network. Consider either (a) not publishing the DB port at all, (b) binding it to localhost only (e.g. 127.0.0.1:5433:5432), or (c) switching to md5 auth (supported by this client) so the sample isn’t running an open Postgres on the developer machine.
| - "5433:5432" | |
| - "127.0.0.1:5433:5432" |
| environment: | ||
| POSTGRES_USER: postgres | ||
| POSTGRES_PASSWORD: postgres | ||
| POSTGRES_DB: postgres |
There was a problem hiding this comment.
With POSTGRES_HOST_AUTH_METHOD=trust, the POSTGRES_PASSWORD/PGPASSWORD values are effectively unused (server won’t request a password). This can confuse readers and can mask an accidental reliance on trust auth; consider aligning these by using md5 auth (and keeping passwords) or removing the password env vars if trust is intentional.
| echo -e "\n--- /health (sanity) ---" | ||
| curl -s -w " HTTP %{http_code}\n" "$BASE_URL/health" | ||
|
|
||
| # /run/all exercises every scenario (setup, dml, catalog-{cte,sub,setop}, | ||
| # copy, prepare, cursor, admin, validation-ping, multistatement, | ||
| # empty-query) inside a single HTTP request. That keeps the recorded HTTP | ||
| # testcase count small while still driving the full Postgres wire surface | ||
| # Keploy needs to see. | ||
| echo -e "\n--- /run/all (one call exercises every scenario) ---" | ||
| curl -s -w "\n HTTP %{http_code}\n" "$BASE_URL/run/all" -o /tmp/run-all.json | ||
| echo " response bytes: $(wc -c </tmp/run-all.json)" |
There was a problem hiding this comment.
The script prints HTTP status codes but doesn’t fail the run when an endpoint returns a non-2xx/3xx response (curl exits 0 on HTTP 500 unless --fail/--fail-with-body is used). Since these endpoints are expected to succeed, consider making the script exit non-zero on unexpected status codes so CI/local runs don’t silently pass while generating bad recordings.
| conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 10*time.Second) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| c := &pgClient{conn: conn, user: user} | ||
| if err := c.startup(user, db, password); err != nil { | ||
| conn.Close() | ||
| return nil, err | ||
| } | ||
| return c, nil | ||
| } |
There was a problem hiding this comment.
After dialing, the connection has no read/write deadlines; readMessage uses io.ReadFull, so a stalled/half-open Postgres connection can block the HTTP handler indefinitely. Consider setting per-operation deadlines (e.g., SetDeadline/SetReadDeadline/SetWriteDeadline) around startup + each query, or at least a global idle deadline, to avoid hung requests and leaked goroutines in failure cases.
| return err | ||
| } | ||
| default: | ||
| return fmt.Errorf("unsupported postgres authentication code %d", code) |
There was a problem hiding this comment.
The error unsupported postgres authentication code %d doesn’t give the user an actionable remediation (e.g., Postgres 16 default SCRAM is code 10). Consider including a hint like “configure Postgres auth to md5/trust (POSTGRES_HOST_AUTH_METHOD)” so users can quickly fix local runs when auth negotiation fails.
| return fmt.Errorf("unsupported postgres authentication code %d", code) | |
| return fmt.Errorf("unsupported postgres authentication code %d; this server may require SCRAM authentication (code 10). For local runs, configure Postgres auth to md5 or trust, for example with POSTGRES_HOST_AUTH_METHOD, or use a server/user configuration compatible with this client", code) |
…TP errors Address Copilot review on #229: - docker-compose.yml: bind the container's 5433 port to 127.0.0.1 only instead of all interfaces. With POSTGRES_HOST_AUTH_METHOD=trust the DB accepts unauthenticated connections, so publishing on 0.0.0.0 left an open Postgres on whatever network the developer machine was attached to. Loopback-only keeps the sample usable for record/replay without that exposure. - docker-compose.yml: remove POSTGRES_PASSWORD / PGPASSWORD. Under trust auth the server never requests a password; keeping those env vars suggested the auth path in use required one and could mask an accidental reliance on trust mode. Trust is intentional here (see the adjacent comment block) so the password vars are now dropped. - test.sh: pass --fail-with-body to every curl invocation so an HTTP 5xx from /health, /run/all, or the /case endpoints exits the script non-zero. Without this curl exits 0 on 500s and the traffic script silently produces a bad recording. Signed-off-by: slayerjain <shubhamkjain@outlook.com>
…ed auth codes Address Copilot review on #229: - Add a 30s per-operation deadline around startup() and simpleQuery(). Before this, the raw net.Conn had no deadlines, so a stalled or half-open Postgres connection would wedge io.ReadFull inside readMessage and hang the HTTP handler (and leak goroutines) until the OS eventually gave up. setOpDeadline / clearOpDeadline arm the deadline on entry and clear it on exit, so the /run/all path (which shares one pgClient across scenarios) never inherits a stale deadline between operations. - Replace the opaque "unsupported postgres authentication code %d" error with a message that spells out the codes this client actually speaks (0/trust, 3/cleartext, 5/md5) and points at POSTGRES_HOST_AUTH_METHOD as the quick local remediation when the server negotiates scram-sha-256 (code 10, the Postgres 16 default). The prior message sent users digging through protocol docs to discover what the numeric code meant. Signed-off-by: slayerjain <shubhamkjain@outlook.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Start Postgres yourself (e.g. `docker run -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16-alpine`) and then: | ||
|
|
||
| ```bash | ||
| PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=postgres go run . | ||
| ``` |
There was a problem hiding this comment.
The "Run Natively" example uses the default Postgres image password auth, which commonly negotiates SCRAM (auth code 10). This client explicitly errors on SCRAM and only supports auth codes 0/3/5, so the documented command is likely to fail. Update the README to show a compatible server config (e.g., trust or md5 via POSTGRES_HOST_AUTH_METHOD / pg_hba.conf) or clarify the requirement.
| module postgres-wire-features | ||
|
|
||
| go 1.24 |
There was a problem hiding this comment.
This new Go sample directory isn't included in the golangci-lint workflow matrix (.github/workflows/golangci-lint.yml), so it won't be linted in CI. Add postgres-wire-features to the matrix to keep lint coverage consistent with other Go samples.
| body.WriteByte(0) | ||
|
|
||
| msg := withLength(body.Bytes()) | ||
| if _, err := c.conn.Write(msg); err != nil { |
There was a problem hiding this comment.
net.Conn.Write may write fewer bytes than requested; both the startup packet write and writeMessage ignore the returned byte count, which can result in truncated protocol messages and hard-to-debug failures. Consider using io.WriteFull (or a small write loop) to ensure the entire message is sent, and handle any short write as an error.
| if _, err := c.conn.Write(msg); err != nil { | |
| if _, err := io.WriteFull(c.conn, msg); err != nil { |
| func (c *pgClient) writeMessage(tag byte, payload []byte) error { | ||
| msg := make([]byte, 1, 1+4+len(payload)) | ||
| msg[0] = tag | ||
| msg = append(msg, withLength(payload)...) | ||
| _, err := c.conn.Write(msg) | ||
| return err |
There was a problem hiding this comment.
writeMessage doesn't verify that the full buffer was written to the TCP connection. If a short write occurs, the Postgres backend will see a truncated frame and the protocol stream becomes unrecoverable. Use a full-write helper/loop and treat short writes as an error.
| func (c *pgClient) writeMessage(tag byte, payload []byte) error { | |
| msg := make([]byte, 1, 1+4+len(payload)) | |
| msg[0] = tag | |
| msg = append(msg, withLength(payload)...) | |
| _, err := c.conn.Write(msg) | |
| return err | |
| func (c *pgClient) writeFull(buf []byte) error { | |
| for len(buf) > 0 { | |
| n, err := c.conn.Write(buf) | |
| if err != nil { | |
| return err | |
| } | |
| if n <= 0 { | |
| return io.ErrShortWrite | |
| } | |
| buf = buf[n:] | |
| } | |
| return nil | |
| } | |
| func (c *pgClient) writeMessage(tag byte, payload []byte) error { | |
| msg := make([]byte, 1, 1+4+len(payload)) | |
| msg[0] = tag | |
| msg = append(msg, withLength(payload)...) | |
| return c.writeFull(msg) |
Summary
Adds
postgres-wire-features/, a stdlib-only Go HTTP service that speaks the Postgres v3 frontend/backend protocol directly (no driver). It packages ten Postgres feature areas behindGET /run/allandGET /case/<name>so a single Keploy record/replay pass covers a wide surface of wire-protocol message types in one sample.setupDROP TABLE,CREATE TABLE,INSERT,CREATE TABLE IF NOT EXISTSdmlINSERT … ON CONFLICT DO UPDATE,UPDATE,DELETE,MERGEcatalog-cteWITH … SELECT FROM pg_catalog.pg_classcatalog-subSELECT … FROM (SELECT …) AS scatalog-setopSELECT … UNION SELECT …copyTRUNCATE,COPY … FROM STDIN,COPY … TO STDOUTpreparePREPARE,EXECUTE,DEALLOCATEcursorBEGIN,DECLARE CURSOR,FETCH×2,CLOSE,COMMITadminANALYZE,REINDEX,LOCK TABLE,DO $$ … $$,CREATE PROCEDURE,CALLvalidation-pingSELECT 'ok',SELECT true,SELECT NULL,SELECT 1Each scenario's JSON response carries the per-statement command tag, rows, and (for
COPY TO STDOUT) thecopyDatapayload, so replay diffs localise cleanly to the Postgres statement that differed.Why stdlib / no driver
Driver libraries hide a number of wire-level details (extended-query portals,
CopyData/CopyDoneframing, raw command tags). Talking the v3 protocol directly overnet.Connmeans the recorded Postgres traffic is what Keploy's proxy actually saw, which is the point of a wire-protocol sample.Layout
Mirrors the
http-postgres/convention:main.go— server + scenariosDockerfile— two-stage (golang:1.24-alpine → alpine:3.19)docker-compose.yml—api+db(postgres:16-alpine) with healthchecktest.sh— traffic script (/health,/run/all, each/case/<name>)README.md— record & replay instructionsRelated
Test plan
docker compose up --buildbrings the stack up andcurl http://localhost:8080/healthreturnsok./test.shexercises every/case/<name>and/run/allkeploy record -c "docker compose up" --container-name api --delay 10captures HTTP tests + Postgres mocks underkeploy/keploy test -c "docker compose up" --container-name api --delay 20replays; diffs are reported per-scenario in the JSON body🤖 Generated with Claude Code