fix: accept the Transform Platform API URL as server_url (0.46.2) - #352
Conversation
The Transform Platform's API Keys page, and the docs, hand you https://platform-api.transform.unstructured.io/api/v1. That works with curl and 404s every Platform call in this SDK: clean_server_url only stripped a path for unstructuredapp.io hosts, so the /api/v1 survived and the operation's own /api/v1/jobs/ was appended on top, producing /api/v1/api/v1/jobs/, which matches no route. Recognize unstructured.io hosts too, matched on domain boundaries rather than by substring, so a lookalike host such as unstructuredapp.io.example.com keeps its path and scheme. Clean the base URL in BaseSDK._get_url as well. An operation-level server_url= override bypasses the SDK-init hook, so client.jobs.list_jobs(request={}, server_url=...) was uncleaned and 404d the same way even after the domain fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The base URL is now cleaned in BaseSDK._get_url, which every operation passes through, so a self-hosted deployment behind a subpath is the regression this change could cause. The existing server-url cases mock _build_request and so cannot see it; assert on the request that is actually sent instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Formatting only, plus seven noqa directives for pre-existing lint that cannot be auto-fixed without changing behaviour. Two of them matter: `raise err` in basesdk re-raises the exception an after-error hook returned, which is not always the active one, so ruff's suggestion of a bare `raise` would be wrong. No behaviour change. pylint 10.00/10 and mypy stay clean, and the unit and contract suites pass on 3.11, 3.12 and 3.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repro-first proofBugfix — server_url copied from the Transform Platform API Keys page (.../api/v1) doubled the path and 404'd every Platform SDK call Reproduced the broken state
Failing test (red)
Fix
Proof it's resolved
Auto-generated from this branch's |
There was a problem hiding this comment.
All reported issues were addressed across 7 files
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
…alified host Two problems cubic caught on the PR, both real. basesdk.py is generated and was not in .genignore, so a Speakeasy run would drop the _get_url cleaning and an operation-level server_url= would double its /api/v1 prefix again. Added the entry, following the same pattern as general.py, with a regeneration guard test to match the existing ones. Freezing the file also freezes the generated request plumbing, so the entry documents how to un-freeze it for a regen. is_unstructured_domain also missed a fully qualified host carrying the terminal root dot. That was a regression this branch introduced: the old substring test matched api.unstructuredapp.io. and stripped its path, the domain-boundary test did not. The path is stripped again; the host keeps the dot exactly as the caller wrote it, since it changes the Host header and SNI and is a deliberate choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
0 issues found across 4 files (changes from recent commits).
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 1 unresolved issue from previous reviews.
Re-trigger cubic
| def test_basesdk_keeps_cleaning_the_base_url(): | ||
| """`_get_url` is the only point every operation's base URL passes through. | ||
|
|
||
| An operation-level `server_url=` override bypasses the SDK-init hook, so the |
There was a problem hiding this comment.
We can drop this comment. We're no longer autogenerating, so we can change anything we need in the code. In fact, this whole file may be able to go away.
| # that normalizes the client-level URL, so clean here too -- this is the one point | ||
| # every operation's base URL passes through. Cleaning an already-clean URL is a | ||
| # no-op, so the client-level case is unaffected. | ||
| return clean_server_url(utils.template_url(base_url, url_variables)) |
There was a problem hiding this comment.
Per the last comment, I would just do a quick test and see if this one line transforms the url whether it was passed in the operation or the base client. If so, we don't even need the hook file anymore.
| # - Bring back the ignore line and commit | ||
| src/unstructured_client/general.py | ||
|
|
||
| # Ignore basesdk.py so _get_url keeps cleaning the base URL. |
There was a problem hiding this comment.
Unused file, if you don't mind deleting it while you're in here.
awalker4
left a comment
There was a problem hiding this comment.
LGTM! All of my comments are just about cleanup now that we own the codebase in full, no need to step around auto-generated files. I don't want to block the fix, though. So feel free to merge with a fast follow.
|
@awalker4 |
The client-level and operation-level server_url are both cleaned at request time in BaseSDK._get_url, so the CleanServerUrlSDKInitHook is redundant; remove the hook class and its registration. The clean_server_url function stays (basesdk.py and general.py import it). Retarget the six clean_server_url unit tests onto the function directly, since the stored sdk_configuration.server_url is no longer normalized at init (request URLs are unaffected). Delete .genignore and test_regeneration_guards.py: they were insurance for Speakeasy regeneration, which is retired. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P1fwUQxYtk4khxLHEixYza
What & why
Problem: The Transform Platform's API Keys page hands you
https://platform-api.transform.unstructured.io/api/v1, and the docs tell you to pass that value asserver_url. Do it and every Platform call in this SDK fails with a 404: listing jobs, creating a workflow, checking a connector. The same URL works with curl, so the URL looks right and the SDK looks broken, and there is nothing in the error to point at the real cause. Anyone starting from the app's own copy button hits this on their first call.Change: Treat hosts under
unstructured.ioas Unstructured API hosts, so a copied/api/v1suffix is stripped fromserver_urlthe way it already was forunstructuredapp.io. Also clean the base URL for an operation-levelserver_url=override, which bypassed the cleaning hook entirely.Linked ticket
none
Client-facing follow-up: reported while writing the Transform Python quickstart, where every SDK sample had to be written against a URL different from the one the app displays.
The bug
Every Platform operation in this SDK already carries its own path prefix.
jobs.list_jobsrequests/api/v1/jobs/,workflows.create_workflowrequests/api/v1/workflows/, and so on. So the base URL must not carry/api/v1of its own.clean_server_urlexists to strip exactly that kind of pasted-in path, but it only did so when the host containedunstructuredapp.io:platform-api.transform.unstructured.iodoes not match, so the path was kept and the operation path was appended on top, giving/api/v1/api/v1/jobs/, which matches no route.basesdk.pyis generated, so the customization needs protecting: it is now in.genignore, the mechanism this repo already uses forgeneral.py,users.py,retries.pyandpartition.py, with a guard test alongside the existing ones asserting that both the_get_urlcall and the.genignoreentry survive. Without it a regeneration silently drops the fix and the doubled prefix returns. Freezing the file freezes the generated request, retry and hook plumbing too, so the entry carries the same un-freeze proceduregeneral.pydocuments.Three smaller problems came out of the same code while fixing it. The host test was a substring match, so
unstructuredapp.io.example.comwas treated as one of ours and had its path stripped and its scheme forced to HTTPS; it is now matched on domain boundaries and left alone. Aserver_url=passed to a single operation never reached the cleaning hook at all, because the hook runs at SDK init; that override is now cleaned inBaseSDK._get_url, the one point every operation's base URL passes through. And a fully qualified host carrying the terminal root dot (api.unstructuredapp.io.) has to be recognized explicitly, since the old substring test matched it by accident and the domain-boundary test does not; the path is stripped as before and the host keeps its dot, which changes the Host header and SNI and is the caller's choice to make.What the patch changes, and what it does not
Every
server_urlshape the existing tests, the docs and the app use, run throughclean_server_urlonmainand on this branch. Seven results change; sixteen are byte-identical.server_urlmainhttps://platform-api.transform.unstructured.io/api/v1https://platform-api.transform.unstructured.io/api/v1https://platform-api.transform.unstructured.iohttp://platform-api.transform.unstructured.io/api/v1http://platform-api.transform.unstructured.io/api/v1https://platform-api.transform.unstructured.ioplatform-api.transform.unstructured.io/api/v1http://platform-api.transform.unstructured.io/api/v1https://platform-api.transform.unstructured.ioplatform-api.transform.unstructured.iohttp://platform-api.transform.unstructured.iohttps://platform-api.transform.unstructured.iohttps://platform-api.unstructured.io/api/v1https://platform-api.unstructured.io/api/v1https://platform-api.unstructured.iohttp://unstructuredapp.io.example.com/api/v1https://unstructuredapp.io.example.comhttp://unstructuredapp.io.example.com/api/v1http://myunstructuredapp.io/api/v1https://myunstructuredapp.iohttp://myunstructuredapp.io/api/v1https://platform-api.transform.unstructured.iohttps://platform-api.transform.unstructured.iohttps://platform.unstructuredapp.io/api/v1https://platform.unstructuredapp.iohttps://api.unstructuredapp.io/general/v0/generalhttps://api.unstructuredapp.iounstructured-000mock.api.unstructuredapp.io/general/v0/generalhttps://unstructured-000mock.api.unstructuredapp.iohttp://localhost:8000http://localhost:8000localhost:8000http://localhost:8000http://localhost:8000/my/endpoint/http://localhost:8000/my/endpointlocalhost:8000/general/v0/generalhttp://localhost:8000/general/v0/generalhttps://unstructured.example.com/api/v1https://unstructured.example.com/api/v1http://not-unstructured.io/api/v1http://not-unstructured.io/api/v1The first five changed rows are the reported bug. The last two are the substring-match fix: those hosts are not ours, so they keep their path and their scheme.
Impact
Customers: Anyone using the Python SDK against the Transform Platform can now paste the API URL shown in the app, or set it from the documented
UNSTRUCTURED_API_URL, and have jobs, workflows, sources, destinations and templates calls work. Today that exact value 404s on every call. Users who already worked around it by passing the bare host are unaffected; that keeps working. Users onunstructuredapp.ioare unaffected; their URLs were already cleaned.Internal (devs / ops / other teams): The docs can stop steering readers away from the URL the product displays. No service imports this code; it is a client library published to PyPI.
Wire contract / clients: No request or response shape changes. The only behavior change is which URL a request is sent to, and only for base URLs that were previously producing a doubled path. The one case where a user could notice a difference is a self-hosted deployment on a host under
unstructuredapp.ioorunstructured.iothat genuinely serves the API beneath a subpath; that path is now stripped. Hosts outside those domains keep their path exactly as before, which the existing localhost subpath tests cover.Deployment target considerations: This is a PyPI client library, not a deployed service, so SaaS / DI / in-VPC / on-prem / SND deploys are unaffected. Air-gapped users pointing the SDK at their own hostname keep the existing keep-the-path behavior, since their host is not under an Unstructured domain.
A note on the diff size
The last commit is
ruff formatover the files this change touches, plus sevennoqadirectives for pre-existing lint that cannot be auto-fixed without changing behaviour. It is formatting only and carries no behaviour change, so reading the first two commits on their own gives you the whole fix. Two of thenoqas are worth knowing about:raise errinbasesdk.pyre-raises whatever an after-error hook returned, which is not always the active exception, so ruff's suggested bareraisewould be a real bug.Risk / rollback
Low. Small changes to URL normalization plus a
.genignoreentry, revert-safe, no migration and no flag.How it was verified
Ran the unit suite on Python 3.11, 3.12 and 3.13 and the contract suite, plus
pylint(10.00/10) andmypy, all green, matching what CI runs.uv.lockis unchanged, so theUV_LOCKED=1install holds. Reproduced the bug and then the fix against the live Transform Platform API without an API key, which is enough to tell the two apart: a route that exists answers 401, a route that does not answers 404. Not exercised with a real API key end to end, and not exercised against a self-hosted deployment.Proof
Repro, against the live API, before the fix:
Through the SDK, before the fix:
Failing tests at
HEADbefore the fix,_test_unstructured_client/unit/test_server_urls.py::test_platform_request_url_has_a_single_api_prefixplus the hook tests:After the fix, the same live check across both ways of passing the URL:
Every case now reaches the real route. Suites after the fix: unit and contract both pass,
pylint10.00/10,mypyclean.Dependencies / merge order
none
Worked Example
Release
Bumped to 0.46.2 with CHANGELOG and RELEASES entries.