fix: capture S3 diagnostics and stop logging presigned URLs on SQL cache failures - #114
Draft
tkislan wants to merge 1 commit into
Draft
fix: capture S3 diagnostics and stop logging presigned URLs on SQL cache failures#114tkislan wants to merge 1 commit into
tkislan wants to merge 1 commit into
Conversation
…che failures `upload_sql_cache` reported failures using only the string from `raise_for_status()`. For presigned S3 uploads that discarded the one thing identifying the cause (the response body) while including the one thing that should never reach logs (the presigned URL, which carries X-Amz-Credential and X-Amz-Security-Token). The unique URL in every message also meant no two occurrences shared a message string, so downstream error tracking could not group them. - Extract S3's <Code>/<Message>/<Expires>/<ServerTime> plus x-amz-request-id rather than the exception string. Only those four elements are read; <CanonicalRequest>, <StringToSign> and <AWSAccessKeyId> echo the signed query string and are never logged. - Redact credential-bearing material from any text destined for a log. The primary defence strips the query string off anything URL-shaped, including the scheme-less path-only form urllib3 puts in connection errors - so network failures, not just HTTP ones, are covered. - Carry the URL's issue time in a SqlCacheUpload NamedTuple and log seconds_since_url_issued beside url_expires_in, making an expiry-driven failure self-evident. - Use constant log messages with all variable data in `extra`, at all four sites in the module. - Give serialization failures a distinct `failed_to_serialize_cache` cause, and log only the exception type for them - the message quotes user column names. - Fetch the cached object explicitly instead of handing the URL to pandas, which fetched it with urllib and raised before S3's error body was ever read. The parquet/pickle fallback is preserved and now downloads once instead of twice. - Bound the connect phase of both transfers at 5s; read is left unbounded so slow but healthy transfers of large cache objects are unaffected. Cache failures remain swallowed and can never fail the user's query. tests/unit/test_sql_caching.py goes from 26 tests in 31.1s to 74 in 1.4s - the former suite had a test making a real network call to localhost:19456 whose mocks were provably dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xJczfWE4mq3ux7ZU9ZeU9
|
📦 Python package built successfully!
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #114 +/- ##
==========================================
+ Coverage 74.42% 74.85% +0.43%
==========================================
Files 95 95
Lines 5704 5790 +86
Branches 850 860 +10
==========================================
+ Hits 4245 4334 +89
+ Misses 1182 1177 -5
- Partials 277 279 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
|
🚀 Review App Deployment Started
|
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.
@coderabbitai ignore
Closes #113.
What was wrong
upload_sql_cachereported failures using only the string produced byraise_for_status(). For presigned S3 uploads that discarded the one thing identifying the cause (the response body) while including the one thing that should never reach logs (the presigned URL, which carriesX-Amz-CredentialandX-Amz-Security-Token). Because a unique URL was interpolated into every message, no two occurrences shared a message string, so downstream error tracking saw a stream of unrelated errors instead of one issue with a rising count.The change, in one before/after
Before — actual production output, redacted:
After — the same 403, run through the new code:
The last two lines are the point:
931.4 > 900makes an expiry-driven failure self-evident, ands3_error_codeseparates the four causes that all present as a bare 403 today (AccessDenied+expired,ExpiredToken, plainAccessDenied,SignatureDoesNotMatch).What's in it
<Code>,<Message>,<Expires>,<ServerTime>from the body, plusx-amz-request-id/x-amz-id-2/Date. Only those four elements are read —<CanonicalRequest>,<StringToSign>and<AWSAccessKeyId>echo the signed query string and are never logged.<Expires>/<ServerTime>/Dateare AWS's own clock-independent answer to "did the URL run out of time", independent of this pod's accounting.raise_for_status()is gone; the status check is explicit and its failure path never builds a string containing the URL. A redaction pass strips the query string off anything URL-shaped in any text destined for a log.get_sql_cachenow returns aSqlCacheUpload(url, issued_at)NamedTuple;issued_atis taken before the webapp round-trip so the measurement is a conservative upper bound on lifetime consumed.logger.errorsites in the module take a single string literal with everything variable inextra.failed_to_serialize_cacheinstead of being conflated with upload failures, and log only the exception type — the message quotes user column names.pd.read_parquet(download_url)fetched the URL with urllib internally and raised before S3's body was ever read. The object is now fetched explicitly, so the same diagnostics are available. The parquet→pickle fallback is preserved and now downloads once instead of twice.timeout=(5, None)on both transfers. Read is left unbounded so slow-but-healthy transfers of large cache objects are unaffected; a hung connect is unambiguously a bug and its exception is swallowed like any other.Cache failures remain swallowed and can never fail the user's query.
Notes for the reviewer
Two things worth knowing, both found while verifying rather than assumed:
Removing
raise_for_status()is not sufficient on its own.requests'ConnectionError/Timeout/SSLErrorembed the URL via urllib3'sMaxRetryError, in a scheme-less, path-only form:So the redaction pattern deliberately makes the scheme optional. A
https?://-anchored pattern is a no-op on this input, which is the shape that actually occurs on network failures.urlsplit(None)does not raise — it returnsSplitResultBytes(path=b'', ...). Abytesvalue inextramakesjson.dumpsfail insidereport_error_to_webapp, which is outside anytry, so the entire error report is silently discarded. Both URL helpers now guard onisinstance(url, str).Disproven hypothesis, so it isn't re-litigated: chunked encoding is not the cause of the 403s.
requests.put(url, data=<tempfile>)sendsContent-Lengthwith noTransfer-Encoding—super_lenresolves the length from the fd.Testing
tests/unit/test_sql_caching.py: 26 tests in 31.1s → 74 in 1.4s.The old suite contained a test making a real network call to
localhost:19456whoseread_parquet/read_picklemocks were provably dead — it exited through the cache-info handler and never reached the code it claimed to test. It is now patched properly and asserts both mocks were called.Tests pin each acceptance criterion mechanically, including: no credential survives into any logged field across ~40 adversarial inputs; the message string is identical across different failure kinds; no
extrakey collides with a reservedLogRecordattribute; everyextravalue is JSON-serializable on every failure path; andbuffer.seek(0)before the pickle fallback is pinned with real pickle bytes, so removing it fails loudly rather than silently breaking every pickle-format cache read.black/isort/flake8/mypyclean. Full suite: 1016 passed, 4 skipped.Follow-ups, deliberately not in this PR
LoggerManagerre-adds handlers on everyget_logger()call. The re-entry guard atlogging.py:238only fires when the level changes, sofile_loggeraccumulates ~10WebappErrorHandlers and every toolkit error is reported to the webapp ~10 times. Invisible in CI, where the handler isn't attached. Pre-existing, but this PR ships richerextradicts so it amplifies. Wants its own issue.seconds_since_url_issued. It is logged only on failure, so there's no distribution to judge 931s against. Emitting it viaoutput_sql_metadatawould fix that, but that payload is a contract with the webapp — out of scope here.isort .also rewritesdeepnote_toolkit/streamlit_data_apps.py, which is mis-sorted onmainand unrelated. Left alone to keep this diff scoped.🤖 Generated with Claude Code
https://claude.ai/code/session_015xJczfWE4mq3ux7ZU9ZeU9