Skip to main content

NodeMaven

NodeMaven Python SDK

Builds the proxy username a gateway expects, and refuses the input it would silently drop.

pypi python ci

license

Quickstart · Reference · Parameters · Errors · Sticky sessions · Why the validation is the point · Asking the gateway · Account API · What it does not do · Other gateways · Docs

Proxy opens no socket. It builds the username a proxy gateway expects, refuses the input that gateway would mishandle, and hands the result to whatever HTTP client you already use.

Two calls do reach the network, both by name and neither on import: proxy.check() opens one CONNECT and tells you what the gateway said about it, and Client talks to the dashboard API.

Works with requests · httpx · aiohttp · Playwright · Patchright · Puppeteer · curl - and anything else that takes a proxy URL, because that is all it hands back.

pip install nodemaven

Quickstart

login and password are the Proxy Username and Proxy Password assigned under Proxy Setup in the dashboard - a separate pair from the account you sign in with. The other option there is IP whitelisting, which needs no credentials in the username at all; both are described in authentication methods.

import requests
from nodemaven import Proxy

proxy = Proxy(login="your-login", password="your-password",
              country="us", filter="medium")

r = requests.get("https://api.ipify.org", proxies=proxy.requests())
print(r.text)
203.0.113.42

If that is not your own address, it worked. If it is your own, the request never went through the proxy.

Why not just write the URL yourself?

Because the gateway does not tell you when you get it wrong. A misspelt parameter is not refused - the tunnel opens, the setting is dropped, and the traffic you are paying to route through a medium-quality US pool goes out wherever the gateway felt like:

# by hand: a typo the gateway answers 200 to, and never mentions again
"http://user-country-us-filtr-medium:pass@gate.nodemaven.com:8080"

# with this library: refused before anything is sent
Proxy(login="user", password="pass", country="us", filtr="medium")
ParamError: NodeMaven does not know the parameter 'filtr': it is answered with
200 and dropped, so the connection would succeed and your setting would NOT be
applied. Known: ['city', 'country', 'filter', 'ipv4', 'isp', 'region', 'sid',
'speed', 'ttl', 'type']

That is the whole reason the package exists, and why the validation is the point has the measurements behind it.

Credentials can come from the environment instead, so nothing is in your source:

# NODEMAVEN_LOGIN and NODEMAVEN_PASSWORD
proxy = Proxy(country="us", filter="medium")

The same identity, for other clients:

proxy.url()          # http://user:pass@gate.nodemaven.com:8080  - httpx, aiohttp, curl
proxy.requests()     # {"http": ..., "https": ...}
proxy.httpx()        # {"http://": ..., "https://": ...}
proxy.playwright()   # {"server": ..., "username": ..., "password": ...}
proxy.username       # the username on its own
proxy.server         # host:port, no credentials

With Playwright, Patchright or Puppeteer:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(proxy=proxy.playwright())

Reference

What every public name takes, returns and raises. Nothing in this section needs the ones after it; those carry the measurements the refusals were built on.

Proxy

Proxy(*, login=None, password=None, host=None, port=None, provider=None, **params)

Keyword-only. Builds a username and opens nothing.

argument falls back to refused when
login NODEMAVEN_LOGIN missing → CredentialsError
password NODEMAVEN_PASSWORD missing → CredentialsError
host NODEMAVEN_HOST, then the definition's own -
port NODEMAVEN_PORT, then the definition's own not a whole number 1 to 65535 → CredentialsError
provider the shipped nodemaven definition -
**params - a name outside known_params, or a value that is empty, carries a separator, carries whitespace the definition does not fold, or is outside a list the definition declares → ParamError

The environment names come from the definition's id in upper case, so a gateway of your own reads its own pair - see Other gateways.

attribute is
.username the username, in the gateway's dialect
.server host:port, with no credentials in it
.params the parameters as they will be sent, already folded. A copy
.provider the Provider behind it
call returns
.url(scheme="http") http://user:pass@host:port, both credentials percent-encoded
.requests(scheme="http") {"http": ..., "https": ...}
.httpx(scheme="http") {"http://": ..., "https://": ...}
.playwright() {"server": ..., "username": ..., "password": ...}, credentials not encoded
.session(session_id) a new Proxy pinned to that sticky session
.sessions(count, *, length=6) a list of count new Proxy objects, ids distinct
.replace(**changes) a new Proxy; a value of None removes that parameter
.check(*, target="api.ipify.org:443", timeout=15.0) a Check. The only call here that opens a socket

session() raises ParamError on a definition that declares no session parameter. sessions() ids are 2 * length hexadecimal characters, so the default holds 2**48; it raises ParamError when count asks for more distinct ids than length bytes can hold, rather than looping forever looking for them.

Check

Returned by proxy.check(). Frozen, and a refusal arrives as one of these rather than as an exception.

attribute is
.ok True only on 200
.status the CONNECT status, an int
.reason the reason phrase, verbatim - it labels which back end answered
.server the host:port that was asked
.elapsed seconds, including DNS and the TCP handshake
.headers every response header, keys lower-cased
.exit_ip the exit address, or None when the gateway did not send one
.meaning what this status means on this gateway, or None

CheckError is raised only when nothing usable came back at all: DNS failure, a refused connection, a timeout, a response head that never ended, or a first line that is not a status line. Refusals that happen before anything is sent are CheckError too, and those messages end in Nothing was sent.

Client and Page

Client(api_key=None, *, base_url=None, timeout=30.0, transport=None)

api_key falls back to NODEMAVEN_APIKEY and raises CredentialsError when neither is set. transport is (method, url, headers, body) -> (status, bytes) and defaults to urllib.request.

call returns
.me() the account object, the server's own field names
.countries() .regions() .cities() a Page
.zip_codes() .zip_code_regions() .zip_code_cities() a Page
.isps() .isp_regions() .isp_cities() a Page - isps() has an envelope that is not the usual one
.statistics_data(proxy_username, **filters) / .statistics_requests(...) a dict
.domain_statistics(proxy_username, **filters) a Page that does not page - the whole answer is one object
.sub_users() .whitelist_ips() a Page, numbered by page rather than by offset
.whitelist_ip(id) a dict
.create_sub_user(username, password, *, traffic_limit=None, is_traffic_limited=None, **extra) a dict
.update_sub_user(id, **changes) / .delete_sub_user(id) a dict
.reset_sub_user_usage(ids) a dict. Takes a list, not one id
.upsert_whitelist_ip(ip, ports_count, *, name=None, protocol="HTTP", sticky=None, ttl=None, **extra) a dict. Creates or replaces. protocol is always sent
.delete_whitelist_ip(id) a dict
.iterate(page, *, max_pages=100) an iterator over the rest of the collection, page by page
.validate(proxy) a list of problem strings, empty when the catalogue agrees

Every list call takes **filters, passed through as query parameters in the server's own spelling - country__code="us".

Paging differs by endpoint and the difference is not cosmetic. The nine location endpoints take limit and offset, both always sent: limit defaults to DEFAULT_PAGE_SIZE, 1000, and offset to 0. sub_users() takes page and per_page; whitelist_ips() takes page and page_size, whose documented default is 5 and maximum 100. An unknown query parameter is ignored rather than refused, so one hard-coded pair would silently do nothing on two thirds of this surface.

1000 is the largest limit the location endpoints accept: limit=10000 is refused outright by isps, and on cities it returns the same 1000 rows limit=1000 does.

That 1000 is a ceiling on the answer, not a count of the rows behind it. Asking cities for the next offset at the same limit returns a further 965, so one call sees about half that collection and nothing in the reply says so - count, next and previous all come back None. Use iterate() there. The other catalogues do fit in one request.

A Page iterates its own rows and has a len(). .count, .next and .previous are the server's, and are None everywhere - no schema in the vendor's own specification declares either field, so this is the API's shape and not a gap in one endpoint. .request_path, .request_params and .paging record the call the page came from, which is what lets iterate() ask for the next one.

iterate() raises ApiError rather than looping in four cases: more than max_pages pages, a next-page url that has already been returned, a page identical to the one before it - the server accepted the cursor and ignored it - and a next-page url pointing at a different scheme, host or port than base_url. The host check is there because the request that would follow the url carries the API key in a header.

Both page-numbered endpoints start at page 1, measured 2026-09-09 by lab/probes/probe_account_api.py --phase 9. On sub-users/ the evidence is where the rows are - page 0 came back with none and page 1 with the account's only sub-user, which a 0-based server cannot produce. On whitelist/ips the collection is empty, so the reading is weaker: page 1 is the only number answered 200 at all, while 0, 2 and 9999 are answered 404.

The two endpoints do not end their collections the same way. Past the last page sub-users/ answers 200 with an empty payload and whitelist/ips answers 404, so a 404 is treated as the end of the walk on the whitelist and nowhere else - everywhere else it is still an error, because a 404 is also what a wrong path looks like.

Provider and the module functions

call returns
load(provider_id="nodemaven") a shipped definition
load_file(path, provider_id=None) a definition from a TOML file; the id is the filename unless named
available() the ids of every shipped definition, as a list

A Provider is a frozen description of one gateway: id, label, known_params, status, prefix, separator, pair_separator, session_param, host, port, aliases, values, normalize, connect_reactions, exit_ip_header, source, source_read, notes. Only id, label and known_params are required. .is_measured is True when status is measured.

Parameters

What the shipped NodeMaven definition accepts. Every name here was confirmed against the gateway rather than transcribed:

parameter what it selects values measured to work
country country code, or any us, de, ...
region area inside the country a name
city city inside the country a name, with a region beside it
isp the exit's ISP a name
type mobile or residential exits mobile, residential
sid the sticky session - see below any string with no -
ttl how long that session is held 1m, 10m, 10h, 24h
filter IP quality low, medium, high
speed claims a connection speed class - see below fast, slow
ipv4 claims to force IPv4 - see below True

type picks a different pool rather than a filter over one pool. Five requests per arm with a fresh sid and country=us: type=mobile drew T-Mobile and Verizon Wireless ASNs, while type=residential and leaving it unset drew Comcast, Charter, Windstream and other wireline carriers, with no mobile ASN among them.

ipv4 and speed are confirmed names whose effects are unmeasured, and the table says claims to for that reason. For ipv4 the name took a different method to confirm: a junk value on it is answered 200, so it cannot be told apart from an unimplemented name that way. What tells them apart is the sticky session, whose key is the parsed parameter set - over two independent session ids with both controls holding, ipv4=True moves the exit and an unknown name does not. ipv4=False lands on the same exit as leaving it out, which is what a default would do and also what a dropped value would do.

Names are validated. Values, on this gateway, are not. Passing a name that is not in this table raises before anything is sent, because the gateway answers an unknown name with 200 and drops the setting. Values are passed through, because what is known is which ones have been observed to work - and that is not the same as the set the gateway accepts. Refusing on a guessed list would block a setting that would have worked, which is the worse mistake of the two. The schema does carry a per-parameter list of legal values and refuses anything outside it; the shipped definition leaves that list empty for every parameter, deliberately, and a definition you write yourself gets the check as soon as you fill it in.

Case and spacing

country, region, city, isp and type are folded before they are sent: surrounding whitespace trimmed, ASCII A-Z lowered, each remaining space turned into _. country="US" and country="us" are therefore the same request, and

Proxy(login="u", password="p", region="District of Columbia").username
# u-region-district_of_columbia

region-district_of_columbia is the form this gateway generates for itself - it appears in a username the dashboard issued - and the vendor's own client applies the same transformation. Without the fold a space reaches the username, which cannot carry one: the CONNECT line is a single token, so the value is either malformed or cut short.

sid, filter, ttl and speed are sent with their case unchanged. sid is yours, and folding an identifier a caller chose would rename their session, so it is left alone whatever the gateway does with it.

For the other three, pass lower case, and for ttl that is not advice: ttl-10m opens the tunnel and ttl-10M is answered 407, which reads as a credentials problem and is not one. filter and speed were not refused in either case, and this library still does not fold them - what the gateway accepts today and what it will accept next month are different claims, and the fold list is data in the gateway definition rather than a decision in this package.

Which parameters fold is declared in the gateway definition, as data, so a gateway you describe yourself folds what you say it folds and nothing else.

Every other value is refused if it contains whitespace. There is no spelling of a space that works here - username would emit it raw, url() would percent-encode it to %20, and playwright() would hand over a third thing - so between the fold and the refusal, no value with whitespace in it can reach the wire by any path.

Credentials come from NODEMAVEN_LOGIN and NODEMAVEN_PASSWORD when not passed in, and the gateway address from NODEMAVEN_HOST and NODEMAVEN_PORT.

Errors

Everything inherits from NodeMavenError, so one except catches everything this library raises.

exception raised when
ParamError a parameter name is unknown, a value is empty, a value contains whitespace or the gateway's separator, or a value is outside a list the definition declares
CredentialsError no login, no password, no gateway address, or no API key, from arguments or environment
ProviderError a gateway definition is missing, unreadable, or internally inconsistent
CheckError check() got no answer at all - DNS, a refused connection, a timeout, or something that is not a proxy on that port
ApiError the account API refused a call, or answered a shape this library cannot read. Carries .status and .body
AuthError the API key was rejected - ApiError with a 401 or 403
NotFoundError the row is gone - ApiError with a 404
RateLimitError too many calls - ApiError with a 429, and a .retry_after
NodeMavenError the base, never raised on its own

ParamError also covers the two structural cases: session() on a definition that declares no session parameter, and a definition whose parameter names collide with login, password, host, port or provider.

The first three are found before a socket exists - they are failures in what you asked for, not in what happened. The next five have been to the network and back. That split is why they are separate classes: retrying a ParamError can only produce the same ParamError.

Two credentials, two exceptions, and they are not interchangeable. CredentialsError from Proxy is the proxy password, refused before anything is sent; AuthError from Client is the dashboard API key, and it has been to the server. A library that reported both as one would tell you to fix the key when the password is wrong.

Sticky sessions

One Proxy is one identity. Pin it to a sticky session:

held = proxy.session("order4417")

A session id cannot contain the character the gateway separates parameters with, which for this one is -, and passing one raises rather than connecting. That is measured and not a precaution: a probe opened tunnels with sid-order8e3bf9-4417 and with sid-order8e3bf9, four rounds each, interleaved, and both landed on one exit address while a third arm spelled sid-order8e3bf94417 held a different one throughout. The gateway cuts the value at the separator and reads the rest as something else, so every order id beginning order would quietly share one session and one exit.

The same cut applies to every parameter, not just sid, which is why a separator in any value is refused. isp-verizon opens the tunnel, a junk isp is answered 406, and isp-verizon-zzqqx-zzqqx is answered 200 - so the gateway took verizon as the ISP and read the tail as a parameter name it does not know, which it drops silently.

The session key is the whole parameter set, not the session id. country=us, sid=A and country=us, sid=A, filter=medium are two different sessions on the gateway, so adding or removing any parameter moves you to a different exit address. That is why parameters change through a method that returns a new object rather than by assignment - the move is a different identity, and the code should say so:

germany = proxy.replace(country="de")   # a new identity, a new exit
plain   = proxy.replace(filter=None)    # also a new identity

The set, not the order. Measured over 20 rounds a side: the canonical parameter order and a shuffled one drew the same exit 20 times each, while a control differing by one parameter value drew a different exit 20 times. So the order this library emits parameters in cannot change which exit you get.

Many sessions at once

For a worker pool, one identity per worker:

for identity in proxy.sessions(50):
    queue.put(identity)                 # each one a different exit

Each id is 2 * length hexadecimal characters from secrets, length=6 by default, and the ids are distinct within one call. Asking for the whole space or more raises ParamError - sessions(256, length=1) wants every one of the 256 ids an eight-bit space holds, and drawing them without repeating is a loop that either never finishes or leaves nothing for the next caller.

Hex, and not the alphabets people reach for first, because the gateway cuts a value at its separator and every id sharing a prefix then collapses onto one exit - silently, since the connection still succeeds. secrets.token_urlsafe emits - and _, uuid4() emits - four times, base64 emits + and /, and each of those is a separator on some gateway. From secrets and not random because random is seeded from the clock: two workers starting in the same millisecond would draw the same ids.

Why the validation is the point

Every gateway behaviour below was measured against the live gateway rather than transcribed from documentation, and each one carries its date and the probe behind it in CHANGELOG.md.

A gateway is bad at telling you that you got the username wrong. One class of mistake - a value it will not take - comes back five different ways, and not one of them names the parameter. Read by raw CONNECT, one arm per row:

you sent the gateway answers
bad country value 407 Proxy Authentication Required
bad region value 406 Not Acceptable
bad city value 406 Not Acceptable
city sent without region 500 Internal Server Error
bad isp value 406 Not Acceptable, and 410 Gone for comcast
bad filter value 407 Proxy Authentication Required
bad ttl value 407 Proxy Authentication Required
bad type or speed value 407 Proxy Authentication Required
empty value nothing, the connection hangs about 20 s
unknown parameter name 200, and the parameter is ignored

Every 407 there sends you to check credentials that are correct, and the 406 does not even say which of the two parameters it refused: a bad region, a bad isp and charter - a real ISP - all answer it, so it separates neither the parameter nor a misspelling from a pool you cannot have. comcast is the one value measured to answer 410 instead, which reads as a name the gateway knows and a pool this account cannot reach - one ISP, so read it that narrowly.

The two city rows are one rule: send city with its region. Measured over six CONNECTs holding the login, the password, the target, the gateway host and port and the parameter order fixed. country=us, region=louisiana, city=abbeville answers 200, and so does a second city in a second region. The same city with the region left out answers 500, and an invented name sent with a real region answers 406 - so the gateway does look the name up, and the 500 is a request it could not resolve rather than a fault on their side. Client.validate() refuses that combination before it goes out.

The last row is worse than any of them: the request succeeds, your code carries on, and the setting you asked for was never applied. Nothing that comes back over the wire can tell you.

ttl counts in minutes and hours - 1m, 10m, 10h and 24h connect, while 10s, 10d and a bare 10 are answered 407. It is also the one parameter whose value case matters: 10M is refused where 10m is accepted. Parameter names are case-insensitive at the gateway; this library folds values for country, region, city, isp and type to the wire form anyway, so a space or a capital in a place name is not your problem.

So this library checks before anything is sent:

>>> Proxy(login="u", password="p", contry="us")
ParamError: NodeMaven does not know the parameter 'contry': it is answered with
200 and dropped, so the connection would succeed and your setting would NOT be
applied. Known: ['city', 'country', 'filter', 'ipv4', 'isp', 'region', 'sid',
'speed', 'ttl', 'type']

Asking the gateway

Validation catches everything knowable without sending anything. For the rest - a wrong password, a country the pool does not have, a value the gateway dislikes

  • there is one call that opens a single CONNECT and reports what came back:
result = proxy.check()
200 Connection established via gate.nodemaven.com:8080 in 0.42s, exit 203.0.113.7

The exit address arrives on the CONNECT reply itself, on a header the gateway definition names, so knowing where you came out costs one handshake and no traffic through the tunnel.

Do not build anything on it being there. More than one implementation answers behind this hostname, which one you reach is decided by your username, and they do not agree about the header: one measured 200 carried X-Exit-IP where the shipped definition names X-Proxy-Exit-IP, and others send no address at all. So result.exit_ip is None more often than the definition suggests, and that is normal rather than an error. If you need the address every time, read it through the tunnel from a service that echoes it.

A refusal is a return value, not an exception. The status code is the thing you came for, and raising would bury it in a traceback - which is what a general HTTP client does. So a failed tunnel comes back as an object, carrying the gateway's own reading of its own status code:

407 Proxy Authentication Required via gate.nodemaven.com:8080 in 0.19s
usually NOT your credentials, despite what the status says. A value the gateway
will not take on `country`, `filter`, `ttl`, `type` or `speed` answers 407, and
so does a wrong password. Check the values before the password - and check the
case of `ttl`, which is the one value that is case-sensitive: `10M` is refused
where `10m` is accepted.

That second paragraph is data in the gateway definition, not a string in this library, because what a status code means is per-gateway. The fields a Check carries are in the reference; CheckError and when it is raised are there too.

ok does not mean your parameters were applied. An unrecognised parameter name is also answered 200 and dropped, which is the whole reason the section above refuses unknown names before sending. No call can recover that after the fact, and this one does not pretend to.

check() names the host it tunnels to - api.ipify.org:443 by default - and whatever you name will see a TCP connection from your exit address. There is no CONNECT to nowhere, so this is a parameter rather than a constant:

proxy.check(target="example.com:443", timeout=15.0)

The timeout defaults to 15 seconds rather than something brisk, because one of this gateway's measured reactions is no reply for about 20 seconds. A 5-second timeout would report that as a network problem.

Account API

Quota, usage, sub-users and the location catalogue. Separately credentialled, because the API key and the proxy password are different secrets from different places.

Every path here now comes from the vendor's own OpenAPI specification, read 2026-09-09. Five of the calls have also been sent against a live account and answered: users/me, countries, regions, cities and isps, measured 2026-09-08. The rest are still transcribed - from the specification now rather than from the vendor's client at github.com/nodemavencom/proxy, python/nodemaven/client.py, where four paths were wrong. The five write calls have still never been called, on purpose: probing one costs a real object on a production account.

A wrong path is not answered 404 here, measured 2026-09-09. locations/zip-codes/, statistics/ and whitelist-ips/ - three paths this package sent before the specification was read - are answered 200, text/html, 6415 bytes, byte-identical to a path invented on the spot as a negative control. The dashboard serves its front end for anything it does not route, so calling a path cannot tell you whether the path exists. If you wrap a path of your own against this API, compare its response against a path nobody could have implemented rather than against a status code.

The real paths are locations/zipcodes/ (solid, no separator), statistics/data/, statistics/requests/, statistics/domains/ and whitelist/ips. sub-users/ was real all along but was being read under results, where the rows are under payload.

Calling isps is how its different envelope was found; the other four came out of the specification.

from nodemaven import Client

client = Client()                       # NODEMAVEN_APIKEY from the environment
me = client.me()
print(me["data"])                       # traffic left

me() returns the server's own object with its own field names, unrenamed and unmodelled. It answers six fields:

field type
data int traffic left. Not traffic_left
email str
is_traffic_frozen str not a bool - see below
proxy_password str the proxy password, not your API key
proxy_username str
subscription_status str

is_traffic_frozen is a string, so if me["is_traffic_frozen"]: is true whichever way it reads. Compare it against the value rather than for truth.

Not modelling this into a dataclass is deliberate, and the response above is the argument for it. Written a day earlier from the vendor's client, a dataclass would have declared traffic_left, which does not exist, and typed is_traffic_frozen as a bool, which it is not. The raw dict was wrong about nothing, because it claimed nothing.

client.countries()                      # the catalogue, paginated
client.regions(country__code="us")      # Django's field lookup, the server's spelling
client.cities(country__code="us", region__code="dc")
client.isps(country__code="us")         # a different envelope, see below
client.zip_codes(country__code="us")

# Which regions and cities the ISP and zip-code catalogues actually cover.
# Separate endpoints, not filters on the two above.
client.isp_regions(country__code="us")
client.isp_cities(country__code="us", region__code="dc")
client.zip_code_regions(country__code="us")
client.zip_code_cities(country__code="us", region__code="dc")

# Statistics are per proxy username, and the username is required.
# The range is `start` and `end`, not `start_date` and `end_date`, and the
# dates are `dd-mm-yyyy`. ISO is answered 400 - see below.
client.statistics_data("acct-1", start="01-09-2026", end="07-09-2026")
client.statistics_requests("acct-1", start="01-09-2026")
client.domain_statistics("acct-1", period="hours24")

client.sub_users(page=1)
client.create_sub_user("worker-1", "a-password", traffic_limit=1024)
client.update_sub_user(id, traffic_limit=2048)
client.delete_sub_user(id)
client.reset_sub_user_usage([id])       # a list, even for one

client.whitelist_ips(page=1)
client.whitelist_ip(id)
client.upsert_whitelist_ip("203.0.113.7", 10, name="the office")
client.delete_whitelist_ip(id)

The dates go as dd-mm-yyyy, measured 2026-09-09 by --phase 10: start=20-08-2026 is answered 200 with 21 data points and start=2026-08-20 is answered 400, with a body byte-identical to the one start=not-a-date draws. The vendor's document writes the format both ways - dd-mm-yyyy in the prose, format: date in the type - and the prose is the half that is right. So ISO is not a rival spelling the server declines, it is a string the server cannot parse, and every client generated from that specification sends the one form that fails.

This paragraph said the opposite until 2026-09-09, and said it for the worst possible reason: "the type is what the server parses" was an inference about which half of a self-contradicting document to trust, written before either half had been sent. The measurement cost one request.

sub_users() returns each sub-user's proxy_password in clear text, on every row, by the specification's own required-field list. So does me(). Do not print a row of either, and do not paste one into an issue.

Five of the vendor's twenty-four documented paths are deliberately not wrapped: locations/all-doc/, notifications/, llm/submit/, llm/results/{id}/ and llm/balance/. Listing them is the point - an omission nobody wrote down reads the same as an oversight.

A list endpoint returns a Page, which iterates one page and not the collection. iterate() gets the rest:

page = client.countries()               # one page, 1000 rows by default
for country in client.iterate(page):    # all of them
    ...

page.count is None everywhere, and that is the API's shape rather than a gap here. No schema in the vendor's specification declares a count or a next at all - PaginatedCountryList is {"results": [...]} and nothing else - and a page of 50 drawn from a catalogue of nearly 200 countries came back with all three fields empty. A full page is therefore indistinguishable from a complete collection by looking at it, so iterate() asks for the next cursor and reads the answer.

It stops on an empty page, not on a short one. That rule changed on 2026-09-09 and the one before it was unsafe against this server in particular: it stopped at the first page shorter than the size it had asked for, which is wrong wherever the server caps the size below the request. cities(limit=10000) is answered with 1000 rows out of 1965, and "shorter than asked" reads those 1000 as the end. The measurement that says so is four paragraphs up in this same file and was already there - a measurement sitting in a document is not a measurement anyone applied. Stopping on empty costs one spare request per walk and cannot truncate.

limit and offset are sent on your behalf and are overridable:

client.countries(limit=50)              # four requests instead of one

They are sent rather than omitted because isps() refuses a request without them, while the others answer one with a silently partial list. offset is honoured: offset=50 returns rows disjoint from offset=0, and an offset past the end answers 200 with zero rows.

sub_users() and whitelist_ips() are numbered by page instead, and both start at page 1, measured 2026-09-09 - see the reference. They mark the end of a collection differently, so iterate() cannot use one rule for both: past the last page sub-users/ answers 200 with an empty payload and whitelist/ips answers 404. A 404 therefore ends the walk on the whitelist and nowhere else.

iterate() raises rather than looping in four cases: a next-page url it has already returned, more than max_pages pages, a page identical to the one before it - which means the server took the cursor and ignored it - and a next-page url on another host. None is a tuning knob; each is the difference between a bug you can see and one that surfaces as a rate limit or a short answer.

The ISP catalogue answers a different shape

isps() returns a Page like the others and most callers can stop here. The rest of this section is why it took two runs to get there.

Its 200 is an object keyed city, country, isps and region, with no results anywhere. The rows are the list under isps - 358 of them for country__code="us"; the other three fields are strings and are not rows. isps() reads that key and the other four list methods read results. That is per-endpoint knowledge in the client rather than a page builder that goes looking for whichever value happens to be a list, which is a rule decided by key order the day a second list appears.

For a day this method returned a Page of exactly one row - the envelope itself

  • whatever the account held. Nothing was released in that state; it was written up as broken rather than fixed, because the run that found it printed the key names and not the values, and a key unwrapped because its name reads right is how a parameter the gateway ignores once got into this package and a field the server does not send got into this README. The fix waited for one command, and was the same edit either way.

The whitelist needs a field the spec marks optional

upsert_whitelist_ip() always sends protocol, defaulting to "HTTP". The vendor's OpenAPI document marks ip and ports_count required and gives protocol a default: "HTTP"; the server does not apply that default. Measured 2026-09-09 at 16:18, one field at a time:

body answer
ip, ports_count, name 400 "Please enter a valid protocol(HTTP or SOCKS5)."
the same plus protocol: "HTTP" 201, {"ip_id", "message"}, and a read of whitelist/ips found the address

So a client written from the specification sends the one body that fails. The default here is a compensation for that, not a convenience, which is why protocol is a keyword with a value rather than another None.

Two things that run did not settle: name was present in every rung, so whether it is also required is untested, and no rung tried ip, ports_count and protocol alone, so that trio is not known to be a complete body.

An earlier version of this section said only that the required pair was refused and that which field was short was the server's word. That was accurate and it was one call away from being a measurement.

Checking a Proxy against the catalogue

The gateway answers a country it does not have with 407, which reads as a credentials problem, and does not say which parameter was wrong. The catalogue knows, so it can be asked:

problems = client.validate(proxy)
if problems:
    raise SystemExit("\n".join(problems))

This is a method on Client and not a check inside Proxy, for one reason: a refusal that ships in a release can be wrong forever, and the catalogue moves. Asking the live catalogue cannot go stale - and it costs a network call, so it has to be your decision rather than a hidden one.

Two things are checked. country is matched against the catalogue, and a city sent without a region is refused before the request goes out - that one needs no network. The gateway answers a city with no region with 500, which reads as a fault on their side and is not one; the same city with its own region answers 200. The values of region, city and isp are not matched against the catalogue, because city codes repeat across regions - aberdeen, albany and alexandria each appear twice in a single page - so matching a bare code would report success and mean nothing.

No dependencies, and your own client if you want one

The transport is urllib.request from the standard library, so this adds nothing to your dependency tree. If you would rather it went through the client you already have, that is one function:

def transport(method, url, headers, body):
    r = requests.request(method, url, headers=headers, data=body)
    return r.status_code, r.content

client = Client(transport=transport)

Return the status rather than raising on it; mapping statuses to exceptions is this library's job, and doing it in both places is how a NotFoundError becomes somebody else's exception halfway up a stack.

What this library does not do

It does not retry. That is deliberate, and it is the one design decision here taken against a measurement rather than a preference.

Retrying a refused request is the thing that most reliably makes the next one worse: each retry confirms automation to the target and burns the exit range for everyone else sharing the pool. Measured over 1464 attempts, the chance that the next attempt succeeds, by how many failures came immediately before it:

failures before P(next attempt succeeds)
0 75%
1 21%
3 5.9%
5 5.8%
6 1.6%
7-9 0.5%

294 attempts were spent past six consecutive failures and returned 3 pages - 98 attempts per delivered page, against 1.7 in a healthy session. A library that shipped automatic retry as a default would be spending that on your behalf without telling you.

Those 1464 attempts, and the cells they came from, are in nodemaven/proxy-benchmark - the harness that measured them, open source, so the table above can be re-run rather than believed.

It also does not own an HTTP client, a connection pool or a browser. Those are yours, and they are better than anything a vendor SDK would bundle.

Other gateways

No account here? Any proxy you already have works. Parameters are data, not hardcoded keywords. A gateway is its prefix, separators, session parameter and the set of parameter names it actually recognises - and a definition written by you goes through the same builder and the same validation as the one shipped here.

Build it in place:

from nodemaven import Provider, Proxy

mine = Provider(id="mine", label="My proxy", known_params=frozenset())
proxy = Proxy(provider=mine, login="u", password="p",
              host="proxy.example.com", port=8000)

Describe the parameters it does take and it validates those too. Or keep the definition in a TOML file:

# my-gateway.toml
label = "My proxy"
known_params = ["country", "session"]
session_param = "session"
host = "proxy.example.com"
port = 8000
from nodemaven import Proxy, load_file

mine = load_file("my-gateway.toml")
proxy = Proxy(provider=mine, login="u", password="p", country="us")
proxy.session("order4417")     # u-country-us-session-order4417

known_params is the whole point of the file: name a parameter that is not in it and the call raises instead of connecting. Leave the list empty and every parameter is refused, which is the correct thing to say about a gateway whose dialect nobody has established.

Credentials fall back to the environment under the definition's id in upper case, so this one reads MY_GATEWAY_LOGIN and MY_GATEWAY_PASSWORD and never NODEMAVEN_*. One process can hold several gateways without their credentials reaching each other.

The id comes from the filename, not from the variable you assign it to. load_file("my-gateway.toml") is my-gateway however it is named in your code, and - becomes _ in the variable names. Pass provider_id= to say it outright. The error raised when a credential is missing prints the exact pair it looked for, so this is one guess you never have to make.

Every definition carries a status. measured means traffic has gone through that gateway and the dialect was read off the wire. documented means it was transcribed from documentation and never exercised. Only nodemaven is shipped here, and it is measured.

Requirements

Python 3.9 or newer. No dependencies on 3.11 and newer; tomli on older ones.

Changes

CHANGELOG.md. Entries carry the probe and the date behind any change to what the gateway is believed to accept.

License

MIT.

Metadata

Release files for nodemaven 0.1.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nodemaven 0.1.3
File Size Uploaded
nodemaven-0.1.3.tar.gz 155.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nodemaven 0.1.3
File Interpreter ABI Platform
nodemaven-0.1.3-py3-none-any.whl Python 3 none any Details

Total release size: 240.2 kB

Release files / nodemaven-0.1.3.tar.gz

Download URL nodemaven-0.1.3.tar.gz
Size 155.5 kB
Tags Source
SHA-256 checksum
How to use checksums
cec5842907622572168ecc968299202caf5479f9ea36bc31e4959237495661f7
BLAKE2b-256 checksum
How to use checksums
2b141d43279401bf43625829390b83daa3bfffb1312606bdf8835cd91b7646bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release files / nodemaven-0.1.3-py3-none-any.whl

Download URL nodemaven-0.1.3-py3-none-any.whl
Size 84.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2ab8383ca72295dadf85aa4f8f35d0bf8e5301a4dea238fbac8e7475f133dc2e
BLAKE2b-256 checksum
How to use checksums
8603bde03a925e8f27a511551f7362fd30e272bbe02148ae82d55be9d3dbfff4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.5

2 release files

0.1.4

2 release files

This release

0.1.3 This release

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page