HTTP API
server.NewHTTPHandler(service) returns an http.Handler serving nine routes. Every
response the handler itself produces is JSON; the two exceptions come from ServeMux
below. The main that mounts it is Running a Server.
The
acor/servermodule is experimental and separately versioned — these paths and shapes can change in any release.
For a V3 collection, use server.NewVersionedHTTPHandler(collection) as a separate
handler. It retains /healthz and /v1/status, and exposes versioned searching, bounded
text transformation, and expected-version writes under /v1/versioned/. For metrics, use
server.NewVersionedHTTPHandlerWithObservability(collection, observability) with a
server.VersionedObservability containing a registry and stable Collection label.
The collection-labeled acor_versioned_* gauges refresh on each Prometheus scrape,
without a preceding status request. The deprecated handler’s optional registry argument
is ignored.
V3 routes are all POST: find, scan, mask, replace-text, replace, add,
remove, add-many, remove-many, wait, and resolve-operation. Write requests carry
expected_version; their response carries previous_version, version, operation_id,
added, and removed. A write confirms the Redis commit, not that this server is already
searching the new engine. Call wait with the returned version when read-after-write is
needed. A 202 response with outcome:"unknown" carries the operation ID for safe
resolve-operation recovery; never blindly reapply it.
scan returns original byte and rune spans and accepts max_input_bytes, max_matches,
max_candidates, kind, and whole_word. mask takes exactly one Unicode-rune mask;
replace-text takes a literal replacement. The callback-only WordRune option is not
serializable and remains available only in the Go API. V3 requests use the same 1 MiB body
cap as the legacy handler.
Endpoints
| Method | Path | Request | Success response |
|---|---|---|---|
GET | /healthz | — | {"status":"ok"} |
POST | /v1/add | {"keyword":"..."} | {"count":1} |
POST | /v1/remove | {"keyword":"..."} | {"count":1} |
POST | /v1/find | {"input":"..."} | {"matches":["..."]} |
POST | /v1/find-index | {"input":"..."} | {"matches":{"kw":[0,12]}} |
POST | /v1/suggest | {"input":"..."} | {"matches":["..."]} |
POST | /v1/suggest-index | {"input":"..."} | {"matches":{"kw":[0]}} — always [0] |
GET | /v1/info | — | {"keywords":3,"nodes":7} |
POST | /v1/flush | — | {"status":"ok"} |
The V3 status handler returns active_version, serving_version, building,
refresh_failures, active_leases, and refresh timestamps. /healthz returns 503 when
no engine is serving or the last refresh failed. Error text and Redis key names are not
included in this response.
count is how many keywords the operation actually changed, so a second add of the same
keyword answers {"count":0}. /v1/flush takes no body, does not read one if you send it,
and deletes every key in the collection. The method column is enforced: anything else is
405.
curl -sX POST localhost:8080/v1/add -d '{"keyword":"redis"}'
# {"count":1}
curl -sX POST localhost:8080/v1/find -d '{"input":"redis-backed matching"}'
# {"matches":["redis"]}
curl -sX POST localhost:8080/v1/find-index -d '{"input":"redis and redis"}'
# {"matches":{"redis":[0,10]}}
curl -s localhost:8080/v1/info
# {"keywords":1,"nodes":6}
The two *-index routes do not mean the same thing
/v1/find-index returns, per keyword, the positions where it matched — counted in
runes, not bytes:
curl -sX POST localhost:8080/v1/find-index -d '{"input":"한글 레디스 and redis"}'
# {"matches":{"redis":[11],"레디스":[3]}}
레디스 starts at rune 3 but byte 7; redis at rune 11 but byte 21. A client slicing the
original string by these numbers must count runes — Go []rune, Python str indices,
Rust chars(). JavaScript needs care in both directions, since its string indices are
UTF-16 code units: [...s] gives code points, and anything outside the BMP counts as two
units under .slice().
/v1/suggest-index looks like the same shape but is not. Suggest matches the input as a
prefix, so every match starts at the beginning by construction and the implementation
assigns exactly [0] to every suggestion. Read it as “these keywords start with your
input”, not as a position list.
Errors
Every failure the handler produces is {"error":"<message>"} with
Content-Type: application/json.
| Status | When | Body |
|---|---|---|
400 | Body is not valid JSON | {"error":"unexpected EOF"} — from encoding/json, not a stable string |
400 | Body holds more than one JSON value | {"error":"request body must contain only a single JSON value"} |
405 | Wrong method for the path | {"error":"method not allowed"} |
413 | Reading the body reaches the 1 MiB cap | {"error":"request body must not be larger than 1048576 bytes"} |
409 | V3 expected-version conflict | {"error":"..."} |
503 | V3 maintenance or closed collection | {"error":"..."} |
500 | Any error from the underlying collection | {"error":"<the error's own text>"} |
404 | No such path | text/plain, 404 page not found |
301 | Path needs canonicalizing (/v1//info) | text/html, Go’s Moved Permanently page |
The body is read as it is decoded, so whichever fault surfaces first is the one you get: an
oversized body that is also malformed comes back as the 400, because the decoder
reaches the bad byte before the reader reaches the cap.
Every collection error is a 500. There is no error taxonomy — a client mistake and a
Redis outage are indistinguishable by status code. Writing to a V1 collection, which the
core rejects with ErrV1ReadOnly, arrives as
500 {"error":"V1 collections are read-only; migrate with MigrateV1ToV2"}, not a 4xx.
Telling “retry this” from “fix your request” means matching on message text, and message
text is not part of any promise.
/readyz is not part of this handler. NewHTTPHandler and NewHTTPServer register
only the routes above, so /readyz returns the 404. It exists only if you mount
health.RegisterHTTPHandlers on an outer mux, as Running a Server does.
Not every response is JSON. The 404 and the 301 are answered by Go’s
http.ServeMux before ACOR’s handler sees them. /v1//info, /v1/./info, and
/v1/../v1/info all redirect to /v1/info — and a client that follows redirects
automatically may downgrade the POST to a GET and then receive a 405, turning a
doubled slash into a confusing method error. Normalize paths before sending.
Disconnecting does not cancel the work
For legacy routes, a client timeout or dropped connection ends the request, not the Redis operation behind
it. Each legacy handler passes r.Context() into its adapter and the adapter discards it
(server/server.go:96 and siblings take _ context.Context); Service declares no
context on any method, and (*AhoCorasick).Add runs against the collection’s own
long-lived context.
So a client that gives up on /v1/add, /v1/remove, or /v1/flush has not prevented
the write:
- Do not read a timeout as “it did not happen”. Re-read with
/v1/infoor/v1/findbefore retrying anything that mutates. - Retrying can double-apply.
addandremoveare idempotent by keyword; a retriedflushis a second flush. - A short client timeout sheds no server load — the Redis work continues at full cost.
Legacy gRPC RPCs behave identically: same Service interface, same discard.
V3 routes pass the request context into the collection. An interrupted write can still
have an ambiguous commit; use its operation receipt to recover rather than blindly retry.
What the server does not check
- Request
Content-Typeis ignored — any value is accepted if the body parses as JSON. - Unknown JSON fields are ignored.
{"keyword":"x","bogus":1}succeeds. - Missing fields become the zero value.
POST /v1/addwith{}is anAdd(""). - There is no authentication.
/v1/flushis reachable by anyone who can reach the port.