fix: relocate log() so parse/validation failures are captured (#35) - #36
Merged
Conversation
Previously, log() was only called right after the Guzzle send resolved or rejected. A response that looked fine over the wire (e.g. HTTP 200) but later failed json_decode or JSON Schema validation was logged as an apparent success, with the real failure invisible. log() is now called once, at the same point as writeResponseToCache(): after parseResponseBody() and postProcess() have both had a chance to run/throw. Both branches funnel through log(), so a single log() call now sees the real outcome, whether that's the postprocessed response or whatever exception was thrown along the way -- and it still includes the raw Response object it has on hand, so schema failures show both the response *and* the exception (with ->getExtendedData() errors). This also relocates the "don't log cache hits" decision from the call site into log() itself (log() now returns early when $this->responseIsFromCache is true). That was previously handled implicitly by simply never calling log() on a cache hit. log() is split into a guard (log()) and the actual write (writeLog()). Children that want different behavior on a cache hit -- e.g. writing a short log that points back at the original request instead of writing nothing at all -- can override log() and call writeLog() directly to bypass the default guard, without having to also override responseFromCache() just to get a chance to log. Tests added: - testParseFailureIsLogged / testSchemaValidationFailureIsLoggedWithExtendedExceptionData: reproduce the exact scenario from the ticket (a 200 response that reads as valid JSON but fails ParseResponseJSONSchemaOrThrow's schema check). testSchemaValidationFailureIsLoggedWithExtendedExceptionData does a full-content match (assertStringMatchesFormat) of the log, pinning down that the response body, the exception, and its HasExtendedExceptionData::getExtendedData() (->errors()) are all present in the exact expected shape. - testPostProcessFailureIsLogged: postProcess() rejections are now logged too. - testLogCanBeOverriddenToHandleCacheHits: demonstrates overriding log() to write a distinct, differentiable log on a cache hit that references the original.
The old comment implied postProcess() failures weren't logged at all before this change. That's not quite right: for a cache miss, log() already ran immediately after the Guzzle transfer, before postProcess() ever got a chance to run -- so a postProcess() failure was already producing a log entry, it just froze on "transfer succeeded" before the real outcome was known. Clarify that this change defers that same entry rather than adding a new one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reuse parent::log()'s existing responseIsFromCache guard instead of re-implementing the "not from cache" branch by hand -- calling parent::log() unconditionally at the end is already a no-op on a cache hit, so the example (and the matching test) collapses from two branches to one conditional plus a pass-through call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mattwills23
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #35 — a response that looks fine over the wire (e.g. HTTP 200) but later
fails
json_decodeor JSON Schema validation was being logged as a plainsuccess, hiding the real failure.
log()is now called once, at the same point aswriteResponseToCache()—after
parseResponseBody()andpostProcess()have both had a chance torun/throw. A single
log()call now sees the real outcome (postprocessedresponse, or whatever exception was thrown along the way), and still
includes the raw
Responseit has on hand, so schema failures show boththe response and the exception (with
->getExtendedData()/->errors()).log()itself (
log()now returns early when$this->responseIsFromCacheistrue), instead of being an implicit side effect of nobody calling
log()on a cache hit.
log()is split into a guard (log()) and the actual write (writeLog()).Children that want different behavior on a cache hit — e.g. writing a short
log that points back at the original request — can override
log()andcall
writeLog()directly to bypass the guard, without also having tooverride
responseFromCache()just to get a chance to log.Tests
testParseFailureIsLogged/testSchemaValidationFailureIsLoggedWithExtendedExceptionData:reproduce the exact scenario from the ticket. The schema-validation test does
a full-content match (
assertStringMatchesFormat) of the log, pinning downthat the response body, the exception, and its
->getExtendedData()(
->errors()) are all present in the expected shape.testPostProcessFailureIsLogged: for a cache miss, a log entry was alreadywritten before this change, immediately after the Guzzle transfer completed
— before
postProcess()ever ran. So apostProcess()failure wasn'tunlogged, it was logged as a false success (the entry was already sealed
before the real outcome was known). This test confirms that same entry now
reflects the real outcome instead.
testLogCanBeOverriddenToHandleCacheHits: demonstrates overridinglog()towrite a distinct, differentiable log on a cache hit that references the
original.
All 78 tests pass.
Upgrading: what to audit in your own request classes before adopting this
This changes when
log()is called and what it's called with. For plainconsumers that only override
getLogFolder(), nothing changes. If you overridelog(),responseFromCache(), orwriteResponseToCache(), check the following:1. Any
log($outcome)override that doesn't checkresponseIsFromCachewill now also fire on cache hitsPreviously,
log()was never invoked at all on a cache hit — that was animplicit side effect of the call site, not a guard inside
log()itself. Nowlog()is called on every outcome, cache hit or miss, and the defaultimplementation returns early when
$this->responseIsFromCacheis true. But ifyou have an override that fully replaces the base logic (i.e. doesn't call
parent::log()), it won't inherit that guard, and will start running on cachehits too.
Audit: grep for every
function log(override. Any that don't callparent::log()need an explicitif ($this->responseIsFromCache) { return; }(or equivalent) added, unless firing on cache hits is actually desired.
2. File count for cache-miss requests is unchanged; entry content is not
For a cache miss, exactly one log entry was already being written both before
and after this change, success or failure — this PR doesn't create new log
entries where none existed. What changes is timing and content: previously
log()ran immediately after the Guzzle transfer, beforeparseResponseBody()/postProcess()had a chance to run, so a downstream failure never appeared inthat entry — it just looked like an unremarkable success (the exact bug in
#35). Now that same entry is deferred until parsing and postprocessing have
both had their turn, so it reflects the real outcome, including the exception
and its
getExtendedData()/->errors()where applicable.Audit: review what your
parseResponseString()/postProcess()exceptions'messages and
getExtendedData()contain. That content now lands in the samelog entry the response body was already going into — check nothing sensitive
is exposed there that wasn't being logged before.
3.
postProcess()should reject withThrowable, not arbitrary valuesTo be clear about the shape of the change: the log entry still contains the
Request and Response data exactly like before --
writeLog()builds[$this->toGuzzle(), $this->response, $outcome, $this->requestStats], so apostProcess()rejection doesn't replace or alter the familiar Request/Responsesection, it just appends as a new, final section ahead of the transfer stats.
That appended section is the part to watch:
LogFile::put()'sarray_map(stringify_body, $contents)runs outside its own try/catch, anduntil now was only ever exercised with Guzzle's own
Response/Throwabletypes. Since
postProcess()canreturn new RejectedPromise($anything), thatvalue now flows into
log()→writeLog()→stringify_body()for the firsttime -- as that new appended section. If it's not a
Throwableand notcleanly JSON-serializable (a resource, a closure, a circular structure),
stringify_body()will throw on that section -- and becausearray_mapaborts entirely on the first exception, nothing gets written, not even the
Request/Response section that would otherwise have succeeded. The exception
from the failed logging attempt, not your real failure, becomes the promise's
rejection reason, masking the actual error.
Audit: confirm
postProcess()implementations only reject withThrowableinstances (best practice regardless of this change).
4. Log entries for transfer-level exceptions may show the response body twice
log()now includes the raw$this->responseand$outcomewhenever theydiffer. For
BadResponseException/RequestException-style failures, theresponse body was already embedded inside the exception's own stringified
form — so it'll now appear twice in that entry (once standalone, once nested
in the exception). Cosmetic, but:
Audit: any tooling or tests that parse log files structurally (e.g.
asserting an exact number of blank-line-delimited sections) should be
rechecked.
5.
responseFromCache()-based cache-hit logging (thePreQualifyStrangerRequestpattern) keeps working unchangedIf you have a class that overrides
responseFromCache()purely to call$this->log(...)manually for cache-hit visibility, no change is required:responseIsFromCacheisn't flipped totrueuntil afterresponseFromCache()returns, so the manual call still runs normally, and the later, automatic
log()call is a no-op (guard is active by then) — no double-write. That said,this is a good opportunity to simplify by overriding
log()directly instead(see the docblock on
AbstractRequest::log()for the pattern), rather than arequirement.
6. Tests asserting "nothing gets logged on parse/postProcess failure" will need updating
If you have tests asserting an empty log directory (or unchanged file count)
after a deliberately-broken response, the entry's content will now differ
from what it was — worth a look even though the file count itself won't
change for cache-miss traffic.
🤖 Generated with Claude Code