Skip to content

Survive json 3.0.0, which broke every API response for users too - #39

Merged
tibortc merged 1 commit into
mainfrom
fix-json-3-compat
Sep 7, 2026
Merged

tibortc merged 1 commit into
mainfrom
fix-json-3-compat

Conversation

@tibortc

@tibortc tibortc commented Sep 7, 2026

Copy link
Copy Markdown
Owner

main is currently broken, and so is every fresh gem install. This is the fix.

What happened

json 3.0.0 dropped the second positional argument to JSON.parse. Faraday::Response::Json#parse still passes one:

decoder.public_send(method_name, body, @parser_options || {})

So every JSON response from KSeF raised Faraday::ParsingError: wrong number of arguments (given 2, expected 1).

This reached users, not just CI. The gemspec requires faraday "~> 2.0", faraday declares json >= 0, and faraday 2.14.3 is the latest release — so gem install ksef_client produced a client that could not read any API response.

It arrived with no commit here. Gemfile.lock is gitignored by library convention, so CI resolves fresh — and rubocop 1.90.0 relaxed its own json ~> 2.3 pin to >= 2.3, which is what let json 3.0.0 in. A transitive development pin had been shielding the build by accident.

Measured in a clone bundled against json 3.0.0, before and after in the same clone:

Examples Failures
unfixed 1580 163
fixed 1581 0 (coverage gates enforced, exit 0)

All 163 were this one cause. lib/ and tasks/ make no direct JSON calls at all — faraday's two middlewares are the gem's entire JSON surface — and the encode side is unaffected.

The fix

Faraday's response middleware takes a caller-supplied decoder, read from inside parser_options, so only the failing call changes. Ksef::HTTP::JsonDecoder provides it, and everything else the middleware does is retained: the content-type match with its ; split (so application/problem+json; charset=utf-8 still parses), the to_str guard (so a 204 doesn't raise on nil.strip), blank body → nil, and Faraday::ParsingError wrapping — which published users may already rescue.

It also leaves connection_spec.rb's ordering pin working untouched, since that names Faraday::Response::Json by class. Replacing the middleware wholesale would have meant re-implementing six behaviours and rewriting the spec that guards the ordering invariant — I'd originally recommended that; it was the worse plan.

Three traps, all measured

Trap Behaviour
parser_options in a frozen constant FrozenError on the first request — the read is a destructive delete
One hash shared by two connections first request empties it to {}; second silently reverts to ::JSON.parse and fails again
decoder: JSON (faraday's respond_to?(:load) branch) JSON.load(body, {}) returns nil for a valid body, no exception

So the hash stays a fresh literal in build and the decoder is given in array form. CLAUDE.md says not to tidy either, because "hoist it into a frozen constant" is exactly what this codebase's style would otherwise invite.

Two things easy to miss

spec/ksef/http/retry_spec.rb builds its own stack with a bare f.response :json — without the same wiring, 7 of the 163 survive a fix to connection.rb alone.

Nothing in lib/ requires json today; it has always arrived via faraday's middleware file, which masks the gap. JsonDecoder requires it itself.

The guard

connection_spec.rb now asserts our decoder is the one actually called. It is the only thing that can see the wiring go missing: under json 2 the default decoder works, so removing it leaves every other example green — which is precisely how this arrived unannounced. Confirmed by removing the wiring under json 2.21.2: exactly that one example fails.

A message expectation rather than a peek at faraday's @kwargs — what matters is that the parse goes through our module, not how faraday stores the option.

Removal trigger

docs/REFERENCE.md §4.9 records it. Faraday merged json 3 support in PR #1687 on 2026-08-12 and has not released it (2.14.3 shipped 2026-06-16). JsonDecoder's optional second parameter satisfies both that call shape (kwargs) and today's (two positionals), so the shim needs no version check and can be deleted when the gemspec floor moves.

Why not a pin

Pinning json < 3 — as a runtime dep or in the Gemfile — would fight faraday's own json >= 0 and rubocop ≥1.90's json >= 2.3. A Rails app on json 3 would become uninstallable alongside ksef_client. And a dev-only pin would leave users broken while CI went green, which is the pattern behind the skipped coverage gate.

Verified

rake exit=0 · 1581 examples, 0 failures · line 100 / branch 98.76 / method 100 · RuboCop clean over 187 files · release gates 1597 · green on the 3.2.11 floor · and green under json 3.0.0.

Note on #38

PR #38 (workflow guards) is red for this reason alone and will go green once this lands. It needs no change of its own.

`json 3.0.0` dropped the second *positional* argument to `JSON.parse`;
options are keyword-only now. `Faraday::Response::Json#parse` still
calls `decoder.public_send(method_name, body, @parser_options || {})`,
so every JSON response from KSeF raised

    Faraday::ParsingError: wrong number of arguments (given 2, expected 1)

**This reached users, not just CI.** The gemspec requires
`faraday "~> 2.0"`, faraday declares `json >= 0`, and faraday 2.14.3 is
the latest release — so `gem install ksef_client` produced a client that
could not read any response.

It arrived with no commit here. `Gemfile.lock` is gitignored by library
convention, so CI resolves fresh, and **rubocop 1.90.0 relaxed its own
`json ~> 2.3` pin to `>= 2.3`**, which is what let json 3.0.0 in. A
transitive development pin had been shielding the build by accident.

Measured in a clone bundled against json 3.0.0, before and after:
1580 examples / **163 failures** -> 1581 examples / **0 failures** with
the coverage gates enforced. Every one of the 163 was this one cause;
`lib/` and `tasks/` make no direct JSON calls at all, so faraday's two
middlewares are the gem's entire JSON surface, and the encode side is
unaffected.

Faraday's response middleware takes a caller-supplied decoder, read from
*inside* `parser_options`, so only the failing call changes.
`Ksef::HTTP::JsonDecoder.call(body, _options = nil)` provides it and
everything else the middleware does is retained — the content-type match
with its `;` split, so `application/problem+json; charset=utf-8` still
parses; the `to_str` guard, so a 204 does not raise; blank body to nil;
and `Faraday::ParsingError` wrapping, which published users may rescue.
It also leaves `connection_spec.rb`'s ordering pin — which names
`Faraday::Response::Json` by class — working untouched. Replacing the
middleware would have meant re-implementing six behaviours and rewriting
the spec that guards the ordering invariant.

Three traps, all measured, all recorded on the code:

- `parser_options` in a **frozen** constant raises `FrozenError` on the
  first request, because the read is a destructive `delete`.
- One hash **shared** by two connections is emptied by the first, and the
  second silently reverts to `::JSON.parse` and fails again.
- `decoder: JSON` takes faraday's `respond_to?(:load)` branch, and
  `JSON.load(body, {})` under json 3 returns **nil** for a valid body
  with no exception.

So the hash stays a fresh literal in `build` and the decoder is given in
array form. CLAUDE.md says not to tidy either.

`spec/ksef/http/retry_spec.rb` builds its own stack with a bare
`f.response :json` and needs the same wiring — without it, seven of the
163 survive a fix to `connection.rb` alone.

The guard example in `connection_spec.rb` asserts our decoder is the one
called. It is the only thing that can see the wiring go missing: under
json 2 the default decoder works, so removing it leaves every other
example green — which is precisely how this arrived unannounced.
Confirmed by removing the wiring under json 2.21.2: exactly that one
example fails.

`docs/REFERENCE.md` §4.9 carries the removal trigger. Faraday merged json
3 support in PR #1687 on 2026-08-12 and has not released it;
`JsonDecoder`'s optional second parameter satisfies both that call shape
and today's, so the shim needs no version check and can be deleted when
the floor moves.

No new dependency and no version pin: pinning `json` would fight
faraday's own `json >= 0` and rubocop's `json >= 2.3`, making this gem
uninstallable beside them.

Verified: `rake exit=0`, 1581 examples, line 100 / branch 98.76 /
method 100, RuboCop clean over 187 files, release gates 1597, green on
the 3.2.11 floor, and green under json 3.0.0.
@tibortc
tibortc merged commit 20ddc4b into main Sep 7, 2026
7 checks passed
@tibortc
tibortc deleted the fix-json-3-compat branch September 7, 2026 09:42
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