Repository navigation
Releases: nodemaven/nodemaven-python
Release list
v0.1.7
An error never prints a body that has no message.
An ApiError whose answer carried no message of its own used to quote the whole body. The sub-user payload holds proxy_password, so an answer with success: false, an empty description and that payload put the live proxy password into the error text. The text now comes only from message fields, from errors when it holds messages, or from field errors; anything else is described by its key names, as in no message in the answer (keys: description, errors, payload, success).
ApiError.body still holds the answer as received and can carry credentials, so do not log it.
pip install -U nodemaven
v0.1.6
The extras install line is quoted, so it works in zsh
pip install nodemaven[requests], as the README and docs/api-reference.md
printed it, fails on the default macOS shell before pip runs: zsh reads the
brackets as a glob and answers zsh: no matches found: nodemaven[requests].
Reproduced 2026-10-01 in a clean Python 3.12 venv from a zsh prompt. Both files
now print pip install "nodemaven[requests]", which works in zsh, bash and fish,
and test_readme.py refuses an unquoted extra.
Errors and wording
- A rejected API key now reads as the server's own sentence. The host
answers a missing or rejected key with
{"error": {"code", "message", "retryable"}};AuthErrorused to show that
object flattened into one line, and now shows the message and the code. - Corrected wording about unregistered paths. The host redirects a path it
does not serve (302) to/not-found; a client that follows redirects
receives that page with200. Docstrings anddocs/observed-behavior.md
said the host answered200itself. Behaviour is unchanged: the default
transport follows no redirect, so such a path is anApiErrorcarrying302,
and a non-JSON2xxfrom a transport that does follow redirects is still
refused.
v0.1.5
A patch release, and the first reason for it is a credential: 0.1.4's default
transport followed redirects and carried the API key to the redirected host.
Nothing here says the dashboard has ever redirected an API call, but a key that
must only reach one host cannot rest on that. The rest are silent failures in
the account API client - a write reporting success on a web page, a timeout
escaping this package's errors, isp_cities() unable to finish at its default -
found by running the shared SDK specification's cases and a live smoke run
through Client on 2026-09-29, the same day the API client was ported to the
Rust SDK.
All of it is a fix or an addition, so the number is a patch. Where this release
changes what working code sees, it raises where 0.1.4 silently returned a wrong
answer - except check()'s exit_ip under X-Exit-IP and isp_cities()'s
default page, which now return a value 0.1.4 could not return.
Three rules tightened while porting the API client to Rust
Found by review of the Rust port, which had copied them from here, and fixed in
both SDKs the same day with cases in the shared specification.
- An empty
2xxis an answer only to aDELETE. Every other call is
answered with JSON, so an empty body on aGETbecame{}or an empty page
and read as "nothing there". It is anApiErrornow. - In the sub-user write calls,
success: falsein a2xxis an error
whether or not apayloadis present. Those are the calls that unwrap the
{success, payload}envelope - create, update, delete, usage reset - and the
check used to sit behind the one forpayload. Other calls do not read the
flag. sessions()never draws the provider's separator. A definition
separating on a hexadecimal digit handed ids containing it tosession(),
which refused them, sosessions()failed at random. Ids are now drawn from
the hex digits minus any one-character separator, and a request larger than
that smaller id space is refused before anything is drawn. Skipping whole ids
instead was tried first and does not scale: with separator0, a 200-character
id avoids it with probability about 2.5e-6.
The API key no longer follows a redirect to another host
The default transport followed redirects, and urllib copies every header onto
the redirected request - Authorization included. Measured 2026-09-29 on two
loopback servers: a 302 from the first delivered x-api-key <key> to the
second, and the second's body came back from me() as the account. Redirects
are no longer followed; a 3xx is an ApiError carrying its status. Nothing
here says the dashboard has ever redirected an API call - this is the guard
for a key that must only ever reach one host.
Found while porting the API client to the Rust SDK, whose transport was written
to follow no redirect from the start.
A write that reached the dashboard's web page no longer reports success
create_sub_user, update_sub_user, delete_sub_user, reset_sub_user_usage
and delete_whitelist_ip returned the response body as their result whatever it
was. dashboard.nodemaven.com answers a path it does not serve with 200 and
its web front end - 6415 bytes of HTML, measured 2026-09-09 - so any of the five
sent to a wrong path returned that HTML as though the call had worked. A delete
that did not happen read as a delete that did.
Every call now raises ApiError on a 2xx whose body is not JSON. An empty
2xx, which is a real answer to a DELETE, still returns {}. The read methods
already refused the page, because they checked the shape of what came back; the
check now happens once, for all of them, before any shape is looked at.
The paths these methods use were all measured on 2026-09-09 and are correct, so
this did not misfire against the live API. It is the guard for the day a path
or the server changes, which on this host is otherwise indistinguishable from
success.
Found on 2026-09-29 by running the shared SDK specification's cases against this
package - the first defect that specification caught.
A timeout is an ApiError, and isp_cities() asks for pages that fit
A read timeout reached callers as a bare TimeoutError, past
except NodeMavenError, because the default transport caught only
urllib.error.URLError and that covers the connect but not the read. Every
OSError a transport raises - the default one or a caller's, and requests'
exceptions are OSErrors too - is now an ApiError saying no status came back,
and a truncated or malformed response (http.client.HTTPException, which is not
an OSError) is one as well.
It was found live. isp_cities() asked for the shared default of 1000 rows, and
this endpoint's time grows with the page: measured 2026-09-29 with
country__code="us", 10 rows in 2.3 s, 100 in 8.3 s, 1000 in 56.0 s. At 1000 it
timed out at 60 s in three runs, on 2026-09-09 and twice on 2026-09-29, so with
the client's 30-second default isp_cities() with no arguments could not
succeed. It now asks for 100 (ISP_CITIES_PAGING) and iterate() walks the
rest; limit= still overrides it. One sample per size, from one connection.
AuthError no longer blames an expired key
The message said the key is a JWT and "an expired one is refused exactly like a
wrong one". Measured 2026-09-29: a key whose exp had passed twenty days earlier
was accepted on every call, reads and all five writes. The server does not
enforce exp, so the sentence sent readers after a clock nothing looks at.
Docstrings brought up to the 2026-09-29 run
lab/probes/smoke_client_live.py called every method through Client, which
the 2026-09-09 probe had not done for the writes. Three docstrings changed as a
result: reset_sub_user_usage() returns the reset sub-users' full rows, proxy
passwords included; whitelist_ip() returns exactly the document's eighteen
WhitelistIP fields; and domain_statistics() rows are three-element arrays,
not the objects the document declares - which position is which is not yet
measured, so nothing names them.
check() reads the exit address under both of the gateway's spellings
exit_ip_header in a provider definition takes a list of header names as well
as one name, tried in order, and the shipped definition lists
["X-Proxy-Exit-IP", "X-Exit-IP"]. Until now it named X-Proxy-Exit-IP alone,
and the gateway's OK back end sends the address as X-Exit-IP, so exit_ip
came back None on replies that carried one. In the 2026-09-08 sample of eight
200 replies that was two of the three Nones; the third carried no address
under any name. The pairing of X-Exit-IP with the OK reason phrase is from
two rounds on 2026-09-10.
Compatible in both directions: a definition with one name loads as before,
Provider.exit_ip_header is still there and is the first name, and the new
Provider.exit_ip_headers holds all of them. connect(exit_ip_header=...)
takes a name or a list. A definition giving an empty name or an empty list is
refused at load, since it reads as "this gateway reports its exit address" and
can never find it.
Stale comments in api.py
Three places described a state the same file had already moved past, and all
three shipped in 0.1.4:
- the catalogue comment gave the pre-2026-09-08 reaction table - any
city500,
a badisp410 - and called the cause of the 500 unknown; - the sub-user comment said none of the five writes had ever been called, above
docstrings recording each call from 2026-09-09; - four grouping methods said "Spec shape, never called", and all four were
called through these methods on 2026-09-09 by the probe's--phase 10.
v0.1.4
The quickstart imported requests and pip install nodemaven did not provide it. Reported by an outside developer reading 0.1.3 from PyPI on 2026-09-10 and reproduced the same day in a clean venv holding only nodemaven==0.1.3.
pip install nodemaven[requests]
An optional extra, not a dependency. requests is imported nowhere in src/ and stays that way: check.connect() writes its own CONNECT precisely so that no HTTP library is needed to read a status line, and an HTTP library discards the status line, the reason phrase and the headers that are the diagnosis on this gateway.
Worth knowing if you hit the same thing: an undeclared extra is not an error. pip answers yourpackage[nosuchthing] with a warning and installs without it, so a README can document an extra that does not exist and look fixed. A test now reads pyproject.toml and asserts every extra the documentation names is declared.
The README was split
984 lines, over half reference and measurement, so a reader arriving from PyPI met the API reference before the second example. Now a 499-line README plus:
docs/api-reference.md- what every public name takes, returns and raisesdocs/validation.md- the five status codes a wrong value comes back as, the fold list, why a separator in a value is refuseddocs/observed-behavior.md- what the account API does that its own specification does not say
Nothing was deleted. The measurements moved at full length, because summarising a measurement turns it into an opinion.
Fixed: api.py's module docstring was wrong in 0.1.3
Its "What is still not measured" block claimed the write endpoints had never been called and the page-number base was unknown. Both were settled on 2026-09-09 and the findings sit in the method docstrings of the same file. Corrected in place with a note on how it happened: a module docstring reads as authoritative and was believed over the code it summarises.
No behaviour changed. Full detail in CHANGELOG.md.
v0.1.3
The library opens sockets now. Two calls reach the network and neither runs on
import: proxy.check() sends one CONNECT to the gateway, and Client() talks
to the dashboard account API. Everything from 0.1.2 still opens nothing -
Proxy builds a string and hands it to whatever client you already have.
proxy.check()
One CONNECT, the reply handed back whole: status, the reason phrase verbatim,
every header, elapsed seconds, the exit address when the gateway sends one, and
the gateway's own explanation of that status.
A refusal is a return value, not an exception. A 407 is the gateway
answering the question, so it comes back as a Check. CheckError is raised
only when nothing came back at all.
The CONNECT is hand-rolled rather than delegated, because of what an HTTP
library does to the diagnosis: requests reports a failed tunnel as
ProxyError('Unable to connect to proxy', OSError('Tunnel connection failed: 407 ...')), where the status survives as text inside a nested exception and the
reason phrase and every header are gone.
The account API
Client() wraps 19 of the vendor's 24 paths - the catalogues, statistics,
sub-users and the IP whitelist. The five that are not wrapped are listed in the
CHANGELOG rather than left silent.
It was written against the vendor's OpenAPI document and then measured against
the live server on 2026-09-09, and the document is wrong in four places that a
generated client would ship:
- Statistics dates are
dd-mm-yyyy, not ISO. The document writes the format
both ways -dd-mm-yyyyin the prose,format: datein the type - and the
prose is the half that is right.start=20-08-2026answers 200 with 21 data
points;start=2026-08-20answers 400 with a body byte-identical to the one
start=not-a-datedraws. So ISO is not a rival spelling the server declines,
it is a string the server cannot parse. - Three paging conventions, not one -
limit/offset,page/per_page
andpage/page_size- and an unknown query parameter is ignored, so a
hard-coded pair silently does nothing on two thirds of the surface. - Sub-user rows arrive under
payload, notresults, in an envelope with
nocountand nonext. whitelist/ip/upsertis refused 400 unlessprotocolis sent, though the
document marks it optional withdefault: "HTTP". The server does not apply
its own default, so this package sends it on every call.
iterate() stops on an empty page rather than a short one, because this server
caps cities at 1000 rows of 1965 and a short page is not the end.
Notes
RateLimitError.retry_after is always None from this client and the 429
message now says so. Transport returns (status, bytes) and the response
headers are discarded before anything could read Retry-After; widening that
return type is a public protocol change across four SDKs and is not made here.
The API key is a JWT that lives 1800 seconds, exp - iat. No path in the
vendor's 24 issues or refreshes one and the document does not mention an expiry,
so a Client held longer than half an hour begins raising AuthError for a
reason no response distinguishes from a wrong key. The message names the clock
as a third possibility. Nothing here reads exp or renews on its own.
This is the first release since the repository went public, so the Source and
Issues links reach the PyPI project page for the first time - metadata only
travels with a release, never with a visibility flip.
Full detail, with the run behind each claim, is in
CHANGELOG.md.
v0.1.2
norotate is refused again, and the version number now distinguishes the two artifacts.
What changed
norotate was added to the recognised parameter set on 2026-08-21 because it appears in the vendor's proxy generator. It has been taken back out.
Probed from a VPS on 2026-08-26 against the gateway, three ways, each with a negative control carrying a name nobody could have implemented and a positive control on filter:
| probe | norotate=true |
both controls |
|---|---|---|
| junk value | 200 |
200 |
no sid, 6 draws |
6 distinct exits of 6 | 6 distinct of 6 |
fixed sid + ttl=1m, sampled at 0/78/157 s |
3 distinct of 3 | 3 distinct of 3 |
Rotation is what norotate claims to change, and it does not change it. The gateway answers 200 and drops the setting, which is the exact failure this package exists to catch, so the parameter belongs on the refused side.
values and its parser, added in the same commit, stay. Nothing measured since bears on them.
speed has the same provenance and has never been probed for its effect. It stays, with a note in the provider definition saying so. Removing it on inference would be this same mistake pointing the other way.
Why 0.1.2 and not a patch to 0.1.1
Between 2026-08-21 and today, what was published as 0.1.1 and what was on main disagreed about norotate while both reported __version__ == "0.1.1". Nothing outside the repository could tell them apart. This release ends that.
Full Changelog: v0.1.1...v0.1.2