Skip to content

feat: a Go client, written against the HTTP surface - #20

Merged
9bany merged 1 commit into
masterfrom
feat/go-client
Sep 4, 2026
Merged

9bany merged 1 commit into
masterfrom
feat/go-client

Conversation

@9bany

@9bany 9bany commented Sep 4, 2026

Copy link
Copy Markdown
Member

clients/go was an empty directory. The Python client landed in #19 and the same argument applies here: anyone reaching for bigdb from Go had to write their own HTTP and their own escaping, and escaping is where a mistake is a security bug rather than a wrong answer. archived/featurebase is Go and is not a starting point - it speaks a different server's routes.

Same scope as the Python client: /sql, /table/{t}/query, /table/{t}/import, /table/{t}/delete, /table/{t}/records, /schema, /health, /ready, and the DDL routes. Not the operator surface. A Client, an iter.Seq2 walk over record ids, and a database/sql driver so sqlx and anything else that speaks it works.

No dependencies. go.mod has no require block, there is no go.sum, and CI fails if either changes - make deps walks go list -deps and asserts every package is either stdlib or this module.

What is ported rather than reinvented, because a client with its own idea of these agrees with the server until it does not:

  • sql.go is contrib/big-message/src/sql.rs function for function, with its test vectors. Identifiers are always quoted, numbers are checked against big_sql::lex's own grammar, and nil is refused because the dialect has no NULL literal. One thing is shorter here: strconv.FormatFloat with 'f' and precision -1 never emits an exponent, so push_float's search for a spelling has nothing to search for. 1e300 is still refused, by the same u64 overflow it reaches in Rust.
  • The retry rules and the connection lifetime from big-message's readme. TransportError.Sent is the split: false is always safe to repeat, true is never repeated on a route that allocates. The connection is retired at 900 requests and 3s idle against the server's 1000 and 5s, so the idle-close race is unreachable rather than handled. Through database/sql the same two rules are driver.Validator.IsValid and driver.SessionResetter.ResetSession, which the pool already calls at exactly those two moments.

Three things Go forces that the Python client did not have to answer:

  • net/http is not the transport. It knows whether a request reached the wire and discards the fact: when a request will not be retried, transport.go unwraps nothingWrittenError and transportReadFromServerError to the underlying error, so "nothing was written" and "written in full, then silence" arrive identical. That one bit is the whole retry policy. Its own retrying is not the problem and the readme says so. WithDoer takes an implementation for anyone who needs a proxy more than the distinction.
  • encoding/json decodes a number into any as a float64, and record ids are u64: 18446744073709551615 comes back as ...552000. So every shape is a typed struct and the escape hatch for a field this build has not heard of is json.RawMessage, not map[string]any. Two tests hold it, one at each end of the pipe.
  • Client takes a lock. The Python client declares threadsafety = 1 and does not, on the grounds that a lock serialises calls without saying so. In Go a client that cannot be shared will be shared anyway; the readme says it is one connection and points at database/sql for parallelism.

?database= is folded into the table name rather than sent as a parameter alone. #18 made the parameter scope the data routes, but the DDL routes read the path only - so a client that set a database would CreateTable in the default one and Import into another, with a 200 at every step. A qualified path works on every route and is documented to win over a disagreeing parameter, so WithDatabase has one meaning across the whole client. The parameter is still sent; the permission check reads it.

Testing is 3,700 lines against 4,100 of source, and the vectors are the repository's own so the two sides are held to each other rather than each to itself. The fake server is a raw net.Listen rather than httptest.Server, which cannot emit a Content-Length that lies or a response cut off mid status line - the cases that matter. Integration is behind -tags integration and skips with instructions when no big binary is found; it does not build one, because a go test that shells out to cargo fails outright without Rust.

The integration suite found the schema snapshot bug fixed in #18 - a create that answered 200, a field route that answered 200, and a write that said the table did not exist. table() asserts the schema admits a table exists rather than trusting the status code, which is what caught it, and a regression test pins drop-then-create.

`clients/go` was an empty directory. The Python client landed in #19 and the
same argument applies here: anyone reaching for bigdb from Go had to write
their own HTTP and their own escaping, and escaping is where a mistake is a
security bug rather than a wrong answer. `archived/featurebase` is Go and is
not a starting point - it speaks a different server's routes.

Same scope as the Python client: `/sql`, `/table/{t}/query`,
`/table/{t}/import`, `/table/{t}/delete`, `/table/{t}/records`, `/schema`,
`/health`, `/ready`, and the DDL routes. Not the operator surface. A `Client`,
an `iter.Seq2` walk over record ids, and a `database/sql` driver so `sqlx` and
anything else that speaks it works.

No dependencies. `go.mod` has no `require` block, there is no `go.sum`, and CI
fails if either changes - `make deps` walks `go list -deps` and asserts every
package is either stdlib or this module.

What is ported rather than reinvented, because a client with its own idea of
these agrees with the server until it does not:

- `sql.go` is `contrib/big-message/src/sql.rs` function for function, with its
  test vectors. Identifiers are always quoted, numbers are checked against
  `big_sql::lex`'s own grammar, and `nil` is refused because the dialect has no
  `NULL` literal. One thing is shorter here: `strconv.FormatFloat` with `'f'`
  and precision -1 never emits an exponent, so `push_float`'s search for a
  spelling has nothing to search for. `1e300` is still refused, by the same
  `u64` overflow it reaches in Rust.
- The retry rules and the connection lifetime from `big-message`'s readme.
  `TransportError.Sent` is the split: false is always safe to repeat, true is
  never repeated on a route that allocates. The connection is retired at 900
  requests and 3s idle against the server's 1000 and 5s, so the idle-close race
  is unreachable rather than handled. Through `database/sql` the same two rules
  are `driver.Validator.IsValid` and `driver.SessionResetter.ResetSession`,
  which the pool already calls at exactly those two moments.

Three things Go forces that the Python client did not have to answer:

- `net/http` is not the transport. It knows whether a request reached the wire
  and discards the fact: when a request will not be retried, `transport.go`
  unwraps `nothingWrittenError` and `transportReadFromServerError` to the
  underlying error, so "nothing was written" and "written in full, then
  silence" arrive identical. That one bit is the whole retry policy. Its own
  retrying is not the problem and the readme says so. `WithDoer` takes an
  implementation for anyone who needs a proxy more than the distinction.
- `encoding/json` decodes a number into `any` as a `float64`, and record ids
  are `u64`: `18446744073709551615` comes back as `...552000`. So every shape
  is a typed struct and the escape hatch for a field this build has not heard
  of is `json.RawMessage`, not `map[string]any`. Two tests hold it, one at each
  end of the pipe.
- `Client` takes a lock. The Python client declares `threadsafety = 1` and does
  not, on the grounds that a lock serialises calls without saying so. In Go a
  client that cannot be shared will be shared anyway; the readme says it is one
  connection and points at `database/sql` for parallelism.

`?database=` is folded into the table name rather than sent as a parameter
alone. #18 made the parameter scope the data routes, but the DDL routes read
the path only - so a client that set a database would `CreateTable` in the
default one and `Import` into another, with a 200 at every step. A qualified
path works on every route and is documented to win over a disagreeing
parameter, so `WithDatabase` has one meaning across the whole client. The
parameter is still sent; the permission check reads it.

Testing is 3,700 lines against 4,100 of source, and the vectors are the
repository's own so the two sides are held to each other rather than each to
itself. The fake server is a raw `net.Listen` rather than `httptest.Server`,
which cannot emit a `Content-Length` that lies or a response cut off mid status
line - the cases that matter. Integration is behind `-tags integration` and
skips with instructions when no `big` binary is found; it does not build one,
because a `go test` that shells out to cargo fails outright without Rust.

The integration suite found the schema snapshot bug fixed in #18 - a create
that answered 200, a field route that answered 200, and a write that said the
table did not exist. `table()` asserts the schema admits a table exists rather
than trusting the status code, which is what caught it, and a regression test
pins drop-then-create.
@9bany
9bany merged commit c2ef37d into master Sep 4, 2026
22 of 24 checks passed
@9bany
9bany deleted the feat/go-client branch September 4, 2026 07:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant