Skip to content

Defensive fix: surface Google Analytics API 404 as a user error in keboola.ex-google-analytics-v4 - #132

Open
matyas-jirat-keboola wants to merge 2 commits into
masterfrom
notification-defensive-fix
Open

matyas-jirat-keboola wants to merge 2 commits into
masterfrom
notification-defensive-fix

Conversation

@matyas-jirat-keboola

Copy link
Copy Markdown

Summary

Adds defensive handling for an unhandled GuzzleHttp\Exception\ClientException (HTTP 404) observed in production. No change to the component's behaviour on inputs that already worked.

Root cause

Component::handleException() maps a known set of HTTP status codes (400, 401, 403, 429, 502, 503) to UserException, and lets everything else fall through to throw new ApplicationException(...) at src/Component.php:375.

A 404 was never in that set. When the Google Analytics Reporting API responds:

The requested URL /v4/reports:batchGet was not found on this server. (404 Not Found)

the job ends as an opaque internal error (exit code 2) — the user is told nothing actionable, and the failure pages the team instead of the person who can fix it.

Classified as deterministic, user-actionable. In the alert window this accounted for 20 of 20 critical events for this component over 30 days, across 12+ distinct configurations and 2 stacks, spread evenly over 7 days — so it is a permanent condition, not a transient blip. /v4/reports:batchGet is the Universal Analytics (view/profile) reporting endpoint; the GA4 property path uses a different host and is unaffected.

Fix

Added one 404 branch to the existing status-code chain in handleException(), between the current 403 and 502 branches, that raises UserException with an actionable message and the original API response body appended.

Behaviour impact: none — defensive-only

  • The job still fails. Only the exception class changes for this one status code, which moves the outcome from exit 2 (opaque "internal error") to exit 1 (message shown to the user). Nothing is swallowed and no return was added.
  • Happy path unchanged; no outputs, transforms, schema, or config semantics touched. The new branch is unreachable unless the API returns 404.
  • Every other status code keeps its existing mapping, including the fall-through to ApplicationException — pinned by tests.
  • Existing tests pass unchanged; no assertion was modified or deleted.

Evidence

Datadog monitor: https://app.datadoghq.eu/monitors/97152903

Tests

New tests/Keboola/GoogleAnalyticsExtractor/ComponentTest.php (9 tests, 13 assertions, green):

  • testNotFoundIsSurfacedAsUserException — drives the new branch with the exact production response body and asserts the UserException, its code, message and getPrevious(). Verified it fails on master with the identical production error (ApplicationException at src/Component.php:375), so it genuinely covers the fix.
  • testUnmappedStatusCodesStillRaiseApplicationException (405, 409, 500) — guards the scope of the change: everything else still falls through exactly as before.
  • testAlreadyMappedStatusCodesKeepRaisingUserException (400, 401, 429, 502, 503) — guards the neighbouring branches.

parallel-lint, phpcs and phpstan --level=max all clean.

The credential-dependent test classes (ApplicationTest, ClientTest, ExtractorTest, MigrateConfigurationTest) cannot run locally without live Google/Storage credentials and are left to CI. Baseline check on the credential-free classes: clean master = 20 tests / 2 pre-existing credential failures; with this change = 29 tests / the same 2 failures. No new failures.

A 404 response from the Google Analytics API was not handled in
Component::handleException() and fell through to ApplicationException,
so the job ended with an opaque "internal error" (exit code 2) that told
the user nothing and paged the team.

Map 404 to UserException (exit code 1) with an actionable message, next
to the existing 400/401/403/429/502/503 branches. The job still fails;
only the way it reports the failure changes.

Adds ComponentTest covering the new branch plus the neighbouring and
fall-through status codes, so the existing mapping is pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matyas-jirat-keboola

Copy link
Copy Markdown
Author

@keboola-pr-reviewer review profile=component-factory

@keboola-pr-reviewer-bot keboola-pr-reviewer-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: auto_approve (risk 2/5) · profile component-factory

Auto-approve: additive 404 branch that fails more legibly without changing any working input's behaviour.

Concerns:

  • tests/Keboola/GoogleAnalyticsExtractor/ComponentTest.php: Test uses reflection/newInstanceWithoutConstructor; brittle to internal refactors.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves error classification in the Google Analytics extractor by treating Google Analytics Reporting API HTTP 404 responses as user-actionable failures (UserException) instead of opaque internal errors (ApplicationException), while leaving the happy path and existing status-code mappings unchanged.

Changes:

  • Added explicit handling of HTTP 404 in Component::handleException() to throw UserException with an actionable migration hint and the original API response body.
  • Added a new PHPUnit test suite validating the new 404 behavior and guarding that mapped/unmapped status codes keep their prior behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/Component.php Adds a dedicated 404 branch in handleException() to surface the error as a UserException with user-facing guidance.
tests/Keboola/GoogleAnalyticsExtractor/ComponentTest.php Introduces regression tests for 404 handling and invariants for other status codes’ exception mapping.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Addresses review feedback that the test was brittle to internal refactors.

The test no longer reaches into BaseComponent's private $logger property by
name, and no longer uses newInstanceWithoutConstructor(). It uses an anonymous
subclass that skips the parent constructor and overrides the public getLogger(),
so the only remaining reflection is invoking the private handleException() -
which depends on this component's own code, not on a dependency's internals.

Test-only change; src/ is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@keboola-pr-reviewer-bot

Copy link
Copy Markdown

New commit on c3156b1 — dismissed 1 stale bot approval. Comment @keboola-pr-reviewer-bot review when you want a fresh review.

@keboola-pr-reviewer-bot
keboola-pr-reviewer-bot dismissed their stale review July 29, 2026 06:12

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.

@matyas-jirat-keboola

Copy link
Copy Markdown
Author

Thanks — addressed the one concern raised (ComponentTest.php reflection brittleness) in c3156b1.

The test no longer reflects on BaseComponent's private $logger property by name, and no longer uses newInstanceWithoutConstructor(). It now uses an anonymous subclass that skips the parent constructor and overrides the public getLogger():

$component = new class extends Component {
    public function __construct()
    {
        // Intentionally does not call parent::__construct()
    }

    public function getLogger(): LoggerInterface
    {
        return new NullLogger();
    }
};

The only reflection left is invoking the private handleException(), which depends on this component's own code rather than a dependency's internals. Test-only change — src/ is untouched by that commit.

Verification after the change: the 9 new tests pass, phpcs clean, phpstan --level=max clean. I also re-confirmed the regression test still genuinely covers the fix — against master's Component.php it fails with the exact production error, ApplicationException: The requested URL /v4/reports:batchGet was not found on this server. at src/Component.php:375.

Note on the red Build check (pre-existing, not from this PR)

CI is failing on an expired Google OAuth refresh token in the repo's Actions secrets, not on this change:

POST https://www.googleapis.com/oauth2/v4/token -> 400 Bad Request
"error": "invalid_grant"

All 7 failures are the credential-dependent live-API tests (ApplicationTest, ClientTest, ExtractorTest, MigrateConfigurationTest, AntisamplingPropertyTest). In the same CI run, all 9 tests from this PR passed, and parallel-lint + phpstan were clean. Renewing the REFRESH_TOKEN secret is tracked separately.

@matyas-jirat-keboola

Copy link
Copy Markdown
Author

@keboola-pr-reviewer review profile=component-factory

@keboola-pr-reviewer-bot keboola-pr-reviewer-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants