A Bucket Name Is Not a URL Path
A hash in an ActivityWatch bucket ID swallowed the endpoint suffix. The repair belongs at the path-segment boundary, and its tests need to see real HTTP.
Ask ActivityWatch’s Python client for events in synthetic#fragment, and the old client sends a request for synthetic instead. The /events part disappears too.
This affects custom or imported bucket IDs containing URL-reserved characters. The supported Flask API can store the tested names; the Python client was inserting those names directly into URL syntax. Ordinary bucket names worked, which made the defect easy to miss.
I opened ActivityWatch/aw-client#156 to repair that boundary. As checked on October 29, the PR is open with all seven CI checks passing. This is a tested proposed repair, not a released fix.
Where the suffix went
The failing construction was equivalent to:
endpoint = f"buckets/{bucket_id}/events"
With bucket_id = "synthetic#fragment", the resulting URL contains:
/api/0/buckets/synthetic#fragment/events
The hash starts a URL fragment. Fragments do not travel in the HTTP request target. By the time the server sees the request, the bucket name is truncated and the event route is gone.
Here is a small reproduction using Requests. It prepares requests but makes no network calls:
import requests
from urllib.parse import quote
bucket_id = "synthetic#fragment"
base = "http://127.0.0.1:9999/api/0/buckets"
for segment in (bucket_id, quote(bucket_id, safe="")):
request = requests.Request(
"GET", f"{base}/{segment}/events", params={"limit": -1}
).prepare()
print(request.path_url)
Output:
/api/0/buckets/synthetic?limit=-1
/api/0/buckets/synthetic%23fragment/events?limit=-1
The first line is the broken request target. The second keeps the bucket ID in its intended segment and leaves the route suffix and query parameter intact.
This is why relying on the HTTP library to “escape the URL” is insufficient. The library has already received a URL. It cannot infer that a hash embedded in that URL was meant to be application data.
Escape-like text is still data
A bucket ID can also contain literal text such as %23. That does not make it a pre-encoded hash.
For example, these names are different:
bucket#fragment
bucket%23fragment
At the URL boundary, the first needs %23; the second needs %2523. Encoding the percent sign preserves the literal characters percent, two, three through URL decoding.
Decoding input before quoting it would collapse those two identities. Skipping quoting whenever an ID contains a percent sign would also be wrong. The method’s argument is a literal bucket ID, not an already-encoded path segment.
The proposed change uses quote(bucket_id, safe="") at each of the eleven existing bucket-specific URL constructors. It does not quote the entire URL, rewrite IDs in JSON bodies or dictionary keys, or change fixed route suffixes. Queue registration keeps the literal ID until bucket creation constructs its URL; queued heartbeats retain the encoded endpoint across dispatch and retries.
Python’s quote normally leaves slashes unescaped. safe="" avoids treating a slash inside an identifier as route syntax. That is a boundary precaution, not a new guarantee that the ActivityWatch server accepts slash-containing bucket IDs. The regression contract deliberately excludes that guarantee.
Two different tests were necessary
An assertion about an endpoint string would have caught the raw interpolation, but would not establish what Requests sends or what the server supports. I used two layers.
A loopback HTTP recorder exercises the real client transport and records the method, request target and JSON body. It checks ordinary names, hashes, question marks, literal percent signs, escape-like sequences and Unicode. The assertions cover the fixed suffix, query parameters, force deletion and identical retry endpoints—not just whether a request returns 200.
A supported-server integration fixture forwards those real client requests into Flask with disposable MemoryStorage. Before exercising the SDK, it independently creates, lists and deletes the same names through encoded server routes. That control distinguishes a broken client from an unsupported server identifier.
The integration test also creates unrelated sentinel buckets, records their contents, operates on the target bucket, and verifies that the sentinels are unchanged. A successful response is weak evidence if the client could have addressed the wrong existing bucket.
The retained results on the proposed repair were:
| Check | Result |
|---|---|
| New wire regressions before the production edit | 36 failed, 42 passed; 6 optional-server cases skipped |
| Full client repository target after the repair | 206 passed; 6 optional-server cases skipped |
| Wire plus installed Flask/MemoryStorage suite | 90 passed |
The default client-only environment does not install the server, so its six integration skips are intentional. The separate installed-server run covers them. That run uses a loopback listener and Flask’s test client; it is not a production WSGI deployment test. All data is synthetic.
Some bare-percent and Unicode cases already passed before the fix. “Every unusual name was broken” would be a stronger claim than the evidence supports.
The contract worth testing
If an SDK method accepts an identifier, preserve that identifier as data until the component boundary where it becomes URL syntax. Keep the payload representation literal. Test at least one identifier that resembles the syntax of that destination.
For this repair, the next boundary is maintainer merge, followed by a replay on authoritative master. Package publication and installed-client behavior remain separate. The PR contains the regression suite and verification details; it should not be read as evidence that an existing ActivityWatch installation has already changed.