Make array length: a bound, not just a report - #24
Conversation
Code review — #24 Array length: as a boundVerdict: mergeable with fixes. The code change is correct, minimal, and verified end to end. What needs fixing is around it: the README's new "declare Verified
Strengths
Important
Minor
|
| # element, collect 200k more violations, and answer with a multi-megabyte | ||
| # 422 — for a request already refused by its first check. Stopping here | ||
| # keeps the cost of an oversized array proportional to rejecting it. | ||
| if field[:length] && !Coercion.length_ok?(field[:length], value.length) |
There was a problem hiding this comment.
Returning early with a single length violation when !Coercion.length_ok? prevents allocating thousands of unnecessary element violation objects for a payload that is already rejected.
| e = violations_for({ line_items: Array.new(300) { {} } }, &decl) | ||
| expect(e.details).to eq([{ param: "line_items", code: "length" }]) | ||
| end | ||
|
|
There was a problem hiding this comment.
Asserting that validate: and transform: callbacks do not execute on rejected oversized arrays ensures consistent lifecycle behavior with invalid payloads.
length: recorded its violation and then cast, checked and reported on every element anyway. So an oversized array cost far more to REJECT than a valid one costs to accept: 200,000 elements against `array :tags, of: :string, length: 0..10` before: 200,001 violations, ~9.5 MB error body, ~9.2 s after: 1 violation, ~40 bytes, ~57 ms That is a request the contract refused on its very first check, failing against the exact bound a developer declares to prevent this. The 422 body was itself the amplification: one small request, a multi-megabyte response, and every violation individually constructed and message- resolved on the way. An array outside its length: bound is rejected whatever its contents, so examining those contents can only add cost and noise. It now returns immediately. (The ~57 ms is the contract's own work; the rest of the remaining wall time is the HashWithIndifferentAccess conversion of the payload, which happens before any field is examined and which a real Rails request has already paid.) One consequence worth knowing: validate: and transform: are no longer handed an array the contract has already rejected — the same rule transform: already followed for element violations, now applied to the count too. Arrays within their bounds, and arrays with no length: declared, behave exactly as before. Notably the :json field's length:/max_depth: already worked this way, returning on the first failed bound; this brings arrays in line with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
decaa0c to
e6c624b
Compare
The bug
length:recorded its violation and then cast, checked and reported on every element anyway. So an oversized array cost far more to reject than a valid one costs to accept.200,000 elements against
array :tags, of: :string, length: 0..10:That is a request the contract refused on its very first check, failing against the exact bound a developer declares to prevent this.
The 422 body was itself the amplification: one small request in, a multi-megabyte response out, with every violation individually constructed and message-resolved along the way.
The fix
An array outside its
length:bound is rejected whatever its contents, so examining those contents can only add cost and noise. It now returns immediately:On the measurement above, the remaining wall time is the
HashWithIndifferentAccessconversion of the payload itself (901 ms of the 958 ms) — that happens before any field is examined, and a real Rails request has already paid it during parameter parsing. The contract's own work went from ~8.3 s to ~57 ms.One consequence worth knowing
validate:andtransform:are no longer handed an array the contract has already rejected. That's the same ruletransform:already followed for element violations, now applied to the count too — the existing comment says it best: "a partially-nil one would hand user code garbage it never agreed to see."What doesn't change
length:declared behave exactly as before.The
:jsonfield'slength:/max_depth:already worked this way (returning on the first failed bound), so this brings arrays in line with it.Also
The README now says plainly that
length:is a bound and advises declaring it on every array — it's the only thing standing between a client and however much work your contract is willing to do.Verification
validate:/transform:not being called, in-bounds arrays unchanged, no-length:arrays unchanged, valid arrays still valid