Skip to content

feat(transport)!: collapse the transport error channel - #307

Merged
KaiSchwarz-cnic merged 6 commits into
masterfrom
RSRMID-2937/collapse-transport-error-channel
Aug 5, 2026
Merged

feat(transport)!: collapse the transport error channel#307
KaiSchwarz-cnic merged 6 commits into
masterfrom
RSRMID-2937/collapse-transport-error-channel

Conversation

@KaiSchwarz-cnic

@KaiSchwarz-cnic KaiSchwarz-cnic commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Jira

RSRMID-2937 — Collapse the transport error channel — remove the "httperror|" sentinel

Candidate 1 of 8 from the architecture review of 2026-08-04. Design settled in a grilling session the same day; all ten decisions are recorded in the Jira description and mirrored in docs/agents/architecture.md.

Problem

TransportInterface::post() already returns the failure as tuple element [1], and AbstractClient::performRequest() already destructures it. But newResponse() took no error parameter, so the transport also smuggled the same failure through the raw-response channel as the magic prefix "httperror|", which the translator then string-split back off. The failure was encoded twice because one signature was missing a parameter.

Change

  • ?string $error is now an explicit trailing parameter, appended last and defaulted null, on every hook in the pipeline: TransportInterface::post()'s tuple, AbstractClient::newResponse() and both brand overrides, AbstractResponse::__construct()/translate() and both brand overrides, AbstractResponseTranslator::translate(). Every existing positional call site keeps working; only overriders of the two abstract hooks break.
  • TransportInterface::post()'s docblock now states the contract it always implied: a non-null [1] means [0] is unusable, and HttpTransport returns ["", $error] on failure rather than any payload.
  • The two-level check is unchanged: $error !== null selects the httperror template, $error !== "" gates the {HTTPERROR} injection into it.
  • AbstractResponseTranslator::resolveTemplateId(string $raw, ?string $error, array $templates): ?string is the extracted decision point. It resolves httperror only if the brand's $templates container declares that id, so a third-party brand translator need not include it. The raw-as-template-id lookup (the addTemplate() mocking route CLAUDE.md sanctions) is kept exactly as-is — it's load-bearing, not a leak, and every new R("<templateId>") in the suite depends on it.
  • The unreachable nocurl branch is deleted: curl_init() === false cannot happen with ext-curl as a hard composer dependency. The guard becomes \assert($tmp !== false), matching the file's existing idiom; the "nocurl" entry is removed from both brand ResponseTemplateManager::$templates.
  • A follow-up fix (fix(translator): guard the httperror template lookup against a missing key) hardens resolveTemplateId() so an $error with no matching "httperror" entry degrades to null instead of a TypeError from an unconditional array-index.

Tests

  • TransportSeamTest::testTransportErrorDiscardsParseableBytes() — the one genuinely new, previously-impossible-to-express contract: a transport double returning real, parseable bytes and a non-null error together must have its bytes discarded, not merged or preferred.
  • The two existing new R("nocurl") tests are repurposed rather than deleted — they're the only direct coverage of the raw-as-template-id route, so the id is swapped for a surviving built-in.
  • tests/CNR/ResponseTranslatorTest.php / tests/IBS/ResponseTranslatorTest.php cases that proved explode("|", ..., 2) survived a pipe inside the error message are rewritten (that hazard is gone, but "the message survives verbatim through the parameter" is still worth asserting).
  • tests/CNR/ClientTest.php::testRequestCurlExecFail2 — the behaviour-preservation proof, asserting both getCode() === 421 and the exact getDescription() string against the hand-authored conn-error cassette, unchanged and still passing.
  • No *SeamTest.php guard, deliberately: the parameter is compiler-enforced (PHP performs LSP checking on abstract-method implementations, so a brand dropping ?string $error from only some of newResponse()/translate() is a fatal at declaration time), and a negative-space source sweep for the old sentinel string would be the vacuous-guard shape this repo's docs warn against. Full reasoning, including what a complete sentinel revert would and wouldn't be caught by, is in architecture.md.

Compatibility — breaking, v30.0.0

Two breaking surfaces: the brand-facing newResponse()/translate() hook signatures, and TransportInterface::post()'s newly-stated contract for third-party transport implementers. MIGRATION.md → v30.0.0 has both sub-sections plus the compatibility-table row.

composer lint exit 0 · composer test 593 passed, 1 skipped.

Note: this branch also carried RSRMID-2938 (single redaction module) earlier in the review's plan; that ticket ended up shipping separately as its own non-breaking release (v29.1.0, PR #306, already merged) rather than riding this branch. This PR is now rebased onto that merge and carries only the 2937 work.

🤖 Generated with Claude Code

HttpTransport::post() used to encode a failure twice: once as tuple
element [1], and once as a "httperror|" prefix smuggled onto the raw
payload [0], which AbstractResponseTranslator::translate() then
string-split back off. Replace the sentinel with an explicit, trailing
?string $error parameter on every hook in the pipeline
(TransportInterface::post()'s contract, AbstractClient::newResponse()
and both brand overrides, AbstractResponse::__construct()/translate()
and both brand overrides, AbstractResponseTranslator::translate()),
appended last and defaulted null so every positional call site keeps
working.

The two-level check is unchanged: $error !== null selects the
"httperror" template, $error !== "" gates the {HTTPERROR} injection.
TransportInterface::post()'s docblock now states the contract it
always implied: a non-null [1] means [0] is unusable.

Also removes the unreachable "nocurl" branch (curl_init() === false
cannot happen with ext-curl as a hard dependency) in favour of an
assert(), matching the file's existing idiom, and deletes the "nocurl"
template entry from both brand ResponseTemplateManagers.

BREAKING CHANGE: newResponse()/translate() on a brand Client/Response
gained a trailing ?string $error parameter, and TransportInterface::post()'s
contract now states that a non-null element [1] means element [0] is
discarded in favour of the "httperror" template — a custom transport that
returned bytes alongside an advisory error now has those bytes discarded.
The "nocurl" template id is also gone.

See [MIGRATION.md → v30.0.0](https://github.com/centralnicgroup-opensource/rtldev-middleware-php-sdk/blob/master/MIGRATION.md#-v3000)
Add the RSRMID-2937 entry: the transport error travels as a declared,
trailing ?string $error parameter rather than a "httperror|" sentinel
encoded into the payload; the raw-as-template-id lookup in
resolveTemplateId() stays deliberately, as the sanctioned addTemplate()
mocking route, and the method must genuinely be able to return null for
PHPStan L9 to accept its ?string signature; the nocurl template id and
its unreachable branch are gone; and why none of this needed a new
*SeamTest.php guard (the parameter is compiler-enforced via LSP on
abstract-method implementations, a naive sentinel re-introduction
already fails the conn-error cassette test on both code and
description, and a negative-space source sweep would be the vacuous
guard shape this file warns against elsewhere) — with the one
behavioural test that does pin the newly-stated TransportInterface
contract instead.
The parser seam guard (RSRMID-2924) asserted the exact number of
AbstractResponse::__construct() parameters. That count is not one of the
properties the guard's own docblock claims to protect: it guards that a
substitute parser is reachable through the public constructor by name and
that injection never becomes mandatory. The latter is already covered by
getNumberOfRequiredParameters() === 1.

Appending ?string $error in RSRMID-2937 tripped the count and reported an
undone decision where there was none, costing a detour and a guard edit
that needed justifying. A tripwire whose message misleads is worse than
no tripwire, so the assertion is removed and a comment records why it
must not come back. Both substantive assertions are untouched.
…g key

resolveTemplateId() returned the literal "httperror" whenever $error was
non-null, and translate() then indexed $templates[$templateId]
unconditionally. templates() is an abstract hook, so a third-party brand
translator's container need not declare an "httperror" entry at all --
reachable, and a real regression from master, which only ever indexed a
template after array_key_exists() succeeded and otherwise degraded
quietly to the "invalid" template. A missing key now degrades an
Undefined-array-key warning immediately followed by a TypeError
(preg_replace() against a null $newraw).

resolveTemplateId() now takes the caller's already-bound $templates
snapshot as a parameter (removing a redundant static::templates() call
and the snapshot inconsistency that came with it) and returns null --
falling through to the existing hasMissingRequiredFields()/"invalid"
path, exactly as master did -- when $templates does not declare
"httperror". No new failure mode: no throw, no fallback string.

Add tests/AbstractResponseTranslatorFallbackTest.php: a purpose-built
fixture translator subclass (not a mutation of a real brand's public
static $templates bag, which is process-lifetime and shared across test
classes) whose templates() omits "httperror", asserting the degradation
lands on the "invalid" template rather than warning or throwing.
The RSRMID-2937 entry claimed a naive sentinel revert "already fails"
tests/CNR/ClientTest.php, stated as if that were sufficient on its own.
It only catches a PARTIAL revert (sentinel re-encoded without also
restoring the explode("|", ..., 2) split, so $raw matches no template
id and the test fails on both code and description). A COMPLETE,
consistent revert -- sentinel re-encoded, the split restored, ?string
$error dropped from all seven signatures together -- compiles, is
behaviour-preserving, and leaves the suite green. Rewrite the
justification to say so plainly and to accept that gap as an
out-of-scope risk (a deliberate, whole-hierarchy reversal of a
documented MIGRATION.md v30 BREAKING CHANGE, not the invisible
one-line drift guard tests exist to catch) rather than claiming it is
closed. The other two justifications (compiler-enforced parameter; a
negative-space sweep being the vacuous-guard shape) are unchanged --
both were verified sound.

Also updates the resolveTemplateId() entry for its new $templates
parameter and the missing-key guard from the companion fix(translator)
commit.
The v30 section's "Who is affected" listed only two groups (hook
overriders, TransportInterface implementers). The nocurl removal is a
third, and it is silent: CNR/IBS ResponseTemplateManager::getTemplate
("nocurl") now falls through to the generic "notfound" template
(CODE=500/"Response Template not found") instead of the removed
"nocurl" one (CODE=423/"API access error: curl_init failed"), and
hasTemplate("nocurl") flips from true to false -- nothing throws, the
value just changes. Add it under "Who is affected".
@KaiSchwarz-cnic
KaiSchwarz-cnic requested a review from a team as a code owner August 5, 2026 13:37
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.51%. Comparing base (91f8124) to head (03c9e93).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master     #307      +/-   ##
============================================
- Coverage     99.51%   99.51%   -0.01%     
- Complexity      448      450       +2     
============================================
  Files            32       32              
  Lines          1027     1025       -2     
============================================
- Hits           1022     1020       -2     
  Misses            5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@KaiSchwarz-cnic
KaiSchwarz-cnic merged commit 1db0cab into master Aug 5, 2026
19 checks passed
@KaiSchwarz-cnic
KaiSchwarz-cnic deleted the RSRMID-2937/collapse-transport-error-channel branch August 5, 2026 13:42
@KaiSchwarz-cnic

Copy link
Copy Markdown
Collaborator Author

🎉 This PR is included in version 30.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant