Repository navigation
Concurrent writes to a remote-signing catalog go out unsigned and fail 403Β #3896
Description
Apache Iceberg version
main (development) β also reproduced on 0.12.0 and 0.11.1.
Please describe the bug π
_s3() unregisters the S3 request signer and re-registers it on an event emitter that fsspec caches and every thread shares. A request signed in that window goes out with no Authorization header β _s3() has already set config_kwargs["signature_version"] = UNSIGNED, so botocore does not sign in its place β and the store answers 403 AccessDenied.
pyiceberg/io/fsspec.py, in _s3():
fs = S3FileSystem(**s3_fs_kwargs)
for event_name, event_function in register_events.items():
fs.s3.meta.events.unregister(event_name, unique_id=1925) # opens the window
fs.s3.meta.events.register_last(event_name, event_function, unique_id=1925)FsspecFileIO.get_fs caches per thread and every table gets its own FileIO, so a short workload makes hundreds of these cycles against the one shared emitter. PyIceberg's writer is concurrent by default, so no unusual usage is needed to reach it.
Roughly 3β4% of appends fail against a remote-signing catalog, surfacing as an opaque PermissionError: Access Denied out of s3fs, several frames from its cause. Every unsigned request caught on the wire was a PUT of a manifest during commit.
Measured on Lakekeeper 0.13.1 + MinIO (path-style), 90 writes per run:
| configuration | append failures | unsigned on wire |
|---|---|---|
| as shipped | 2 / 4 / 5 | 2 / 4 / 5 |
| signer registered once (client still shared) | 0 | 0 |
PYICEBERG_MAX_WORKERS=1 |
0 | 0 |
The second row is the one that matters: the failures stop while the client is still shared, which separates this from a general concurrency problem. The response code is always AccessDenied and never SignatureDoesNotMatch β what an unsigned request produces, not a mis-signed one.
Suggested fix
Drop the unregister. Botocore's HierarchicalEmitter._register_section returns early for a unique_id it already holds, so re-registering an equivalent signer was already a no-op; the unregister only opens the window.
Reproduction
Two tests in tests/io/test_fsspec.py, no credentials and no network:
test_s3_leaves_a_signer_installed_while_reconfiguring_a_shared_clientβ drives_s3()and observes the emitter the instant it unregisters.test_the_signer_stays_installed_while_another_thread_reconfigures_s3β the same window seen from another thread. The window is two adjacent statements, so sampling for it blind is a coin flip (20,000 observations caught it zero times); it is held open by delaying only the re-registration, so the timing is deterministic while the defect is not manufactured.
Both fail on main and pass with the fix.
Already covered by an open PR
#3783 (for #3625) removes this unregister as part of registering the signer on an AioSession, for a different reason β a lazily created client not inheriting handlers, failing InvalidRequest. I applied that PR and ran both tests above against it: both pass, so it fixes this as well.
Worth recording rather than closing silently, because the line is wrong for two independent reasons and #3783's own test does not cover this one. If #3783 lands, these two tests are the regression cover for the concurrency window; happy to raise them against that PR instead if a maintainer prefers.
A second, independent defect in the same file β noted, not proposed here
pyiceberg/io/fsspec.py:160 applies the signing service's headers with add_header, which appends, so every header the service echoes back appears twice β 880 of 1615 sign calls ended with a duplicate of a header named in SignedHeaders. On MinIO this is latent: de-duplicating changed nothing, and it did not contribute to the 403s above. SigV4 combines repeated headers comma-separated, so a stricter store may reject them. Mentioned so it is not lost, not to widen this issue.
Willingness to contribute
I can contribute a fix to resolve this bug independently.
Activity
VivekMalipatel commentedon Sep 16, 2026
We hit this in production against a remote-signing REST catalog (Lakekeeper) with an S3-compatible store, and tracked it to a specific interaction between two caches. Reads are affected as well as writes. Sharing the mechanism, the evidence, and β more usefully β why the obvious one-line fix is not safe in general.
Versions:
pyiceberg 0.12.0,botocore 1.43.75,s3fs 2026.7.0,fsspec 2026.7.0,aiobotocore 3.9.1, Python 3.11.Mechanism
pyiceberg/io/fsspec.py::_s3installs the signer by unregister-then-register, and setssignature_version=UNSIGNED:Two caches with different scopes collide:
FsspecFileIO.get_fsis memoised per thread (threading.local+lru_cache), so every worker in the scan pool runs_s3itself._Cachedmetaclass cachesS3FileSystemper process, so all of those workers get the same instance and mutate the samefs.s3.meta.eventsemitter.Between the
unregisterand theregister_lastthere is nobefore-sign.s3handler. Because signing isUNSIGNED, a request that serialises in that gap is not signed by anything and goes out with noAuthorizationheader at all β it is not a bad signature, it is an unauthenticated request. The store then answers with whatever it does for anonymous access; for us that is403 AccessDenied, which is why this reads as a permissions problem and sends you looking at the catalog, the credentials, and clock skew.Short-lived jobs are hit hardest: the registration storm and the first object requests are the same event, so the first file of a scan was the victim nearly every time, while a long-lived process that warms its pool once never failed.
Evidence
Wrapping
AioEndpoint._sendto pair each request with its response:User-Agent,amz-sdk-invocation-id,amz-sdk-request, and the signer had never seen its URLAuthorization,x-amz-date,x-amz-content-sha256, and the signer had seen its URLA packet capture confirmed the failing request's response body is byte-identical to that of a deliberately unauthenticated request, and distinct from both a duplicated-header and a wrong-secret response.
Interleaved A/B on one scan, n=50: 9/25 failures with 6 workers, 0/25 with 1 worker, and every failure carried at least one unsigned request while no success did. In production, across short-lived jobs, 23 of 47 attempts (49%) failed before and 0 of 10 after.
Workaround
Setting max workers to 1 for short-lived readers removes it entirely. We deliberately did not add a 403 retry, since that would mask this class of bug.
Why deleting the
unregisteris tempting but unsafebotocore/hooks.py::_register_sectionearly-returns for aunique_idit already holds:So re-registering the same
unique_idis a no-op, and it is natural to conclude theunregisteris redundant and can simply be dropped β closing the window while keeping concurrency.That breaks multi-table processes. With remote signing, the signer is per table, not per catalog:
s3.signer.endpointembeds the table id, andS3V4RestSigner.__call__readsself.properties, so each instance is bound to the table it was constructed for. Three tables in one of our processes give three distinct endpoints:A job that reads table A and then table B shares one process-cached
S3FileSystem. Drop theunregisterand botocore's early-return keeps table A's signer installed and silently skips table B's registration β so table B's object requests are signed against table A's signer path. Against a catalog that authorises per table location that is either a 403 for a new reason or, worse, an authorisation that should not have been granted. Theunregisteris not vestigial; it is what swaps the per-table signer on a shared client.So the root cause is narrower than "the unregister": a per-table signer installed on a process-cached client and mutated under concurrency.
Suggested direction
Make multiple signers able to coexist instead of swapping one in place β for example scope each signer to its own table's object prefix so it no-ops for requests outside it, and register it under a
unique_idderived from the table id, never unregistering. No gap, no swap, concurrency preserved. Giving each table its ownS3FileSysteminstance (bypassing the fsspec instance cache) would also work, at the cost of more clients per process.Whatever the fix, a regression test that reads two different tables concurrently in one process and asserts each request was signed against its own table's endpoint would be worth having: a test that only checks "no 403 under concurrency" passes for the unsafe patch above.