Skip to content

requests — a browser-impersonating HTTP client

A native nimony HTTP client that impersonates real browsers at the byte level — TLS cipher/extension ordering, GREASE, the post-quantum key_share, ALPN/ALPS, HTTP/2 SETTINGS and pseudo-header order, and the exact default header set. It is a C-FFI binding over libcurl-impersonate (the lexiforest fork), driving a single curl_easy_impersonate(target, 1) call to install the whole fingerprint. Standalone: it does not use the rest of the aoughwl net stack, but the impersonation requires the vendored C library. It is a nimony-idiom reimplementation of the Nim2 client under src/requests/ — status-based returns (no exceptions), top-level {.cdecl.}/{.nimcall.} callbacks (no closures), and caller-owned lifetimes.

Status — Run-verified against httpbin.org / example.com (all verbs, 7 profiles, full header/TLS/proxy/cookie/streaming/multipart/concurrency/hooks/retry paths). The three formerly-deferred TODO(nimony) items are now done and live-tested: the INFO_CERTINFO peer certificate-chain walker (certInfoConfig + certChain), file-IO cookie-jar auto-save/load (saveCookies/loadCookies/cookieFile), and cross-thread CURLSH lock callbacks (newThreadSafeShare).

Quickstart

nim
import requests

let s = newSession("chrome136")            # falls back to builtins[0] if unknown
let r = s.get("https://httpbin.org/get")
echo r.status, " ", r.ok(), " ", r.contentType()

# full header control — verbatim order, or strip a browser default
discard s.get(url, cfg = orderedHeaders(@[("X-A", "1"), ("X-B", "2")]))
discard s.get(url, cfg = withoutHeaders(@["Accept-Language"]))

# auth + typed bodies
discard s.get(url, @[bearer("token")])
discard s.postForm(url, @[("user", "bob")])
discard s.postJson(url, """{"k":1}""")

# streaming, multipart, upload
discard s.download(url, onChunk, addr ctx)                 # {.nimcall.} sink
discard s.uploadString("PUT", url, bigBody)                # READFUNCTION stream
discard s.postMultipart(url, @[field("u", "bob"),
                               fileField("f", "/a.png")])   # curl owns the boundary

# proxy rotation, coherence audit, retry, concurrency
let pool = newProxyPool(@[proxyEntry("http://p1:8080")])
discard s.get(url, cfg = pool.pick().toConfig())
echo auditSession(s, myHeaders)                            # fingerprint tells
discard s.request("GET", url, retry = retryPolicy(maxAttempts = 3))
let rs = s.getAll(@[u1, u2, u3])                           # curl_multi, order kept
s.close()

request never raises: on a transport failure the returned Response has a non-empty .error and .status == 0.

API

Core types

symbolsignaturewhat it does
Sessionref object (handle, profile, verifyTls, timeoutMs, followRedirects, maxRedirs, proxy*, cookieFile, share, extra, defaults, retry, hooks)Wraps ONE persistent curl easy handle reused across calls — connection reuse, TLS-session cache and cookie engine all live on it.
Responseobject (status, body, headers, setCookies, effectiveUrl, httpVersion, totalTime, info, error)Result of a transfer. headers keeps order + dups; setCookies are raw Set-Cookie values; non-empty error ⇒ transport failure.
ResponseInfoobject (primaryIp, primaryPort, ttfb, nameLookup, connect, redirectCount, redirectUrl)Connection/transfer metrics pulled off the handle via getinfo.
RequestConfigobject (headerOrder, removeHeaders, proxy, proxyAuth, proxyKind, noProxy, tls, resolve, connectTo, interfaceName, localPort, ipFamily, postRedir, unrestrictedAuth, autoReferer, rawLong, rawStr)Every advanced override a single request can carry. A default value inherits the session/profile — RequestConfig() changes nothing.
TlsConfigobject (cipherList, tls13Ciphers, sslVersionMin/Max, alpn, verifyPeer/Host, caInfo, caPath, clientCert/Key, clientCertType, keyPassword)Opt-in TLS overrides applied on top of the profile.
PreparedRequestobject (meth, url, body, headers)The mutable request a before-hook sees; editing fields changes the wire.
RetryPolicyobject (maxAttempts, baseDelayMs, maxDelayMs, onTransport, on429, on5xx, honorRetryAfter)Opt-in retry/backoff config; maxAttempts <= 1 ⇒ off.
ProxyKindenum pkAuto pkHttp pkHttps pkSocks4 pkSocks4a pkSocks5 pkSocks5hProxy scheme; pkAuto lets curl infer it from the URL.
IpFamilyenum ipAny ipV4 ipV6Address family; ipAny = happy-eyeballs.
Trienum triInherit triOff triOnTri-state whose default leaves the session/profile value.
DataCbproc(chunk: pointer, n: int, userdata: pointer) {.nimcall.}Per-chunk body sink for streaming download.
ReadCbproc(buf: pointer, cap: int, userdata: pointer): int {.nimcall.}Upload source: fill up to cap bytes, return count (0 ⇒ EOF).
BeforeHook / AfterHookproc(prep: ptr PreparedRequest, ud: pointer) / proc(resp: ptr Response, ud: pointer) {.nimcall.}Request/response interceptor proc types.

Sessions & requests

symbolsignaturewhat it does
newSessionproc(profile = "chrome136", proxy = "", verifyTls = true, timeoutMs = 30000, followRedirects = true, maxRedirs = 10, proxyAuth = "", cookieFile = "", share: CURLSH = …, retry = RetryPolicy()): SessionCreate an impersonating session. Unknown profile ⇒ builtins[0]. Pass a share to pool state across sessions.
closeproc(s: Session)Clean up the easy handle.
requestproc(s, meth, url, body = "", headers = @[], nobody = false, cfg = RequestConfig(), retry = RetryPolicy()): ResponsePerform a request. Runs before/after hooks, honors retry; never raises.
get / post / put / patch / deleteproc(s, url, [body,] headers = @[], cfg = RequestConfig()): ResponseConvenience verbs over request.
headproc(s, url, headers = @[], cfg = RequestConfig()): ResponseA real HEAD via OPT_NOBODY: status + headers, no body.
optionsproc(s, url, headers = @[], cfg = RequestConfig()): ResponseOPTIONS verb.
retryPolicyproc(maxAttempts = 3, baseDelayMs = 200, maxDelayMs = 20000, onTransport = true, on429 = true, on5xx = true, honorRetryAfter = true): RetryPolicyBuild an opt-in retry policy (exponential backoff, Retry-After wins).
onBeforeRequest / onAfterResponseproc(s: Session, hook, userdata = …)Register a {.nimcall.} interceptor (runs in order; may mutate).
sleepMsproc(ms: int)libc usleep wrapper used by the backoff path.

Response inspection

symbolsignaturewhat it does
okproc(r: Response): boolTrue for a 2xx status with no transport error.
headerproc(r: Response, name: string): stringCase-insensitive first-match header ("" if absent).
headerAllproc(r: Response, name: string): seq[string]Every value for name in wire order (multi-value safe).
hasHeaderproc(r: Response, name: string): boolWhether the response carried name.
headerNamesproc(r: Response): seq[string]Header names in server order (dups included).
contentTypeproc(r: Response): stringMedia type sans parameters, lowercased.

Header control

symbolsignaturewhat it does
setHeaderproc(s: Session, name, value: string)Add/replace a session-default header (case-insensitive dedup).
appendHeaderproc(s: Session, name, value: string)Append WITHOUT dedup (allows multi-value).
removeHeaderproc(s: Session, name: string)Drop a session-default header.
orderedHeadersproc(pairs: seq[(string, string)]): RequestConfigA config whose headerOrder REPLACES the computed appended set with a verbatim, ordered list — byte-exact control.
withoutHeadersproc(names: seq[string]): RequestConfigA config that strips named curl-default headers (Name: with no value).
mergedHeadersproc(s: Session, headers = @[]): seq[(string, string)]Preview the final appended set: profile.extraHeaderssession.extra → call headers, deduped.
mergeHeadersproc(profile, session, call: seq[(string, string)]): seq[(string, string)]The underlying three-way merge (last wins).

Streaming, upload & multipart

symbolsignaturewhat it does
downloadproc(s, url, onData: DataCb, userdata: pointer, headers = @[], cfg = RequestConfig()): ResponseStream the body to onData(chunk, n, ud) instead of buffering; Response has headers/status/timing but empty body.
uploadStreamproc(s, meth, url, read: ReadCb, userdata: pointer, size: int64 = -1, headers = @[], cfg = RequestConfig()): ResponseStream a body from a READFUNCTION; size = -1 ⇒ chunked.
uploadStringproc(s, meth, url, data: string, headers = @[], cfg = RequestConfig()): ResponseConvenience: stream data as the body via READFUNCTION.
fieldproc(name, value: string, contentType = ""): PartA plain multipart text field.
fileFieldproc(name, path: string, filename = "", contentType = ""): PartA file-upload field; curl streams from path, filename defaults to basename.
postMultipartproc(s, url, parts: seq[Part], headers = @[], cfg = RequestConfig()): ResponsePOST a multipart/form-data body via OPT_MIMEPOST (curl owns the boundary).
Partobject (name, filename, contentType, …)One multipart field; build with field / fileField.

Convenience helpers (util)

symbolsignaturewhat it does
basicAuthproc(user, password: string): (string, string)Authorization: Basic <base64> header tuple.
bearerproc(token: string): (string, string)Authorization: Bearer <token> header tuple.
encodeUrlproc(s: string): stringPercent-encode (RFC 3986 unreserved kept); local encoder, no std/uri.
encodeFormproc(fields: seq[(string, string)]): stringBuild an application/x-www-form-urlencoded body.
withQueryproc(url: string, params: seq[(string, string)]): stringAppend params to url as a percent-encoded query string.
postFormproc(s, url, fields, headers = @[]): ResponsePOST a urlencoded form (sets Content-Type).
postJsonproc(s, url, body: string, headers = @[]): ResponsePOST a raw JSON string (sets Content-Type: application/json).

TLS & evasion (tls)

symbolsignaturewhat it does
insecureTlsproc(): TlsConfigDisable peer + host verification (testing only).
withCAproc(caInfo = "", caPath = ""): TlsConfigTrust a custom CA bundle file and/or directory.
withClientCertproc(cert, key: string, password = "", certType = "PEM"): TlsConfigPresent a client certificate (mutual TLS).
withAlpnproc(on: bool): TlsConfigToggle ALPN explicitly.
customCiphersproc(tls12List: string, tls13List = ""): TlsConfigOverride cipher/ciphersuite lists. Rewrites the ClientHello — breaks JA3/JA4.
pinTlsVersionproc(minVer = 0, maxVer = 0): TlsConfigPin TLS min/max. Pinning MIN breaks the fingerprint.
withTlsproc(cfg: RequestConfig, tls: TlsConfig): RequestConfigAttach a TlsConfig to a RequestConfig (fluent).
tlsConfigproc(tls: TlsConfig): RequestConfigA RequestConfig carrying just this TlsConfig.
auditTlsproc(cfg: RequestConfig): seq[string]Warnings for any TLS override that would break the fingerprint. Empty ⇒ ClientHello still the profile's.

HTTP version & low-level escape hatch

symbolsignaturewhat it does
useHttpVersionproc(s: Session, version: int)Pin the negotiated HTTP version via an HTTP_VERSION_* constant.
useHttp3proc(s: Session)Prefer HTTP/3 (QUIC), falling back to h2/1.1 (curl built with ngtcp2).
useHttp3Onlyproc(s: Session)Require HTTP/3 — fail rather than fall back.
setOptionproc(s: Session, opt: CURLoption, value: clong) / (…, value: string)Set any un-wrapped CURLOPT on the handle now.
getInfoStr / getInfoLong / getInfoDoubleproc(s: Session, info: CURLcode): string / int / floatRead any curl getinfo metric off the handle.

Proxies (proxy)

symbolsignaturewhat it does
PickStrategyenum ppRoundRobin ppRandomPool rotation strategy (ppRandom uses a local xorshift).
ProxyEntryobject (url, auth, kind)A single proxy: URL + user:password + ProxyKind.
ProxyPoolref object (entries, strategy, idx, rngState)A rotating pool of proxies for a fleet.
proxyEntryproc(url: string, auth = "", kind = pkAuto): ProxyEntryBuild a proxy entry.
newProxyPoolproc(entries = @[], strategy = ppRoundRobin): ProxyPoolCreate a rotating pool.
addproc(pool: ProxyPool, url: string, auth = "", kind = pkAuto)Append a proxy to the pool.
lenproc(pool: ProxyPool): intPool size.
pickproc(pool: ProxyPool): ProxyEntryNext proxy per strategy (empty entry if pool empty).
toConfigproc(e: ProxyEntry): RequestConfigA RequestConfig selecting this proxy (per-request rotation).
setProxyproc(s: Session, e: ProxyEntry)Point a session at this proxy (session-level).
rotateproc(pool: ProxyPool, s: Session): ProxyEntryAdvance the pool and bind the chosen proxy to the session.

Cookies (session engine)

symbolsignaturewhat it does
Cookieobject (domain, includeSubdomains, path, secure, httpOnly, expires, name, value)A typed cookie over curl's in-memory engine.
cookiesproc(s: Session): seq[Cookie]Every cookie in the session jar (INFO_COOKIELIST).
cookieproc(s, name: string, domain = ""): stringValue of the first matching cookie ("" if absent).
hasCookieproc(s, name: string, domain = ""): boolWhether a matching cookie exists.
setCookieproc(s, cookie: Cookie) / proc(s, domain, name, value, path = "/", secure = false, httpOnly = false, expires = 0, includeSubdomains = false)Insert/replace a cookie (applied immediately, OPT_COOKIELIST).
clearCookiesproc(s: Session)Erase all cookies (ALL).
clearSessionCookiesproc(s: Session)Drop only session cookies (SESS).
loadCookieLinesproc(s: Session, lines: seq[string])Seed the jar from Netscape cookie-file lines.
dumpCookiesproc(s: Session): stringThe jar as Netscape cookie-file text.
parseNetscapeLine / toNetscapeLineproc(line: string): (Cookie, bool) / proc(c: Cookie): stringNetscape cookie-file line round-trip.
saveCookiesproc(s: Session, path: string): boolFlush the live jar to a Netscape cookie file now (non-raising; false if unopenable).
loadCookiesproc(s: Session, path: string): boolSeed the live jar from a cookie file now (false if unopenable).
cookieFileproc(s: Session, path: string)Bind a file-backed jar: curl reads path at request start (OPT_COOKIEFILE) and rewrites it on close (OPT_COOKIEJAR); existing cookies are loaded immediately.
symbolsignaturewhat it does
CookieJarref object (bound: seq[Session])A programmatic management layer over a session's live engine.
newCookieJarproc(): CookieJarCreate an unattached jar.
attachproc(s: Session, jar: CookieJar)Bind a jar to a session.
isAttachedproc(jar: CookieJar): boolWhether the jar is bound.
listproc(jar: CookieJar, domain = ""): seq[Cookie]Cookies (optionally domain-filtered, suffix match).
getproc(jar: CookieJar, name: string, domain = ""): CookieFirst matching cookie (empty name if absent).
setproc(jar, cookie) / proc(jar, domain, name, value, …)Insert/replace a cookie.
deleteproc(jar: CookieJar, name: string, domain = "")Remove matches (rebuilds the jar without them).
dumpText / seedTextproc(jar: CookieJar): string / proc(jar: CookieJar, text: string)Round-trip the whole jar to/from Netscape text.

Cross-session share (share)

symbolsignaturewhat it does
Shareref object (handle: CURLSH)A pool of browser-coherent state for several sessions.
newShareproc(cookies = true, dns = true, tlsSessions = true, connections = true): ShareCreate a CURLSH sharing cookies/DNS/TLS-session/connection cache (single-thread use).
newThreadSafeShareproc(cookies = true, dns = true, tlsSessions = true, connections = true): ShareLike newShare but installs CURLSHOPT_LOCKFUNC/UNLOCKFUNC (top-level {.cdecl.} callbacks backed by a per-curl_lock_data array of std/locks mutexes) so one CURLSH is safe across threads.
closeproc(sh: Share)Tear the share down (after every attached session is closed).

Concurrency (multi)

symbolsignaturewhat it does
Requestobject (meth, url, body, headers, cfg, nobody)One request in a concurrent batch.
reqproc(url: string, meth = "GET", body = "", headers = @[], cfg = RequestConfig(), nobody = false): RequestBuild a batch request.
fetchAllproc(s: Session, reqs: seq[Request], maxConcurrent = 8): seq[Response]Run reqs concurrently over curl_multi (order preserved; one failure doesn't sink the batch).
getAllproc(s: Session, urls: seq[string], maxConcurrent = 8): seq[Response]GET a list of URLs concurrently.

TLS certificate chain (certinfo)

symbolsignaturewhat it does
CertInfoobject (fields: seq[(string, string)])One certificate in the peer chain, as ordered (key, value) fields exactly as libcurl reports them.
certInfoConfigproc(base = RequestConfig()): RequestConfigA config with OPT_CERTINFO enabled — pass it to the request so certChain can read the result.
certChainproc(s: Session): seq[CertInfo]The peer certificate chain captured on the session's last request (leaf first); walks INFO_CERTINFO (struct curl_certinfo + per-cert curl_slist).
fieldproc(c: CertInfo, key: string): stringFirst field whose key matches key (case-insensitive; "" if absent).
subject / issuerproc(c: CertInfo): stringShortcuts for the Subject / Issuer fields.

Profiles (profiles)

symbolsignaturewhat it does
Engineenum eChromium eFirefox eSafariBrowser engine family.
Profileobject (name, target, engine, version, os, released, extraHeaders)An impersonation cohort as data; target is the curl-impersonate token.
builtinsconst array[7, Profile]The 7 profiles: chrome136, chrome131, chrome131_android, edge101, firefox135, safari18_4, safari18_4_ios.
findProfileproc(name: string): (bool, Profile)Look up a profile by name (found flag + value).
getproc(name: string): ProfileLook up by name; default (empty .name) on miss.
profileNamesproc(): stringComma-joined built-in names (diagnostics).
acceptEncodingproc(p: Profile): stringThe cohort's exact Accept-Encoding.
epochDayOfproc(iso: string): intParse yyyy-MM-dd to days-since-epoch (-1 on bad input).
ageDaysproc(p: Profile, asOfEpochDay: int): intDays between release and asOf.
staleproc(p: Profile, asOfEpochDay: int, maxAgeDays = 120): boolWhether the cohort is older than maxAgeDays.
freshnessNoteproc(p: Profile, asOfEpochDay: int): stringHuman-readable freshness/staleness warning.

Coherence audit (coherence)

symbolsignaturewhat it does
Warningtype = stringA single coherence finding.
auditproc(p: Profile, headers: seq[(string, string)], proxyGeoLang = ""): seq[Warning]Lint headers against a profile: dup/managed/botty headers, UA-vs-engine, platform-vs-OS, Accept-Language-vs-geo, Firefox Sec-CH-UA. Empty ⇒ coherent.
auditSessionproc(s: Session, headers = @[], proxyGeoLang = ""): seq[Warning]Audit everything a session would send against its active profile.

FFI (ffi)

The full libcurl-impersonate binding, re-exported by the umbrella. Opaque handle types CURL, CURLM, CURLSH, curl_mime, curl_mimepart; code types CURLcode/CURLMcode/CURLSHcode; option enums CURLoption/CURLMoption/CURLSHoption; and the curl_slist/CurlSlistNode/CurlCertInfo/CURLMsg structs. Constant families: OPT_*, INFO_*, PROXYTYPE_* (0/2/4/5/6/7 = http/https/socks4/5/4a/5h), AUTH_*, SSLVERSION_*, HTTP_VERSION_* (_1_0/_1_1/_2_0/_2TLS/_3/_3ONLY), LOCK_DATA_*, SHOPT_*.

symbolsignaturewhat it does
curl_easy_init / _cleanup / _reset / _performover CURLHandle lifecycle + synchronous transfer.
curl_easy_setopt / _getinfo{.varargs.} over CURLSet an option / read an info metric.
curl_easy_impersonateproc(handle: CURL, target: cstring, defaultHeaders: cint): CURLcodeInstall a browser's whole TLS+HTTP/2 fingerprint.
curl_easy_strerror / errStr / curlOkcode → string / boolError text and OK test.
curl_slist_append / _free_allover nil ptr curl_slistBuild/free a header list.
curl_mime_*init/free/addpart/name/data/filedata/filename/typeMultipart MIME construction.
curl_multi_*init/cleanup/add_handle/remove_handle/perform/poll/info_read/setoptThe concurrent transfer interface.
curl_share_*init/cleanup/setopt/strerrorCross-session shared state.
curl_global_initproc(flags: clong): CURLcodeOne-time global init (driven by the client).
cstrToStringproc(cs: cstring): stringWalk a NUL-terminated C string into a nimony string (no $(cstring) in nimony).

Design notes

  • The fingerprint is installed inside the library. A single curl_easy_impersonate(target, 1) call sets the exact TLS ordering, GREASE, key_share, ALPN/ALPS, HTTP/2 SETTINGS, pseudo-header order and default header set. Everything you layer on (headers, TLS knobs, HTTP version) risks breaking that coherence — hence auditTls and the coherence linter, which name the exact tells.
  • No exceptions. Transport failures surface as Response.error (status 0); lookups return default/empty values, with a found-flag where the distinction matters. This is the aoughwl idiom throughout the stack.
  • Callbacks are top-level {.nimcall.}/{.cdecl.} procs, not closures, always paired with an explicit userdata: pointer you cast back inside. The pointed-at object must outlive the synchronous perform — the streaming/upload/multi paths pre-size their sink seqs so element addresses stay valid.
  • Header precedence (appended set, lowest→highest): profile.extraHeaderssession.extra → per-call headers, deduped last-wins — unless orderedHeaders replaces the whole computed set with a verbatim list.
  • One handle per session (reuse = browser-like connection/TLS/cookie behavior); the concurrency path uses its own easy handles under curl_multi on a single thread — genuine I/O concurrency without OS threads or shared-handle hazards.
  • nimony gotchas, learned porting: nilable pointers/refs need an explicit nil ptr T/nil pointer qualifier (plain forms are non-nil); toCString only on var string locals; POST bodies use OPT_COPYPOSTFIELDS so curl owns the copy; ASCII helpers char-walk (strutils slice ops are .raises); no std/random (local xorshift) or std/uri (local percent-encoder).

Requirements

  • nimony toolchain (aoughwl aowl / nimony). Umbrella import requests re-exports every module: ffi, profiles, client, util, headers, tls, proxy, coherence, cookies, cookiejar, certinfo, share, multi.
  • libcurl-impersonate (the lexiforest curl-impersonate fork, built with ngtcp2 for HTTP/3) — the impersonation is not optional; the browser fingerprint lives in this C library. Vendored under vendor/curl-impersonate/lib. Link it and set an rpath at build time (nimony has no compile-time rpath block):
    nimony c -r \
      --passl:-L.../vendor/curl-impersonate/lib \
      --passl:-Wl,-rpath,.../vendor/curl-impersonate/lib \
      --path:.../requests/nimony \
      yourprog.nim
  • Origin: a nimony-native reimplementation of the Nim2 client under src/requests/, over the same libcurl-impersonate FFI. No dependency on the rest of the aoughwl net stack.

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.