Surface Guzzle response-creation failures as UserException instead of an internal error - #47
matyas-jirat-keboola wants to merge 3 commits into
Conversation
Guzzle raises `RequestException: An error was encountered while creating the response` when `EasyHandle::createResponse()` throws - a malformed or missing HTTP status line, or a sink that cannot be opened. The wrapped cURL errno for that path (CURLE_WRITE_ERROR) is not in the recognized user-error list, so the exception escaped `HttpExtractor` unhandled and the job died with an opaque internal error (exit 2), hiding the real reason from the user and paging the team. Re-raise only that specific exception as a `UserException` carrying the previous exception's message, so the run still fails but with exit 1 and an actionable message. Every other `RequestException` is returned and rethrown untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
keboola-pr-reviewer-bot
left a comment
There was a problem hiding this comment.
Verdict: auto_approve (risk 2/5) · profile component-factory
Approve: defensive-only error-reclassification that turns one opaque Guzzle failure into a legible UserException without touching the success path.
Impact flags: high blast radius — see Check Run summary.
There was a problem hiding this comment.
Pull request overview
This PR makes HttpExtractor surface a specific Guzzle failure mode (“An error was encountered while creating the response”) as a UserException, so already-failing runs return exit code 1 with an actionable message instead of escaping as an internal error (exit code 2).
Changes:
- Add
HttpExtractor::convertResponseCreationError()and consult it in the twoRequestExceptionrethrow paths to translate Guzzle response-creation failures intoUserException. - Add PHPUnit coverage for the newly handled branch and a guard test ensuring unrelated
RequestExceptions still propagate unchanged.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/HttpExtractor.php |
Converts Guzzle response-creation RequestExceptions into UserException with the underlying (previous) exception message. |
tests/phpunit/HttpExtractorTest.php |
Adds unit tests for the new conversion behavior and for preserving propagation of unrelated request exceptions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Compare Guzzle's message with `===` instead of `strpos()`. Guzzle passes that literal verbatim, so an exact comparison is strictly narrower and cannot catch some other exception that merely echoes a response body. - Leave the exception untouched when there is no previous exception (or it carries no message) rather than building a tautological message. That case is unreachable with Guzzle 7.3 and now simply keeps today's behaviour. - Assert the original RequestException survives as `getPrevious()`, and that an unrecognized RequestException propagates as the very same object. - Use a message the component could actually see in the guard test, and document why exactly one response is queued in the mock handler. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
New commit on |
Dismissing prior approval — a new commit was pushed and this review was for an earlier SHA. Run @keboola-pr-reviewer-bot review to get a fresh verdict.
…ponse() Comment-only. In Guzzle 7.3 the RuntimeException wrapped by this specific RequestException can only come from HeaderProcessor::parseHeaders(); a sink that cannot be opened throws outside createResponse() and is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
keboola-pr-reviewer-bot
left a comment
There was a problem hiding this comment.
Verdict: needs_human (risk 4/5) · profile component-factory
Head commit has failing checks (Build); no required-check rules discovered on the base branch, falling back to block-on-any-failure.
|
CI note for whoever picks this up: the red
Every CI run of this branch reports the same shape: 40 tests, 72 assertions, 1 failure, that test only. Both tests added here pass, and |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/HttpExtractor.php:116
- The doc comment says the RequestException message "says nothing", but Guzzle’s message is still present (it’s just generic/non-actionable). This is slightly misleading when someone is debugging behavior based on the comment.
* generic RequestException whose own message says nothing. cURL reports that abort as
Behaviour impact: none — defensive-only
No change to the success path, the downloaded file, the output manifest, or any
configuration. The only difference is the shape of an already-failing run: one
specific exception that used to escape unhandled now fails with exit code 1 and a
readable message instead of exit code 2 and "Internal Server Error occurred."
No existing test was modified or deleted.
Root cause
A Datadog alert reported
keboola.ex-httpjobs ending with an internal error(image
v2.5.5):Guzzle emits that exact message from
CurlFactory::createRejection()whenEasyHandle::createResponse()throws — i.e. Guzzle could not build a PSR-7 responseout of the transfer. In Guzzle 7.3 the only source of a
RuntimeExceptionthere isHeaderProcessor::parseHeaders()—Expected a non-empty array of header data,HTTP version missing from header data, orHTTP status code missing from header data.In other words, the remote host answered with a malformed or non-HTTP status line.
(A bad header value would surface as
InvalidArgumentException, and a sink that cannotbe opened is thrown outside
createResponse(), so neither is this.)When the header callback aborts, cURL reports
CURLE_WRITE_ERROR(23). That errno isnot in this class's
$userErrorslist, soHttpExtractor::sendRequest()fell throughto
throw $e, the rawRequestExceptionreachedsrc/run.php, and the entrypoint'sThrowablehandler logged it as critical and exited 2. Both underlying causes aredeterministic — retrying is pointless — and actionable by whoever owns the
configuration or the remote host, but the user was shown nothing.
The fix
HttpExtractor::convertResponseCreationError()is consulted at the two points thatpreviously did a bare
throw $e. It converts only when all of the following hold,and otherwise returns
$ecompletely untouched:===, not a substring, soanother exception that merely echoes a response body cannot be caught here); and
It is deliberately placed after the existing errno checks, so no message produced by
the current code can change.
Before:
After:
Why not a retry
Neither cause is transient. A malformed status line and an unopenable destination both
reproduce on every attempt, so retrying would only add load and delay the same failure.
Tests
Two tests added to
tests/phpunit/HttpExtractorTest.php, neither touching existing ones:testResponseCreationErrorIsThrownAsUserException— drives the newly handled branch,asserts the
UserExceptionmessage, and asserts the originalRequestExceptionsurvives as
getPrevious()so the job log keeps the full context. Run against theunmodified
mastercopy ofsrc/HttpExtractor.phpit reproduces the productionsymptom exactly:
testUnrelatedRequestExceptionKeepsPropagating— the guard: a differentRequestExceptionmust leave the extractor as the very same object(
assertSame). Passes both with and without the change.Exactly one response is queued in each
MockHandleron purpose — cURL error 23 is nota retryable code, so an unexpected retry fails loudly on an empty queue rather than
passing silently.
Results in the component's own
php:7-cliimage:composer phplintcomposer phpcscomposer phpstan(level max)composer tests-phpunitcomposer tests-datadirCI is red, and it is red on
mastertoocomposer cifails — and therefore so does theRun testsstep — on a singlepre-existing, unrelated test:
testThrowsUserExceptionForNonValidCert. It pins ahard-coded third-party IP (
142.251.36.68) viaCURLOPT_RESOLVEand expectscURL error 60. That IP no longer serves TLS on 443, so the test gets whatever thenetwork gives it instead:
masterin the component's own image:cURL error 7: Connection refusedcURL error 7: Connection refused(identical)cURL error 28: Connection timed outTwo different errors from two different networks — it is a stale fixture, not a
regression, and it is untouched by this PR. Both new tests pass in CI. Worth fixing
separately.
Follow-up (not in scope here)
With this merged, the underlying
RuntimeExceptionmessage lands in the job log, so afuture occurrence can be classified properly — which of the three
parseHeaders()messages it is says a lot about what the remote host is actually doing. Acting on that
would be a real behaviour change and does not belong in a defensive PR.