Calling APIs safely

requests, auth, timeouts, retries.

Intermediate25 min · lesson 6 of 14

Calling an API is like sending a courier into an office building you don't run. You show a badge at the front desk, you wait on the building's schedule, and you trust whatever envelope comes back out. An API (application programming interface, a service other programs talk to over the network) works the same way. You send an HTTP request (Hypertext Transfer Protocol, the language browsers and servers use to pass messages) to a web address called an endpoint, and the service answers with structured data, almost always JSON (JavaScript Object Notation, text laid out as labeled key/value pairs). Security automation runs on these couriers: checking an IP address (Internet Protocol address, the number that identifies a machine on a network) against a threat feed, pulling alerts out of your EDR (endpoint detection and response, the agent watching each laptop and server), opening a ticket. Every one of those calls crosses a network you don't own, carries a secret you must not leak, and lands on a service that can stall, lie, or throttle you.

Four ways a naive call fails

A plain requests.get(url) in a SOC (security operations center, the team watching alerts around the clock) script breaks in four predictable ways. It hangs: the requests library ships with no default timeout, so a single unresponsive API can freeze your whole enrichment run until someone notices the dashboard went cold. It leaks: an API key pasted straight into source code rides along into git history and, eventually, into somebody else's hands. It storms: a while-loop that keeps hammering a failing endpoint gets your key rate-limited or banned, which blinds every other tool sharing that key. And it swallows poison: turn off certificate checking, or never look at the response body, and an attacker sitting on the network path can feed your automation invented intelligence that it will act on without blinking. A hardened client is a client with a written-down answer to each of those four.

The four failures and the line of defense for each
Hang
No default timeout
one dead API freezes the run
Fix: timeout=(connect, read)
fail in seconds, move on
Leak
Key hard-coded
lands in git history
Fix: read from env or vault
never in the source
Storm
Retry loop hammers a 503
shared key gets banned
Fix: capped retries + backoff
honor Retry-After
Poison
verify=False, no checks
attacker forges the intel
Fix: TLS on + validate fields
trust the CA, read the body
Every hardening choice in the client below maps to one of these columns.

The building blocks

Before the code, four ideas. A session is a reusable client object, like keeping one courier on staff instead of hiring a stranger for every errand. It holds TCP connections open between calls, which is called connection pooling (TCP, Transmission Control Protocol, the protocol that opens a reliable link with a handshake before any data moves). Reusing the connection skips redoing the TLS handshake on every request. TLS (Transport Layer Security, the encryption that puts the S in HTTPS, meaning HTTP carried over that encrypted channel) is the lock on the line; your client checks the server's certificate against a CA bundle (certificate authority bundle, a file listing the issuers your machine trusts), which requests ships through the certifi package. A retry policy is the rule for which failures earn a second try and how long to wait first. And the secret, your API key, comes from an environment variable, a .env file loader, or a secrets manager, so it never sits in the code itself.

A client you can trust

Here is a full client for AbuseIPDB, a public service that scores how often an IP address has been reported for abuse. Read it once, then take it apart.

client.py
import os
import sys
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
API = "https://api.abuseipdb.com/api/v2/check"
def build_session() -> requests.Session:
retry = Retry(
total=5,
backoff_factor=1, # 1st retry immediate, then ~2s, 4s, 8s, 16s (urllib3 2.x)
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"], # never auto-retry writes
respect_retry_after_header=True,
)
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=retry))
s.headers["Key"] = os.environ["ABUSEIPDB_API_KEY"] # KeyError = fail fast
s.headers["Accept"] = "application/json"
return s
def check_ip(session: requests.Session, ip: str) -> dict:
r = session.get(
API,
params={"ipAddress": ip, "maxAgeInDays": 90},
timeout=(5, 15), # (connect, read) seconds
)
r.raise_for_status() # 4xx/5xx -> exception, not silent garbage
return r.json()["data"]
if __name__ == "__main__":
data = check_ip(build_session(), sys.argv[1])
print(f"{data['ipAddress']} score={data['abuseConfidenceScore']}"
f" reports={data['totalReports']}")

The layering is the point. requests hands the actual connection work to a lower library called urllib3, and the Retry object lives down there. So it counts attempts, sleeps between them, and obeys the server's Retry-After header before your code ever sees a response. allowed_methods=["GET"] limits automatic retries to idempotent requests, ones you can safely repeat because they don't change anything on the server (asking 'what is the score for this IP' five times is harmless; opening a ticket five times is not). The timeout tuple splits connect (how long to wait for the TCP and TLS handshake to finish) from read (how long to wait for each chunk of the answer), because an API that never picks up and one that picks up then goes silent are different problems with different fixes. raise_for_status() turns HTTP error codes, the three-digit status codes like 403 Forbidden or 500 Internal Server Error, into a Python exception your caller has to handle, instead of a dict full of nonsense that flows downstream and blows up somewhere far from the cause.

Run it, then break it on purpose

~/secopslog — bash
$ python3 -m pip install "requests>=2.32" export ABUSEIPDB_API_KEY="paste-from-your-vault" # never from a source file python3 client.py 118.25.6.39
Collecting requests>=2.32 Downloading requests-2.32.3-py3-none-any.whl (64 kB) Collecting certifi>=2017.4.17 (from requests>=2.32) Downloading certifi-2025.1.31-py3-none-any.whl (166 kB) Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests Successfully installed certifi-2025.1.31 charset-normalizer-3.4.1 idna-3.10 requests-2.32.3 urllib3-2.3.0 118.25.6.39 score=100 reports=214

That is the happy path: one call, one score, verification on, timeout armed. Now prove the timeout, the longest you are willing to wait, actually protects you. Call an endpoint that stalls for ten seconds while you allow only three.

~/secopslog — bash
$ python3 -c "import requests; requests.get('https://httpbin.org/delay/10', timeout=3)"
Traceback (most recent call last): ... requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='httpbin.org', port=443): Read timed out. (read timeout=3)

Three seconds, a clean exception, and the run steps to the next indicator instead of hanging until a human notices. The retry policy covers the opposite case, a fast failure that might clear on its own, using exponential backoff (each wait roughly double the one before). With backoff_factor=1 the first retry fires immediately, then the waits stretch to about 2, 4, 8, and 16 seconds, so five transient 503 Service Unavailable errors cost tens of seconds of patience rather than five instant hammer-blows. When the server answers a 429 Too Many Requests with a header that reads Retry-After: 30, urllib3 sleeps for that exact thirty seconds in place of its own backoff, which is the difference between cooperating with a rate limit and picking a fight with it.

The verify=False trap

When a corporate TLS-inspecting proxy breaks certificate checks, the top search result tells you to pass verify=False. Watch what that actually does against a server whose certificate your machine does not trust.

~/secopslog — bash
$ python3 -c "import requests; print(requests.get('https://self-signed.badssl.com/', verify=False).status_code)"
.../urllib3/connectionpool.py:1099: InsecureRequestWarning: Unverified HTTPS request is being made to host 'self-signed.badssl.com'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings warnings.warn( 200
That 200 is a trap, not a success
verify=False turns off server authentication. The request 'worked' against a self-signed certificate, which means anyone on the network path can impersonate the real API, capture the key you send in the header, and hand your automation forged results that look completely valid. The correct fix keeps verification on: trust your proxy's certificate authority explicitly with export REQUESTS_CA_BUNDLE=/etc/ssl/corp-proxy-ca.pem. Then hunt down every leftover 'temporary' bypass across your fleet, because that single toggle is the most common way security automation gets fed lies.
~/secopslog — bash
$ grep -rn "verify=False" --include=*.py .
./enrich/virustotal.py:42: r = session.get(url, verify=False) # TODO remove ./collectors/misp_sync.py:88: resp = requests.post(api, json=body, verify=False)

What breaks at scale

A rate limit is the server's cap on how many requests your key may make in a window, a minute or a day; cross it and you get 429 Too Many Requests. At small scale you never meet one. At scale, enriching ten thousand IP addresses from last night's alerts, you hit it in the first minute, and if five scripts share one org-wide key, a single greedy script starves the rest. Budget the calls: batch when the API supports it, cache answers locally so you never ask the same question twice, and read a 429 as 'slow down', not 'retry harder'. Pagination (an API handing back a large result set one page at a time, using a cursor or a page number) needs a hard cap on loop iterations, because a server bug that returns the same cursor forever turns your poller into an infinite loop that eats the same page all night. And never log request headers at DEBUG level. That is exactly how API keys leak into your own log pipeline, sitting in plaintext for everyone with SIEM (security information and event management, the platform your logs get shipped to) access to read.

Where this design stops

Know the edges. urllib3's Retry is deliberately blunt about calls that change state: if a POST (an HTTP request that creates or updates something on the server) that opens a ticket times out, you cannot tell whether the ticket was created, so an automatic retry risks a duplicate. Handle those by hand, or send an idempotency key when the API offers one (a unique token the server uses to recognize and drop a repeat). For async work or HTTP/2 (the newer version of HTTP that runs many requests over one connection at once), reach for httpx, a modern client with the same ideas: explicit timeouts, a Client object, verification on by default. And treat every response as untrusted input. r.json() proves the body parsed, not that it holds the shape you expect. Check the fields you depend on, because a loud KeyError at the call site beats a None quietly riding into a blocking decision, and reach for pydantic (a library that checks data against a declared schema and rejects what does not fit) once the structure gets complicated.

Quick check
01Your client sets allowed_methods=["GET"], so a POST that creates an incident ticket is never retried automatically. The POST is sent, then times out on the read. Why is not retrying the safe default here?
Incorrect — speed is not the concern. The risk is a duplicate side effect, not a slow call.
Correct — a read timeout tells you nothing landed back, not that nothing happened on the server, so a repeat risks a duplicate.
Incorrect — it can retry POST; you deliberately excluded it via allowed_methods precisely because it is not idempotent.
Incorrect — body format has nothing to do with retry safety, and raise_for_status reads the status code, not the body.
02The client passes timeout=(5, 15) to session.get(). What does the second number, 15, control?
Incorrect — the tuple does not cap total request time, and retry spacing is governed separately by the Retry object.
Incorrect — that is the first value (the connect timeout, here 5 seconds), not the second.
Correct — the read timeout bounds the wait for each piece of the answer, catching a server that connects and then goes silent.
Incorrect — retry spacing comes from backoff_factor in the Retry object, not from the timeout tuple.
03Your client hits an endpoint that answers 429 Too Many Requests with a header Retry-After: 30. Given status_forcelist=[429, 500, 502, 503, 504] and respect_retry_after_header=True, what does it do?
Incorrect — with respect_retry_after_header=True, urllib3 uses the server's Retry-After value in place of its own backoff.
Incorrect — 429 is listed in status_forcelist, so it is explicitly one of the statuses this client retries.
Incorrect — that back-to-back hammering is exactly what honoring Retry-After is meant to prevent.
Correct — respect_retry_after_header=True makes urllib3 wait the server's stated 30 seconds instead of its own backoff, which cooperates with the rate limit.

One enriched IP is a lookup. Ten thousand of them, pulled from a single night of alerts, is a data-processing job with its own failure shapes: memory that balloons, JSON that arrives as a gigabyte stream, a parser that chokes on one malformed line and takes the batch down with it. That is the next lesson, where the data you now know how to fetch safely meets the tools that chew through it without running the box out of memory. Before you scale anything up, run that grep for verify=False across every repository that touches an API key, and turn the ones you find back on.

Related