[{"data":1,"prerenderedAt":1066},["ShallowReactive",2],{"page-\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Frecording-and-replaying-http-with-vcrpy\u002F":3},{"id":4,"title":5,"body":6,"description":1030,"extension":1031,"meta":1032,"navigation":77,"path":1062,"seo":1063,"stem":1064,"__hash__":1065},"content\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Frecording-and-replaying-http-with-vcrpy\u002Findex.md","Recording and Replaying HTTP with VCR.py",{"type":7,"value":8,"toc":1019},"minimark",[9,18,23,41,45,48,192,230,233,255,258,391,395,414,517,521,524,535,538,630,633,637,640,650,656,666,686,695,699,758,762,765,801,804,847,850,925,929,935,950,964,973,977,1009,1015],[10,11,12,13,17],"p",{},"Hand-written HTTP stubs encode what you ",[14,15,16],"em",{},"believe"," an API returns. For an API you own, that is fine. For a third-party payment provider whose response contains forty fields, three of which your parser depends on and one of which changed last month, it is a slow-motion outage: the stub keeps returning the old shape and the tests keep passing. VCR.py records the real exchange once, stores it as a cassette, and replays it — so the fixture is a captured fact rather than a remembered one.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,38],"ul",{},[27,28,29,33,34,37],"li",{},[30,31,32],"strong",{},"vcrpy 5.1+",", and ",[30,35,36],{},"pytest-recording 0.13+"," if you want the pytest integration used below.",[27,39,40],{},"A real credential for the first recording, and a redaction policy before you use it.",[19,42,44],{"id":43},"solution","Solution",[10,46,47],{},"Configure redaction first, record second. Doing it in the other order puts a live token in your git history.",[49,50,55],"pre",{"className":51,"code":52,"language":53,"meta":54,"style":54},"language-python shiki shiki-themes github-light github-dark","# conftest.py\nimport pytest\n\n@pytest.fixture(scope=\"module\")\ndef vcr_config():\n    return {\n        \"filter_headers\": [(\"authorization\", \"REDACTED\"),\n                           (\"x-api-key\", \"REDACTED\"),\n                           (\"set-cookie\", \"REDACTED\")],\n        \"filter_query_parameters\": [(\"api_key\", \"REDACTED\"), (\"sig\", \"REDACTED\")],\n        \"filter_post_data_parameters\": [(\"client_secret\", \"REDACTED\")],\n        # Redact anything sensitive inside the body before it is written.\n        \"before_record_response\": _scrub_body,\n        \"match_on\": [\"method\", \"scheme\", \"host\", \"port\", \"path\", \"query\"],\n        \"record_mode\": \"none\",              # CI default; overridden locally\n    }\n\ndef _scrub_body(response):\n    body = response[\"body\"][\"string\"]\n    if b\"account_number\" in body:\n        response[\"body\"][\"string\"] = b'{\"account_number\": \"REDACTED\"}'\n    return response\n","python","",[56,57,58,66,72,79,85,91,97,103,109,115,121,127,133,139,145,151,157,162,168,174,180,186],"code",{"__ignoreMap":54},[59,60,63],"span",{"class":61,"line":62},"line",1,[59,64,65],{},"# conftest.py\n",[59,67,69],{"class":61,"line":68},2,[59,70,71],{},"import pytest\n",[59,73,75],{"class":61,"line":74},3,[59,76,78],{"emptyLinePlaceholder":77},true,"\n",[59,80,82],{"class":61,"line":81},4,[59,83,84],{},"@pytest.fixture(scope=\"module\")\n",[59,86,88],{"class":61,"line":87},5,[59,89,90],{},"def vcr_config():\n",[59,92,94],{"class":61,"line":93},6,[59,95,96],{},"    return {\n",[59,98,100],{"class":61,"line":99},7,[59,101,102],{},"        \"filter_headers\": [(\"authorization\", \"REDACTED\"),\n",[59,104,106],{"class":61,"line":105},8,[59,107,108],{},"                           (\"x-api-key\", \"REDACTED\"),\n",[59,110,112],{"class":61,"line":111},9,[59,113,114],{},"                           (\"set-cookie\", \"REDACTED\")],\n",[59,116,118],{"class":61,"line":117},10,[59,119,120],{},"        \"filter_query_parameters\": [(\"api_key\", \"REDACTED\"), (\"sig\", \"REDACTED\")],\n",[59,122,124],{"class":61,"line":123},11,[59,125,126],{},"        \"filter_post_data_parameters\": [(\"client_secret\", \"REDACTED\")],\n",[59,128,130],{"class":61,"line":129},12,[59,131,132],{},"        # Redact anything sensitive inside the body before it is written.\n",[59,134,136],{"class":61,"line":135},13,[59,137,138],{},"        \"before_record_response\": _scrub_body,\n",[59,140,142],{"class":61,"line":141},14,[59,143,144],{},"        \"match_on\": [\"method\", \"scheme\", \"host\", \"port\", \"path\", \"query\"],\n",[59,146,148],{"class":61,"line":147},15,[59,149,150],{},"        \"record_mode\": \"none\",              # CI default; overridden locally\n",[59,152,154],{"class":61,"line":153},16,[59,155,156],{},"    }\n",[59,158,160],{"class":61,"line":159},17,[59,161,78],{"emptyLinePlaceholder":77},[59,163,165],{"class":61,"line":164},18,[59,166,167],{},"def _scrub_body(response):\n",[59,169,171],{"class":61,"line":170},19,[59,172,173],{},"    body = response[\"body\"][\"string\"]\n",[59,175,177],{"class":61,"line":176},20,[59,178,179],{},"    if b\"account_number\" in body:\n",[59,181,183],{"class":61,"line":182},21,[59,184,185],{},"        response[\"body\"][\"string\"] = b'{\"account_number\": \"REDACTED\"}'\n",[59,187,189],{"class":61,"line":188},22,[59,190,191],{},"    return response\n",[49,193,195],{"className":51,"code":194,"language":53,"meta":54,"style":54},"import pytest\n\n@pytest.mark.vcr                            # cassette name derives from the test id\ndef test_charge_succeeds(payment_client):\n    result = payment_client.charge(amount=500, currency=\"eur\")\n    assert result.status == \"succeeded\"\n    assert result.id.startswith(\"ch_\")\n",[56,196,197,201,205,210,215,220,225],{"__ignoreMap":54},[59,198,199],{"class":61,"line":62},[59,200,71],{},[59,202,203],{"class":61,"line":68},[59,204,78],{"emptyLinePlaceholder":77},[59,206,207],{"class":61,"line":74},[59,208,209],{},"@pytest.mark.vcr                            # cassette name derives from the test id\n",[59,211,212],{"class":61,"line":81},[59,213,214],{},"def test_charge_succeeds(payment_client):\n",[59,216,217],{"class":61,"line":87},[59,218,219],{},"    result = payment_client.charge(amount=500, currency=\"eur\")\n",[59,221,222],{"class":61,"line":93},[59,223,224],{},"    assert result.status == \"succeeded\"\n",[59,226,227],{"class":61,"line":99},[59,228,229],{},"    assert result.id.startswith(\"ch_\")\n",[10,231,232],{},"Record once, locally, with a mode that allows writing:",[49,234,238],{"className":235,"code":236,"language":237,"meta":54,"style":54},"language-console shiki shiki-themes github-light github-dark","$ pytest tests\u002Ftest_payments.py --record-mode=once      # writes the cassette\n$ git diff --stat cassettes\u002F                            # REVIEW before committing\n cassettes\u002Ftest_charge_succeeds.yaml | 48 ++++++++++++\n","console",[56,239,240,245,250],{"__ignoreMap":54},[59,241,242],{"class":61,"line":62},[59,243,244],{},"$ pytest tests\u002Ftest_payments.py --record-mode=once      # writes the cassette\n",[59,246,247],{"class":61,"line":68},[59,248,249],{},"$ git diff --stat cassettes\u002F                            # REVIEW before committing\n",[59,251,252],{"class":61,"line":74},[59,253,254],{}," cassettes\u002Ftest_charge_succeeds.yaml | 48 ++++++++++++\n",[10,256,257],{},"Reviewing that diff is not optional. It is the moment to confirm the redaction worked, that no pagination token or customer name slipped through, and that the cassette contains the request you intended rather than three retries of a failed one.",[259,260,263,387],"figure",{"className":261},[262],"diagram",[264,265,272,273,272,277,272,281,272,299,272,307,272,316,272,325,272,331,272,336,272,342,272,345,272,350,272,353,272,357,272,360,272,364,272,367,272,371,272,374,272,378,272,381],"svg",{"viewBox":266,"role":267,"ariaLabelledBy":268,"xmlns":271},"0 0 760 172","img",[269,270],"vcrcycle-t","vcrcycle-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[274,275,276],"title",{"id":269},"The record-once, replay-forever cycle",[278,279,280],"desc",{"id":270},"A left-to-right cycle: configure redaction, record against the real API once, review and commit the cassette, and replay it in CI with recording disabled, with a scheduled refresh feeding back into the recording step.",[282,283,284,285,272],"defs",{},"\n    ",[286,287,294],"marker",{"id":288,"viewBox":289,"refX":290,"refY":291,"markerWidth":292,"markerHeight":292,"orient":293},"vcrcycle-a","0 0 10 10","9","5","7","auto-start-reverse",[295,296],"path",{"d":297,"fill":298},"M0 0 L10 5 L0 10 z","#81b29a",[300,301],"rect",{"x":302,"y":302,"width":303,"height":304,"rx":305,"fill":306},"0","760","172","14","#fffdf8",[308,309,276],"text",{"x":310,"y":311,"textAnchor":312,"fontSize":313,"fontWeight":314,"fill":315},"380","30","middle","15","700","#3d405b",[300,317],{"x":318,"y":319,"width":320,"height":321,"rx":322,"fill":323,"stroke":298,"strokeWidth":324},"26","62","142","76","12","#f4f1de","1.8",[308,326,330],{"x":327,"y":328,"textAnchor":312,"fontSize":329,"fontWeight":314,"fill":315},"97","96","12.0","configure redaction",[308,332,335],{"x":327,"y":333,"textAnchor":312,"fontSize":334,"fill":315},"113","11","before any traffic",[61,337],{"x1":338,"y1":339,"x2":340,"y2":339,"stroke":298,"strokeWidth":324,"markerEnd":341},"176","100","208","url(#vcrcycle-a)",[300,343],{"x":344,"y":319,"width":320,"height":321,"rx":322,"fill":306,"stroke":298,"strokeWidth":324},"214",[308,346,349],{"x":347,"y":328,"textAnchor":312,"fontSize":348,"fontWeight":314,"fill":315},"286","12.5","record once",[308,351,352],{"x":347,"y":333,"textAnchor":312,"fontSize":334,"fill":315},"against the real API",[61,354],{"x1":355,"y1":339,"x2":356,"y2":339,"stroke":298,"strokeWidth":324,"markerEnd":341},"364","396",[300,358],{"x":359,"y":319,"width":320,"height":321,"rx":322,"fill":323,"stroke":298,"strokeWidth":324},"403",[308,361,363],{"x":362,"y":328,"textAnchor":312,"fontSize":348,"fontWeight":314,"fill":315},"474","review and commit",[308,365,366],{"x":362,"y":333,"textAnchor":312,"fontSize":334,"fill":315},"the diff is the audit",[61,368],{"x1":369,"y1":339,"x2":370,"y2":339,"stroke":298,"strokeWidth":324,"markerEnd":341},"552","584",[300,372],{"x":373,"y":319,"width":320,"height":321,"rx":322,"fill":306,"stroke":298,"strokeWidth":324},"592",[308,375,377],{"x":376,"y":328,"textAnchor":312,"fontSize":348,"fontWeight":314,"fill":315},"663","replay in CI",[308,379,380],{"x":376,"y":333,"textAnchor":312,"fontSize":334,"fill":315},"record mode none",[308,382,386],{"x":310,"y":383,"textAnchor":312,"fontSize":384,"fontStyle":385,"fill":315},"164","11.5","italic","A scheduled re-record feeds back into step two and surfaces upstream changes.",[388,389,390],"figcaption",{},"The review step is the security control; everything after it is deterministic replay with no credentials involved.",[19,392,394],{"id":393},"why-this-works","Why this works",[10,396,397,398,401,402,405,406,409,410,413],{},"VCR.py patches the HTTP libraries below your client — ",[56,399,400],{},"urllib3","'s connection pool for ",[56,403,404],{},"requests",", ",[56,407,408],{},"httpcore"," for ",[56,411,412],{},"httpx"," — so it sees fully formed requests and complete responses, including status, headers and body. On a cassette hit it constructs the recorded response and hands it back through the same path, which means your client code parses genuine bytes captured from the real service. On a miss the record mode decides: record it, raise, or let it through. That interception depth is why a cassette exercises more of the stack than a hand-written stub, and also why a cassette is coupled to the client library it was recorded with.",[259,415,417,514],{"className":416},[262],[264,418,272,423,272,426,272,429,272,432,272,434,272,443,272,447,272,451,272,455,272,459,272,463,272,466,272,469,272,472,272,476,272,479,272,482,272,484,272,488,272,491,272,494,272,497,272,501,272,504,272,507,272,511],{"viewBox":419,"role":267,"ariaLabelledBy":420,"xmlns":271},"0 0 760 270",[421,422],"vcrmodes-t","vcrmodes-d",[274,424,425],{"id":421},"The four record modes and where each belongs",[278,427,428],{"id":422},"A table of the four VCR record modes - once, new_episodes, none and all - describing the behaviour of each on a cassette hit and miss, and the environment each belongs in.",[300,430],{"x":302,"y":302,"width":303,"height":431,"rx":305,"fill":306},"270",[308,433,425],{"x":310,"y":311,"textAnchor":312,"fontSize":313,"fontWeight":314,"fill":315},[300,435],{"x":436,"y":437,"width":438,"height":439,"rx":440,"fill":441,"stroke":315,"strokeWidth":442},"24","52","712","40","10","#f2cc8f","1.5",[308,444,446],{"x":445,"y":321,"fontSize":322,"fontWeight":314,"fill":315},"38","Criterion",[308,448,450],{"x":449,"y":321,"textAnchor":312,"fontSize":322,"fontWeight":314,"fill":315},"390","On a miss",[308,452,454],{"x":453,"y":321,"textAnchor":312,"fontSize":322,"fontWeight":314,"fill":315},"620","Belongs in",[300,456],{"x":436,"y":457,"width":438,"height":439,"fill":323,"stroke":315,"strokeWidth":458},"92","1",[308,460,462],{"x":445,"y":461,"fontSize":384,"fill":315},"116","once",[308,464,465],{"x":449,"y":461,"textAnchor":312,"fontSize":384,"fill":315},"records if no cassette",[308,467,468],{"x":453,"y":461,"textAnchor":312,"fontSize":384,"fill":315},"first local recording",[300,470],{"x":436,"y":471,"width":438,"height":439,"fill":306,"stroke":315,"strokeWidth":458},"132",[308,473,475],{"x":445,"y":474,"fontSize":384,"fill":315},"156","new_episodes",[308,477,478],{"x":449,"y":474,"textAnchor":312,"fontSize":384,"fill":315},"appends new interactions",[308,480,481],{"x":453,"y":474,"textAnchor":312,"fontSize":384,"fill":315},"extending coverage",[300,483],{"x":436,"y":304,"width":438,"height":439,"fill":323,"stroke":315,"strokeWidth":458},[308,485,487],{"x":445,"y":486,"fontSize":384,"fill":315},"196","none",[308,489,490],{"x":449,"y":486,"textAnchor":312,"fontSize":384,"fill":315},"raises CannotOverwrite",[308,492,493],{"x":453,"y":486,"textAnchor":312,"fontSize":384,"fill":315},"CI, always",[300,495],{"x":436,"y":496,"width":438,"height":439,"fill":306,"stroke":315,"strokeWidth":458},"212",[308,498,500],{"x":445,"y":499,"fontSize":384,"fill":315},"236","all",[308,502,503],{"x":449,"y":499,"textAnchor":312,"fontSize":384,"fill":315},"re-records everything",[308,505,506],{"x":453,"y":499,"textAnchor":312,"fontSize":384,"fill":315},"scheduled refresh",[61,508],{"x1":509,"y1":437,"x2":509,"y2":510,"stroke":315,"strokeWidth":458},"274","252",[61,512],{"x1":513,"y1":437,"x2":513,"y2":510,"stroke":315,"strokeWidth":458},"505",[388,515,516],{},"CI must use none: any other mode allows a test to reach the network, which turns a deterministic suite back into an integration suite.",[19,518,520],{"id":519},"matching-rules-decide-what-a-cassette-proves","Matching rules decide what a cassette proves",[10,522,523],{},"The default matcher pairs a request with a recorded interaction on method and URI. That is often too loose and occasionally too strict, and both failure modes are worth understanding.",[10,525,526,527,530,531,534],{},"Too loose: two POSTs to the same endpoint with different bodies match the same interaction, so a test that sends the wrong payload replays the right response and passes. Adding ",[56,528,529],{},"body"," to ",[56,532,533],{},"match_on"," fixes it, at the cost of brittleness when the body contains a timestamp or a generated id.",[10,536,537],{},"Too strict: a request whose query parameters are ordered differently, or which carries a nonce, never matches its own recording. The answer is a custom matcher that normalises the volatile part rather than loosening the whole rule:",[49,539,541],{"className":51,"code":540,"language":53,"meta":54,"style":54},"import json\n\ndef match_on_json_body(r1, r2):\n    # Compare decoded bodies so key ordering and whitespace do not matter,\n    # and ignore the client-generated request id.\n    def norm(request):\n        if not request.body:\n            return None\n        data = json.loads(request.body)\n        data.pop(\"request_id\", None)\n        return data\n    return norm(r1) == norm(r2)\n\n@pytest.fixture(scope=\"module\")\ndef vcr(vcr):\n    vcr.register_matcher(\"json_body\", match_on_json_body)\n    vcr.match_on = [\"method\", \"path\", \"json_body\"]\n    return vcr\n",[56,542,543,548,552,557,562,567,572,577,582,587,592,597,602,606,610,615,620,625],{"__ignoreMap":54},[59,544,545],{"class":61,"line":62},[59,546,547],{},"import json\n",[59,549,550],{"class":61,"line":68},[59,551,78],{"emptyLinePlaceholder":77},[59,553,554],{"class":61,"line":74},[59,555,556],{},"def match_on_json_body(r1, r2):\n",[59,558,559],{"class":61,"line":81},[59,560,561],{},"    # Compare decoded bodies so key ordering and whitespace do not matter,\n",[59,563,564],{"class":61,"line":87},[59,565,566],{},"    # and ignore the client-generated request id.\n",[59,568,569],{"class":61,"line":93},[59,570,571],{},"    def norm(request):\n",[59,573,574],{"class":61,"line":99},[59,575,576],{},"        if not request.body:\n",[59,578,579],{"class":61,"line":105},[59,580,581],{},"            return None\n",[59,583,584],{"class":61,"line":111},[59,585,586],{},"        data = json.loads(request.body)\n",[59,588,589],{"class":61,"line":117},[59,590,591],{},"        data.pop(\"request_id\", None)\n",[59,593,594],{"class":61,"line":123},[59,595,596],{},"        return data\n",[59,598,599],{"class":61,"line":129},[59,600,601],{},"    return norm(r1) == norm(r2)\n",[59,603,604],{"class":61,"line":135},[59,605,78],{"emptyLinePlaceholder":77},[59,607,608],{"class":61,"line":141},[59,609,84],{},[59,611,612],{"class":61,"line":147},[59,613,614],{},"def vcr(vcr):\n",[59,616,617],{"class":61,"line":153},[59,618,619],{},"    vcr.register_matcher(\"json_body\", match_on_json_body)\n",[59,621,622],{"class":61,"line":159},[59,623,624],{},"    vcr.match_on = [\"method\", \"path\", \"json_body\"]\n",[59,626,627],{"class":61,"line":164},[59,628,629],{},"    return vcr\n",[10,631,632],{},"The general rule: match on everything the API's behaviour depends on, and normalise everything your client generates. A cassette that matches on a nonce is a cassette that has to be re-recorded on every run.",[19,634,636],{"id":635},"keeping-cassettes-from-rotting","Keeping cassettes from rotting",[10,638,639],{},"A cassette is a snapshot, and every snapshot ages. Three practices keep the ageing visible rather than silent.",[10,641,642,645,646,649],{},[30,643,644],{},"Re-record on a schedule."," A nightly or weekly job that runs the suite with ",[56,647,648],{},"--record-mode=all"," against a sandbox account, then diffs the cassettes, converts an upstream API change from a production surprise into a pull request. The diff is the alert; the tests passing on old cassettes proves nothing about today's API.",[10,651,652,655],{},[30,653,654],{},"Keep cassettes small and named after the behaviour."," One cassette per test, containing one or two interactions, stays reviewable. A single cassette shared by twelve tests becomes an unreadable file that nobody re-records because the diff is impossible to check.",[10,657,658,661,662,665],{},[30,659,660],{},"Delete cassettes with the tests they serve."," Orphaned cassettes accumulate silently; a short CI check that every ",[56,663,664],{},".yaml"," in the cassette directory has a corresponding test keeps the directory honest.",[49,667,669],{"className":235,"code":668,"language":237,"meta":54,"style":54},"$ pytest --collect-only -q | sed 's\u002F.*::\u002F\u002F; s\u002F\\[.*\u002F\u002F' | sort -u > \u002Ftmp\u002Ftests.txt\n$ ls cassettes | sed 's\u002F\\.yaml$\u002F\u002F' | sort -u > \u002Ftmp\u002Fcassettes.txt\n$ comm -13 \u002Ftmp\u002Ftests.txt \u002Ftmp\u002Fcassettes.txt      # cassettes with no test\n",[56,670,671,676,681],{"__ignoreMap":54},[59,672,673],{"class":61,"line":62},[59,674,675],{},"$ pytest --collect-only -q | sed 's\u002F.*::\u002F\u002F; s\u002F\\[.*\u002F\u002F' | sort -u > \u002Ftmp\u002Ftests.txt\n",[59,677,678],{"class":61,"line":68},[59,679,680],{},"$ ls cassettes | sed 's\u002F\\.yaml$\u002F\u002F' | sort -u > \u002Ftmp\u002Fcassettes.txt\n",[59,682,683],{"class":61,"line":74},[59,684,685],{},"$ comm -13 \u002Ftmp\u002Ftests.txt \u002Ftmp\u002Fcassettes.txt      # cassettes with no test\n",[10,687,688,689,694],{},"Where cassettes prove too heavy for a fast-moving API, the alternative is a contract test against the provider's schema plus hand-written stubs, or a transport-level mock as described in ",[690,691,693],"a",{"href":692},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fmocking-httpx-clients-with-respx\u002F","mocking httpx clients with respx",". Cassettes are best where the payload is complex and stable; stubs are best where it is simple or changes constantly.",[19,696,698],{"id":697},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,700,701,719,725,731,741,752],{},[27,702,703,710,711,714,715,718],{},[30,704,705,706,709],{},"Secrets in the request body are not covered by ",[56,707,708],{},"filter_headers","."," Use ",[56,712,713],{},"filter_post_data_parameters"," and a ",[56,716,717],{},"before_record_request"," hook; a token in a form field is the most common leak.",[27,720,721,724],{},[30,722,723],{},"Binary and gzipped bodies are stored base64-encoded",", which makes the diff unreviewable. Disable compression on the recording run when the body needs to be human-checkable.",[27,726,727,730],{},[30,728,729],{},"A cassette records timing, not behaviour."," Replay is instantaneous, so a test that depends on a timeout firing will never see it; test timeouts with an explicit exception side effect instead.",[27,732,733,736,737,740],{},[30,734,735],{},"Recorded pagination is fixed."," A test that follows ",[56,738,739],{},"next"," links replays exactly the pages captured, so a change in page size upstream is invisible until the next re-record.",[27,742,743,746,747,530,749,751],{},[30,744,745],{},"Client library changes break cassettes."," Switching from ",[56,748,404],{},[56,750,412],{}," changes the interception layer and can change how headers are recorded; re-record after such a migration rather than adapting the matcher.",[27,753,754,757],{},[30,755,756],{},"Concurrency is not recorded faithfully."," Parallel requests replay in matched order, so a race the real API exhibits will not reproduce.",[19,759,761],{"id":760},"reviewing-a-cassette-like-a-security-artefact","Reviewing a cassette like a security artefact",[10,763,764],{},"A cassette is a file containing real traffic to a real service, committed to a repository that may be public or shared with contractors. Treat the first review of each one as a security review, not a code review.",[10,766,767,768,771,772,405,775,405,778,405,781,784,785,788,789,792,793,796,797,800],{},"Four things to check, in order. ",[30,769,770],{},"Headers",": ",[56,773,774],{},"Authorization",[56,776,777],{},"Cookie",[56,779,780],{},"Set-Cookie",[56,782,783],{},"X-Api-Key"," and any vendor-specific auth header must show the redaction placeholder rather than a value. ",[30,786,787],{},"Query strings",": signed URLs put credentials in the path, and ",[56,790,791],{},"filter_query_parameters"," only redacts the parameters you named. ",[30,794,795],{},"Response bodies",": account numbers, email addresses, customer names and internal identifiers all arrive in payloads and none are redacted by default. ",[30,798,799],{},"Request bodies",": form posts and JSON payloads carry client secrets and personal data on the way out.",[10,802,803],{},"Automating the check is better than remembering it. A short test that scans every cassette for high-entropy strings and known key prefixes runs in milliseconds and fails the build when a new recording leaks:",[49,805,807],{"className":51,"code":806,"language":53,"meta":54,"style":54},"import pathlib, re\n\nSUSPECT = re.compile(rb\"(sk_live_|AKIA|-----BEGIN|Bearer [A-Za-z0-9._-]{20,})\")\n\ndef test_no_secrets_in_cassettes():\n    for path in pathlib.Path(\"cassettes\").rglob(\"*.yaml\"):\n        body = path.read_bytes()\n        assert not SUSPECT.search(body), f\"possible secret in {path}\"\n",[56,808,809,814,818,823,827,832,837,842],{"__ignoreMap":54},[59,810,811],{"class":61,"line":62},[59,812,813],{},"import pathlib, re\n",[59,815,816],{"class":61,"line":68},[59,817,78],{"emptyLinePlaceholder":77},[59,819,820],{"class":61,"line":74},[59,821,822],{},"SUSPECT = re.compile(rb\"(sk_live_|AKIA|-----BEGIN|Bearer [A-Za-z0-9._-]{20,})\")\n",[59,824,825],{"class":61,"line":81},[59,826,78],{"emptyLinePlaceholder":77},[59,828,829],{"class":61,"line":87},[59,830,831],{},"def test_no_secrets_in_cassettes():\n",[59,833,834],{"class":61,"line":93},[59,835,836],{},"    for path in pathlib.Path(\"cassettes\").rglob(\"*.yaml\"):\n",[59,838,839],{"class":61,"line":99},[59,840,841],{},"        body = path.read_bytes()\n",[59,843,844],{"class":61,"line":105},[59,845,846],{},"        assert not SUSPECT.search(body), f\"possible secret in {path}\"\n",[10,848,849],{},"Add the vendor prefixes your providers use. The check cannot prove a cassette is clean, but it catches the common cases the moment they are committed, which is the only time removing them is cheap.",[259,851,853,922],{"className":852},[262],[264,854,272,859,272,862,272,865,272,868,272,870,272,874,272,878,272,882,272,887,272,890,272,892,272,896,272,899,272,902,272,904,272,907,272,910,272,913,272,915,272,919],{"viewBox":855,"role":267,"ariaLabelledBy":856,"xmlns":271},"0 0 760 320",[857,858],"cassettereview-t","cassettereview-d",[274,860,861],{"id":857},"Four places a secret hides in a cassette",[278,863,864],{"id":858},"A stack of four locations to check when reviewing a recorded cassette: request headers, query strings, request bodies and response bodies, with what typically leaks in each.",[300,866],{"x":302,"y":302,"width":303,"height":867,"rx":305,"fill":306},"320",[308,869,861],{"x":310,"y":311,"textAnchor":312,"fontSize":313,"fontWeight":314,"fill":315},[300,871],{"x":311,"y":872,"width":314,"height":437,"rx":440,"fill":323,"stroke":873,"strokeWidth":324},"56","#e07a5f",[300,875],{"x":311,"y":872,"width":876,"height":437,"rx":877,"fill":873},"8","4",[308,879,881],{"x":437,"y":880,"fontSize":348,"fontWeight":314,"fill":315},"86","request headers",[308,883,886],{"x":884,"y":880,"textAnchor":885,"fontSize":334,"fill":315},"714","end","Authorization, Cookie, vendor API keys",[300,888],{"x":311,"y":889,"width":314,"height":437,"rx":440,"fill":306,"stroke":441,"strokeWidth":324},"120",[300,891],{"x":311,"y":889,"width":876,"height":437,"rx":877,"fill":441},[308,893,895],{"x":437,"y":894,"fontSize":348,"fontWeight":314,"fill":315},"150","query strings",[308,897,898],{"x":884,"y":894,"textAnchor":885,"fontSize":334,"fill":315},"signed URLs and unlisted parameters",[300,900],{"x":311,"y":901,"width":314,"height":437,"rx":440,"fill":323,"stroke":298,"strokeWidth":324},"184",[300,903],{"x":311,"y":901,"width":876,"height":437,"rx":877,"fill":298},[308,905,906],{"x":437,"y":344,"fontSize":348,"fontWeight":314,"fill":315},"request bodies",[308,908,909],{"x":884,"y":344,"textAnchor":885,"fontSize":334,"fill":315},"client secrets in form posts and JSON",[300,911],{"x":311,"y":912,"width":314,"height":437,"rx":440,"fill":306,"stroke":315,"strokeWidth":324},"248",[300,914],{"x":311,"y":912,"width":876,"height":437,"rx":877,"fill":315},[308,916,918],{"x":437,"y":917,"fontSize":348,"fontWeight":314,"fill":315},"278","response bodies",[308,920,921],{"x":884,"y":917,"textAnchor":885,"fontSize":334,"fill":315},"account numbers, emails, internal ids",[388,923,924],{},"Default redaction covers the first location only; the other three need explicit configuration and a review.",[19,926,928],{"id":927},"frequently-asked-questions","Frequently Asked Questions",[10,930,931,934],{},[30,932,933],{},"When is a cassette better than a hand-written stub?","\nWhen the response body is large, structured and not authored by you — a third-party API's payload. A cassette captures the real shape once, including fields you did not know existed, where a hand-written stub captures only what you remembered.",[10,936,937,940,941,405,943,945,946,949],{},[30,938,939],{},"How do I stop secrets ending up in a cassette?","\nConfigure ",[56,942,708],{},[56,944,791],{}," and ",[56,947,948],{},"before_record_response"," when the VCR instance is created, so redaction happens before anything is written to disk. Never rely on editing the file afterwards.",[10,951,952,955,957,958,960,961,963],{},[30,953,954],{},"What record mode should CI use?",[56,956,487],{},". It replays existing cassettes and fails on any request that has none, so CI can never reach the network or silently re-record. Developers use ",[56,959,462],{}," or ",[56,962,475],{}," locally when adding coverage.",[10,965,966,969,970,972],{},[30,967,968],{},"How do I know when a cassette has gone stale?","\nRun a scheduled job with record mode ",[56,971,500],{}," against the real API and diff the resulting cassettes. A changed cassette is an API change your replayed tests would never have noticed.",[19,974,976],{"id":975},"related","Related",[24,978,979,986,992,1002],{},[27,980,981,985],{},[690,982,984],{"href":983},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002F","Mocking network and HTTP calls"," — where cassette replay sits among the interception layers.",[27,987,988,991],{},[690,989,990],{"href":692},"Mocking httpx clients with respx"," — the hand-declared alternative for fast-changing APIs.",[27,993,994,998,999,1001],{},[690,995,997],{"href":996},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fmocking-requests-with-the-responses-library\u002F","Mocking requests with the responses library"," — stub-based interception for the ",[56,1000,404],{}," stack.",[27,1003,1004,1008],{},[690,1005,1007],{"href":1006},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002F","Controlling time and randomness in tests"," — freezing the clock so recorded timestamps stay comparable.",[10,1010,1011,1012],{},"← Back to ",[690,1013,1014],{"href":983},"Mocking Network and HTTP Calls",[1016,1017,1018],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":54,"searchDepth":68,"depth":68,"links":1020},[1021,1022,1023,1024,1025,1026,1027,1028,1029],{"id":21,"depth":68,"text":22},{"id":43,"depth":68,"text":44},{"id":393,"depth":68,"text":394},{"id":519,"depth":68,"text":520},{"id":635,"depth":68,"text":636},{"id":697,"depth":68,"text":698},{"id":760,"depth":68,"text":761},{"id":927,"depth":68,"text":928},{"id":975,"depth":68,"text":976},"Use VCR.py cassettes to record real HTTP once and replay it forever: record modes, scrubbing secrets, matching rules, and keeping cassettes from going stale.","md",{"slug":1033,"type":1034,"breadcrumb":1035,"datePublished":1036,"dateModified":1036,"faq":1037,"howto":1046},"recording-and-replaying-http-with-vcrpy","article","VCR.py Cassettes","2026-08-01",[1038,1040,1042,1044],{"q":933,"a":1039},"When the response body is large, structured and not authored by you — a third-party API's payload. A cassette captures the real shape once, including fields you did not know existed, where a hand-written stub captures only what you remembered.",{"q":939,"a":1041},"Configure filter_headers, filter_query_parameters and before_record_response when the VCR instance is created, so redaction happens before anything is written to disk. Never rely on editing the file afterwards.",{"q":954,"a":1043},"none. It replays existing cassettes and fails on any request that has none, so CI can never reach the network or silently re-record. Developers use once or new_episodes locally when adding coverage.",{"q":968,"a":1045},"Run a scheduled job with record mode all against the real API and diff the resulting cassettes. A changed cassette is an API change your replayed tests would never have noticed.",{"name":1047,"description":1048,"steps":1049},"How to record and replay HTTP with VCR.py","Capture real API traffic once, redact it, and replay it deterministically in CI.",[1050,1053,1056,1059],{"name":1051,"text":1052},"Configure a VCR instance with redaction","Set filter_headers and response scrubbing before any recording happens.",{"name":1054,"text":1055},"Record the cassette once against the real API","Run with a record mode that allows writing, and review the file before committing it.",{"name":1057,"text":1058},"Pin CI to replay only","Use record mode none so a missing cassette fails instead of hitting the network.",{"name":1060,"text":1061},"Refresh on a schedule","Re-record periodically and diff the cassettes to detect upstream API changes.","\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Frecording-and-replaying-http-with-vcrpy",{"title":5,"description":1030},"advanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Frecording-and-replaying-http-with-vcrpy\u002Findex","BbsNABoRx9C_bBJs-477LhpVCtK7LS9LoTgeqdcYYkA",1785613404424]