Skip to content

Idempotency: Kong 3.9.2 readiness, shared Redis schema, full test suite, security fixes & DB-less playground - #7

Open
udleinati wants to merge 15 commits into
masterfrom
feat/tests
Open

udleinati wants to merge 15 commits into
masterfrom
feat/tests

Conversation

@udleinati

Copy link
Copy Markdown
Owner

Summary

Brings the idempotency plugin up to a production-ready standard for Kong 3.9.2, mirroring the conventions of kong-plugin-the-middleman: shared Redis schema, a full test suite, and a DB-less playground. Adversarial / chaos testing along the way surfaced several real bugs, all fixed and covered by tests.

Kong 3.9.2 readiness

  • Migrated Redis config to Kong's shared config.redis.* record (kong.tools.redis.schema, Kong 3.6+) — vault-referenceable credentials, TLS/SNI, per-DB pooling. Legacy flat redis_* keys still accepted via shorthand_fields.
  • Proper connection lifecycle: get_reused_times-gated AUTH/SELECT, per-DB pool name, set_keepalive release. Fail open (log + passthrough) on Redis errors instead of crashing.
  • Fixed bogus rockspec dependency (redis-lua → none; lua-resty-redis ships with Kong). Bumped to 1.2.0.

Bugs found & fixed (via unit + chaos testing)

  • Cross-consumer leak (security): the cache key was not scoped by the authenticated consumer, so consumer B reusing A's X-Idempotency-Key received A's cached response. Key now includes the consumer id.
  • Cross-host leak: key did not include the request host. Now included.
  • Empty key leak: an empty X-Idempotency-Key ("", truthy in Lua) was a shared key. Now treated as no key.
  • Fractional redis_cache_time: schema accepted number, so a value like 1.5 made SET ... EX fail at runtime and silently disabled idempotency. Now integer, rejected at config time.
  • 500 on key collision: a key ending in -response collided lock/response slots, and the unguarded cjson.decode of the resulting non-object value crashed the request. Lock/response keys now use a fixed :lock: / :resp: discriminator (collision impossible) and the decode is pcall-guarded + type-checked (corrupt value → 409, never 500).
  • Orphaned lock on upstream failure: when the original acquired the lock but never cached a response (upstream reset mid-flight), every retry got 409 for the whole TTL. The lock is now released in the log phase via ngx.timer.at (Redis cosockets are disabled in log_by_lua).

New key format: [user::]prefix:<consumer>:<host>:<path>:<method>:{lock|resp}:<key>.

Tests & tooling

  • 60 unit + integration tests; luacheck clean. Unit tests are fully mocked; integration tests boot real Kong + Redis via Pongo.
  • CI matrix across Kong 3.6.1 / 3.8.0 / 3.9.2 (.github/workflows/test.yml); Makefile + .busted + .pongo/pongorc.
  • Verified locally on Kong 3.6.1 and 3.9.2.

DB-less playground

  • Rewritten as DB-less Kong 3.9.2 (config lives in kong.yml; no Postgres/migrations/custom image).
  • show-config.sh, demo.sh (walkthrough + assertions), and edge.sh (13 adversarial probes incl. concurrency, sticky 5xx, binary/gzip bodies, key collision, upstream failure, client disconnect) — all green (0 bugs).

Known limitations (documented in README)

  • Only POST is handled.
  • A produced 5xx is cached for the window (sticky); could be made configurable.
  • Narrow lock/response TTL-skew window remains.

🤖 Generated with Claude Code

udleinati and others added 15 commits June 6, 2026 21:33
Bring the idempotency plugin up to the-middleman's standard.

Code:
- Migrate Redis config to the shared kong.tools.redis config.redis.* record
  (Kong 3.6+), keeping legacy flat redis_* keys via shorthand_fields.
- Proper connection lifecycle (get_reused_times-gated AUTH/SELECT, per-DB pool,
  set_keepalive release); fail open on Redis errors instead of crashing.
- Only the lock-winning request persists the response (kong.ctx.plugin.store),
  so duplicates/passthrough skip Redis and the TTL window stays fixed.
- Extract pure keys.lua (single source of truth for key format).
- Strip volatile headers (content-length/transfer-encoding/connection) on cache.
- Fix bogus rockspec dependency (redis-lua -> none; resty.redis ships with Kong).

Tests & tooling:
- Unit (mocked) + integration (real Kong + Redis via Pongo): 48 tests.
- Add .busted, Makefile, .pongo/pongorc, CI matrix (Kong 3.6.1/3.8.0/3.9.2).

Docs: rewrite README; pin playground to kong:3.9.2.

Bump to 1.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial testing surfaced correctness/security issues in the cache key:

- Cross-consumer leak: the key was not scoped by the authenticated consumer,
  so consumer B reusing consumer A's X-Idempotency-Key received A's cached
  response. The key now includes the consumer id (or "anonymous").
- Cross-host leak: the key did not include the request host, so the same key
  on different hosts collided. The key now includes the host.
- Empty key: an empty X-Idempotency-Key header (truthy "") was treated as a
  valid shared key, leaking responses across unrelated requests. It is now
  treated as no key (passthrough, or 400 when required).
- Fractional TTL: redis_cache_time was a "number"; a fractional value made
  `SET ... EX` fail at runtime, silently disabling idempotency. It is now an
  integer, rejected at config time.

New key format: [user::]prefix:<consumer>:<host>:<path>:<method>:<key>.
Specs updated; suite now 55 tests (lint clean) on Kong 3.6.1/3.9.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- DB-less Kong 3.9.2: the whole setup lives in kong.yml; drop Postgres,
  migrations, the Node loader and the custom image (plugin is volume-mounted).
- service.js: JSON echo with a per-request X-Upstream-Id plus control headers
  (X-Echo-Status/Delay/Header/Binary/Gzip) used by the scripts.
- show-config.sh: prints kong.yml + the loaded config via the Admin API.
- demo.sh: step-by-step walkthrough that also asserts (smoke test).
- edge.sh: adversarial probes (concurrency, sticky 5xx, binary/gzip bodies,
  empty key, host/path/method scoping, TTL expiry) labelled PASS/NOTE/BUG.
- explicit compose project name to avoid collisions with other playgrounds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more issues found via chaos testing:

- 500 on cache-key collision: an idempotency key ending in "-response" produced
  a lock key equal to another key's response key, and the unguarded
  cjson.decode of that non-object value crashed the request. Lock and response
  keys now use a fixed 🔒 / :resp: discriminator before the key (collision
  impossible), and the decode is wrapped in pcall + a type check (a corrupt or
  foreign value yields 409, never a 500).

- Orphaned lock on upstream failure: when the original request acquired the
  lock but never cached a response (e.g. the upstream reset the connection),
  the lock blocked every retry with 409 for the whole TTL. The lock is now
  released in the log phase via ngx.timer.at (Redis cosockets are disabled in
  log_by_lua, so the delete is deferred to a timer).

Suite now 60 tests (lint clean) on Kong 3.6.1/3.9.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- service.js: X-Echo-Reset (drop the connection mid-flight) and X-Echo-Gzip.
- edge.sh: probes 11-13 — a "-response" key does not crash the plugin, an
  upstream failure frees the lock so retries succeed, and a client disconnect
  still serves the cached result on retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The kong-pongo executable is `pongo.sh`; adding the clone dir to PATH did not
provide a `pongo` command (exit 127). Symlink pongo.sh -> ~/.local/bin/pongo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Silences the Node.js 20 deprecation warning; checkout@v5 runs on Node.js 24.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (v1.3.0)

Four configurable behaviours:

- verify_fingerprint (default true): store an md5 of the request body + query as
  the lock value; reusing a key with a different request returns 422 (conflict)
  instead of replaying the original response.
- methods (default ["POST"]): apply idempotency to POST/PUT/PATCH/DELETE.
- cache_5xx (default false): do not cache 5xx responses; the lock is released so
  the client can retry after a transient server error.
- fail_open (default true): when false, a Redis outage is rejected with 503
  instead of passing the request through.

Bump to 1.3.0. Suite now 72 tests (lint clean) on Kong 3.6.1/3.9.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mode

- kong.yml: /multi (POST/PUT/PATCH/DELETE) and /strict (fail_open=false) routes.
- edge.sh: probes 14-17 — 422 on key reuse with a different body, 5xx not cached
  (retry reprocesses), PUT idempotent on /multi, and 503 vs passthrough with
  Redis stopped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tests for behaviours that were untested (or only exercised in the
playground's edge.sh):

- unit: cache.del(); fingerprint includes the query string; fingerprint
  fallback when the body is unavailable (large/spooled); 503 on a GET error in
  strict mode.
- integration: PUT idempotency (methods); strict mode 503 vs fail-open
  passthrough against an unreachable Redis; per-consumer scoping (key-auth, two
  consumers, same key -> no cross-consumer leak).

Also reject an empty `methods` list (len_min=1) so the plugin cannot be
silently disabled. Suite now 82 tests (lint clean) on Kong 3.6.1/3.9.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix the POST-only wording: idempotency now covers POST/PUT/PATCH/DELETE
  via config.methods.
- state that the client owns idempotency-key uniqueness.
- add Requirements (Kong >= 3.6, Redis) and a worked curl Example showing the
  completed / conflict (422) / waiting_response (409) flows.
- note ordering (runs after auth, enabling per-consumer scope) and response
  buffering; point fail_open=false at the 503 behaviour.
- refresh the integration-tests description and add a License section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Major bump: breaking changes vs the 1.x line — the Redis key format now scopes
by consumer/host (and uses 🔒/:resp: discriminators), the config moved to
Kong's shared config.redis.* record, and verify_fingerprint defaults to on
(reusing a key with a different request now returns 422).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHub renders Mermaid natively, so the request-flow decision tree (passthrough,
400, completed, replay, 422 conflict, 409 in-flight) is visible at a glance, with
colours mapping to the X-Idempotency-Status / HTTP outcome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… each

Three deepenings from an architecture review. Each takes a concept that was
smeared across phases and gives it a single module that owns it.

- lifecycle: the {store, lock_key, cached} protocol the access/response/log
  phases coordinated through on kong.ctx.plugin. The "free an orphaned lock?"
  rule now lives in lifecycle.orphaned_lock() instead of inline in the handler;
  only the timer + Redis del IO stays at the edge.

- scope: the {host, path, method, consumer} descriptor was rebuilt inline in
  both the access and response phases. scope.from_request() builds it once and
  feeds the still-pure keys module.

- payload: encode + the volatile-header strip lived in response, decode +
  validation in access. payload now owns the stored-response contract end to
  end; access and response no longer touch cjson directly.

New pure unit specs cover each module's interface. The existing
access/response/handler specs are unchanged: field names stayed stable, so they
still verify the resulting state. Adds CONTEXT.md recording the domain
vocabulary the modules are named after.

Full suite (unit + integration, real Kong + Redis): 99/0/0/0; luacheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…is variant

Expand the playground from happy-path + resilience coverage to exhaustive
behavioural coverage. New routes/consumers in kong.yml and a password-protected
Redis in docker-compose; edge.sh grows from 17 to 26 probes.

Config flags, both states:
- cache_5xx=true (/cache5xx) and verify_fingerprint=false (/nofp); the redundant
  edge probe #6 is repurposed to test cache_5xx=true (it was duplicating #15).

Scoping (all three isolation dimensions):
- per-consumer (/auth, key-auth + alice/bob consumers), host, and the existing path.

Redis connection variants (previously only mock-tested):
- non-default logical database (/db, redis.database=1; keys verified in db 1);
- AUTH against a password-protected Redis (/redis-auth).

Methods & fidelity:
- PATCH and DELETE on /multi; gzip replay via the advertised X-Echo-Gzip hook
  (Content-Encoding preserved byte-for-byte).

Defensive paths:
- corrupt cached value -> 409 (payload.decode), empty key on /required -> 400.

show-config.sh now lists consumers; README routes/probe list updated. Verified
live: demo.sh 11/0, edge.sh 26 probes, 0 BUG / 2 NOTE (by-design method/path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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