[{"data":1,"prerenderedAt":3207},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx\u002F":3186},{"id":4,"title":5,"body":6,"description":3176,"extension":3177,"meta":3178,"navigation":367,"path":3182,"seo":3183,"stem":3184,"__hash__":3185},"content\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx\u002Findex.md","Mocking External APIs with respx: Routes, Side Effects, and Honest Failure Tests",{"type":7,"value":8,"toc":3163},"minimark",[9,13,23,31,182,185,190,193,280,287,290,292,296,299,1003,1010,1207,1218,1427,1438,1558,1560,1564,1567,1574,1866,1873,1893,1895,1899,1906,2320,2346,2423,2429,2431,2435,2442,2449,2891,2898,2996,2998,3002,3010,3031,3036,3058,3060,3064,3071,3073,3077,3083,3089,3095,3101,3107,3109,3113,3118,3140,3145,3159],[10,11,5],"h1",{"id":12},"mocking-external-apis-with-respx-routes-side-effects-and-honest-failure-tests",[14,15,16,17,22],"p",{},"Your API bills customers through Stripe, enriches records from a data vendor, and pushes events to a webhook consumer. Every one of those calls is a network hop you do not control, and every one of them is a place your test suite either tells the truth or lies to you. This page resolves a specific choice: how to fake third-party HTTP traffic inside a pytest suite so the fake stays accurate, the suite stays fast, and a vendor outage never turns your build red. It extends ",[18,19,21],"a",{"href":20},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","Testing Python APIs with pytest",", the parent guide, which sets up the fixtures and event loop the examples here assume.",[14,24,25,26,30],{},"My recommendation is narrow and firm: use respx for everything your code initiates over HTTP, because it intercepts at the httpx transport layer instead of monkey-patching your own functions. That distinction matters more than it sounds. A monkey-patched ",[27,28,29],"code",{},"fetch_rates()"," proves your test double works. A respx route proves your real client code builds the right URL, sends the right headers, serialises the right JSON, and handles the real status codes — with zero packets leaving the process.",[32,33,41,42,41,46,41,50,41,69,41,76,41,86,41,94,41,99,41,105,41,108,41,112,41,115,41,119,41,124,41,128,41,131,41,138,41,144,41,149,41,154,41,158,41,161,41,165,41,169,41,175,41,179],"svg",{"viewBox":34,"role":35,"ariaLabelledBy":36,"xmlns":39,"style":40},"0 0 720 300","img",[37,38],"respx-layer-t","respx-layer-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[43,44,45],"title",{"id":37},"Where respx intercepts an httpx call",[47,48,49],"desc",{"id":38},"Service client calls httpx AsyncClient, which hands the request to a swappable transport layer. In tests respx replaces that transport and returns a canned response, so the real network path to the vendor is never used.",[51,52,53,54,41],"defs",{},"\n    ",[55,56,63,64,53],"marker",{"id":57,"viewBox":58,"refX":59,"refY":60,"markerWidth":61,"markerHeight":61,"orient":62},"respx-layer-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[65,66],"path",{"d":67,"fill":68},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[70,71],"rect",{"x":72,"y":72,"width":73,"height":74,"fill":75},"0","720","300","var(--c-surface)",[70,77],{"x":78,"y":79,"width":80,"height":81,"rx":82,"fill":83,"stroke":84,"style":85},"20","110","140","60","10","var(--c-surface-alt)","var(--c-border)","stroke-width:2;",[87,88,93],"text",{"x":89,"y":90,"fill":91,"style":92},"90","134","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Service client",[87,95,98],{"x":89,"y":96,"fill":68,"style":97},"153","text-anchor:middle;font-size:11;font-family:var(--font-sans);","convert(amount)",[100,101],"line",{"x1":102,"y1":80,"x2":103,"y2":80,"stroke":68,"style":104},"162","186","stroke-width:2;marker-end:url(#respx-layer-arrow);",[70,106],{"x":107,"y":79,"width":80,"height":81,"rx":82,"fill":83,"stroke":84,"style":85},"190",[87,109,111],{"x":110,"y":90,"fill":91,"style":92},"260","AsyncClient",[87,113,114],{"x":110,"y":96,"fill":68,"style":97},"builds Request",[100,116],{"x1":117,"y1":80,"x2":118,"y2":80,"stroke":68,"style":104},"332","356",[70,120],{"x":121,"y":79,"width":122,"height":81,"rx":82,"fill":75,"stroke":123,"style":85},"360","150","var(--c-blue)",[87,125,127],{"x":126,"y":90,"fill":91,"style":92},"435","Transport layer",[87,129,130],{"x":126,"y":96,"fill":68,"style":97},"swappable seam",[100,132],{"x1":133,"y1":134,"x2":135,"y2":136,"stroke":137,"style":104},"512","128","554","92","var(--c-teal)",[100,139],{"x1":133,"y1":140,"x2":135,"y2":141,"stroke":142,"style":143},"152","200","var(--c-coral)","stroke-width:2;stroke-dasharray:5 4;marker-end:url(#respx-layer-arrow);",[70,145],{"x":146,"y":147,"width":148,"height":81,"rx":82,"fill":75,"stroke":137,"style":85},"558","52","142",[87,150,153],{"x":151,"y":152,"fill":91,"style":92},"629","76","respx router",[87,155,157],{"x":151,"y":156,"fill":68,"style":97},"95","canned Response",[70,159],{"x":146,"y":160,"width":148,"height":81,"rx":82,"fill":75,"stroke":142,"style":85},"196",[87,162,164],{"x":151,"y":163,"fill":91,"style":92},"220","Vendor API",[87,166,168],{"x":151,"y":167,"fill":68,"style":97},"239","never reached",[87,170,174],{"x":171,"y":172,"fill":68,"style":173},"530","36","text-anchor:middle;font-size:12;font-family:var(--font-sans);","under test",[87,176,178],{"x":171,"y":177,"fill":68,"style":173},"278","in production",[87,180,181],{"x":89,"y":177,"fill":68,"style":173},"your real code runs",[183,184],"hr",{},[186,187,189],"h2",{"id":188},"the-four-ways-to-fake-a-vendor-compared","The four ways to fake a vendor, compared",[14,191,192],{},"Before the code, be clear about what you are choosing between. Four approaches show up in real Python suites, and they differ mainly in how much of your own code stays live during the test.",[194,195,196,215],"table",{},[197,198,199],"thead",{},[200,201,202,206,209,212],"tr",{},[203,204,205],"th",{},"Approach",[203,207,208],{},"Your client code runs",[203,210,211],{},"Drift risk",[203,213,214],{},"Speed",[216,217,218,236,251,265],"tbody",{},[200,219,220,227,230,233],{},[221,222,223],"td",{},[224,225,226],"strong",{},"respx routes",[221,228,229],{},"Fully",[221,231,232],{},"Medium",[221,234,235],{},"Microseconds",[200,237,238,243,246,249],{},[221,239,240],{},[224,241,242],{},"Monkeypatched function",[221,244,245],{},"No",[221,247,248],{},"High",[221,250,235],{},[200,252,253,258,260,262],{},[221,254,255],{},[224,256,257],{},"Local fake server",[221,259,229],{},[221,261,232],{},[221,263,264],{},"Milliseconds",[200,266,267,272,274,277],{},[221,268,269],{},[224,270,271],{},"Vendor sandbox",[221,273,229],{},[221,275,276],{},"None",[221,278,279],{},"Seconds",[14,281,282,283,286],{},"Monkeypatching your own ",[27,284,285],{},"get_rates()"," is the option to delete. It skips URL building, header injection, serialisation and status handling — precisely the code most likely to be wrong. A local fake server (a small FastAPI app on a random port) runs everything including the socket, but it costs a process, a port, and a startup wait per test session, and you still hand-write the responses, so its accuracy is no better than respx. The vendor sandbox is the only option with zero drift, and it belongs in a nightly job, not in the suite that gates your pull requests. respx sits at the sweet spot: full client code, no sockets, no ports, no flakes.",[14,288,289],{},"Drift is the real enemy here, not speed. A mock is a claim about someone else's server, and that claim silently rots the day the vendor adds a required field or changes an error envelope. The last section covers how to bound that rot without slowing your suite down.",[183,291],{},[186,293,295],{"id":294},"routing-requests-the-shape-that-scales","Routing requests: the shape that scales",[14,297,298],{},"Start with a client that reads everything from the environment. The test suite then points that same client at an unroutable host, which guarantees a mistake in your mock setup fails loudly instead of hitting a live endpoint.",[300,301,306],"pre",{"className":302,"code":303,"language":304,"meta":305,"style":305},"language-python shiki shiki-themes github-light github-dark","# app\u002Frates_client.py\nimport asyncio\nimport os\nfrom dataclasses import dataclass\nfrom functools import lru_cache\n\nimport httpx\n\n\n@dataclass(frozen=True, slots=True)\nclass RatesConfig:\n    base_url: str\n    api_key: str\n    timeout: float\n\n\n@lru_cache\ndef get_config() -> RatesConfig:\n    return RatesConfig(\n        base_url=os.environ[\"RATES_API_BASE\"],\n        api_key=os.environ[\"RATES_API_KEY\"],\n        timeout=float(os.getenv(\"RATES_TIMEOUT_SECONDS\", \"5.0\")),\n    )\n\n\ndef build_client() -> httpx.AsyncClient:\n    config = get_config()\n    return httpx.AsyncClient(\n        base_url=config.base_url,\n        headers={\"Authorization\": f\"Bearer {config.api_key}\"},\n        timeout=httpx.Timeout(config.timeout),\n    )\n\n\nasync def convert(client: httpx.AsyncClient, amount_cents: int, target: str) -> int:\n    response = await client.post(\n        \"\u002Fv1\u002Fconvert\",\n        json={\"amount\": amount_cents, \"to\": target},\n        headers={\"Idempotency-Key\": f\"conv-{amount_cents}-{target}\"},\n    )\n    response.raise_for_status()\n    return int(response.json()[\"converted\"])\n\n\nasync def convert_with_retry(\n    client: httpx.AsyncClient, amount_cents: int, target: str, attempts: int = 4\n) -> int:\n    for attempt in range(attempts):\n        try:\n            return await convert(client, amount_cents, target)\n        except httpx.HTTPStatusError as exc:\n            if exc.response.status_code != 429 or attempt == attempts - 1:\n                raise\n            await asyncio.sleep(float(exc.response.headers.get(\"Retry-After\", \"1\")))\n    raise RuntimeError(\"unreachable\")\n","python","",[27,307,308,316,327,335,349,362,369,377,382,387,421,433,442,450,459,464,469,475,487,496,514,529,554,560,565,570,581,592,600,610,647,657,662,667,672,703,717,726,748,788,793,799,816,821,826,839,862,871,889,897,908,923,956,962,987],{"__ignoreMap":305},[309,310,312],"span",{"class":100,"line":311},1,[309,313,315],{"class":314},"sJ8bj","# app\u002Frates_client.py\n",[309,317,319,323],{"class":100,"line":318},2,[309,320,322],{"class":321},"szBVR","import",[309,324,326],{"class":325},"sVt8B"," asyncio\n",[309,328,330,332],{"class":100,"line":329},3,[309,331,322],{"class":321},[309,333,334],{"class":325}," os\n",[309,336,338,341,344,346],{"class":100,"line":337},4,[309,339,340],{"class":321},"from",[309,342,343],{"class":325}," dataclasses ",[309,345,322],{"class":321},[309,347,348],{"class":325}," dataclass\n",[309,350,352,354,357,359],{"class":100,"line":351},5,[309,353,340],{"class":321},[309,355,356],{"class":325}," functools ",[309,358,322],{"class":321},[309,360,361],{"class":325}," lru_cache\n",[309,363,365],{"class":100,"line":364},6,[309,366,368],{"emptyLinePlaceholder":367},true,"\n",[309,370,372,374],{"class":100,"line":371},7,[309,373,322],{"class":321},[309,375,376],{"class":325}," httpx\n",[309,378,380],{"class":100,"line":379},8,[309,381,368],{"emptyLinePlaceholder":367},[309,383,385],{"class":100,"line":384},9,[309,386,368],{"emptyLinePlaceholder":367},[309,388,390,394,397,401,404,408,411,414,416,418],{"class":100,"line":389},10,[309,391,393],{"class":392},"sScJk","@dataclass",[309,395,396],{"class":325},"(",[309,398,400],{"class":399},"s4XuR","frozen",[309,402,403],{"class":321},"=",[309,405,407],{"class":406},"sj4cs","True",[309,409,410],{"class":325},", ",[309,412,413],{"class":399},"slots",[309,415,403],{"class":321},[309,417,407],{"class":406},[309,419,420],{"class":325},")\n",[309,422,424,427,430],{"class":100,"line":423},11,[309,425,426],{"class":321},"class",[309,428,429],{"class":392}," RatesConfig",[309,431,432],{"class":325},":\n",[309,434,436,439],{"class":100,"line":435},12,[309,437,438],{"class":325},"    base_url: ",[309,440,441],{"class":406},"str\n",[309,443,445,448],{"class":100,"line":444},13,[309,446,447],{"class":325},"    api_key: ",[309,449,441],{"class":406},[309,451,453,456],{"class":100,"line":452},14,[309,454,455],{"class":325},"    timeout: ",[309,457,458],{"class":406},"float\n",[309,460,462],{"class":100,"line":461},15,[309,463,368],{"emptyLinePlaceholder":367},[309,465,467],{"class":100,"line":466},16,[309,468,368],{"emptyLinePlaceholder":367},[309,470,472],{"class":100,"line":471},17,[309,473,474],{"class":392},"@lru_cache\n",[309,476,478,481,484],{"class":100,"line":477},18,[309,479,480],{"class":321},"def",[309,482,483],{"class":392}," get_config",[309,485,486],{"class":325},"() -> RatesConfig:\n",[309,488,490,493],{"class":100,"line":489},19,[309,491,492],{"class":321},"    return",[309,494,495],{"class":325}," RatesConfig(\n",[309,497,499,502,504,507,511],{"class":100,"line":498},20,[309,500,501],{"class":399},"        base_url",[309,503,403],{"class":321},[309,505,506],{"class":325},"os.environ[",[309,508,510],{"class":509},"sZZnC","\"RATES_API_BASE\"",[309,512,513],{"class":325},"],\n",[309,515,517,520,522,524,527],{"class":100,"line":516},21,[309,518,519],{"class":399},"        api_key",[309,521,403],{"class":321},[309,523,506],{"class":325},[309,525,526],{"class":509},"\"RATES_API_KEY\"",[309,528,513],{"class":325},[309,530,532,535,537,540,543,546,548,551],{"class":100,"line":531},22,[309,533,534],{"class":399},"        timeout",[309,536,403],{"class":321},[309,538,539],{"class":406},"float",[309,541,542],{"class":325},"(os.getenv(",[309,544,545],{"class":509},"\"RATES_TIMEOUT_SECONDS\"",[309,547,410],{"class":325},[309,549,550],{"class":509},"\"5.0\"",[309,552,553],{"class":325},")),\n",[309,555,557],{"class":100,"line":556},23,[309,558,559],{"class":325},"    )\n",[309,561,563],{"class":100,"line":562},24,[309,564,368],{"emptyLinePlaceholder":367},[309,566,568],{"class":100,"line":567},25,[309,569,368],{"emptyLinePlaceholder":367},[309,571,573,575,578],{"class":100,"line":572},26,[309,574,480],{"class":321},[309,576,577],{"class":392}," build_client",[309,579,580],{"class":325},"() -> httpx.AsyncClient:\n",[309,582,584,587,589],{"class":100,"line":583},27,[309,585,586],{"class":325},"    config ",[309,588,403],{"class":321},[309,590,591],{"class":325}," get_config()\n",[309,593,595,597],{"class":100,"line":594},28,[309,596,492],{"class":321},[309,598,599],{"class":325}," httpx.AsyncClient(\n",[309,601,603,605,607],{"class":100,"line":602},29,[309,604,501],{"class":399},[309,606,403],{"class":321},[309,608,609],{"class":325},"config.base_url,\n",[309,611,613,616,618,621,624,627,630,633,635,638,641,644],{"class":100,"line":612},30,[309,614,615],{"class":399},"        headers",[309,617,403],{"class":321},[309,619,620],{"class":325},"{",[309,622,623],{"class":509},"\"Authorization\"",[309,625,626],{"class":325},": ",[309,628,629],{"class":321},"f",[309,631,632],{"class":509},"\"Bearer ",[309,634,620],{"class":406},[309,636,637],{"class":325},"config.api_key",[309,639,640],{"class":406},"}",[309,642,643],{"class":509},"\"",[309,645,646],{"class":325},"},\n",[309,648,650,652,654],{"class":100,"line":649},31,[309,651,534],{"class":399},[309,653,403],{"class":321},[309,655,656],{"class":325},"httpx.Timeout(config.timeout),\n",[309,658,660],{"class":100,"line":659},32,[309,661,559],{"class":325},[309,663,665],{"class":100,"line":664},33,[309,666,368],{"emptyLinePlaceholder":367},[309,668,670],{"class":100,"line":669},34,[309,671,368],{"emptyLinePlaceholder":367},[309,673,675,678,681,684,687,690,693,696,699,701],{"class":100,"line":674},35,[309,676,677],{"class":321},"async",[309,679,680],{"class":321}," def",[309,682,683],{"class":392}," convert",[309,685,686],{"class":325},"(client: httpx.AsyncClient, amount_cents: ",[309,688,689],{"class":406},"int",[309,691,692],{"class":325},", target: ",[309,694,695],{"class":406},"str",[309,697,698],{"class":325},") -> ",[309,700,689],{"class":406},[309,702,432],{"class":325},[309,704,706,709,711,714],{"class":100,"line":705},36,[309,707,708],{"class":325},"    response ",[309,710,403],{"class":321},[309,712,713],{"class":321}," await",[309,715,716],{"class":325}," client.post(\n",[309,718,720,723],{"class":100,"line":719},37,[309,721,722],{"class":509},"        \"\u002Fv1\u002Fconvert\"",[309,724,725],{"class":325},",\n",[309,727,729,732,734,736,739,742,745],{"class":100,"line":728},38,[309,730,731],{"class":399},"        json",[309,733,403],{"class":321},[309,735,620],{"class":325},[309,737,738],{"class":509},"\"amount\"",[309,740,741],{"class":325},": amount_cents, ",[309,743,744],{"class":509},"\"to\"",[309,746,747],{"class":325},": target},\n",[309,749,751,753,755,757,760,762,764,767,769,772,774,777,779,782,784,786],{"class":100,"line":750},39,[309,752,615],{"class":399},[309,754,403],{"class":321},[309,756,620],{"class":325},[309,758,759],{"class":509},"\"Idempotency-Key\"",[309,761,626],{"class":325},[309,763,629],{"class":321},[309,765,766],{"class":509},"\"conv-",[309,768,620],{"class":406},[309,770,771],{"class":325},"amount_cents",[309,773,640],{"class":406},[309,775,776],{"class":509},"-",[309,778,620],{"class":406},[309,780,781],{"class":325},"target",[309,783,640],{"class":406},[309,785,643],{"class":509},[309,787,646],{"class":325},[309,789,791],{"class":100,"line":790},40,[309,792,559],{"class":325},[309,794,796],{"class":100,"line":795},41,[309,797,798],{"class":325},"    response.raise_for_status()\n",[309,800,802,804,807,810,813],{"class":100,"line":801},42,[309,803,492],{"class":321},[309,805,806],{"class":406}," int",[309,808,809],{"class":325},"(response.json()[",[309,811,812],{"class":509},"\"converted\"",[309,814,815],{"class":325},"])\n",[309,817,819],{"class":100,"line":818},43,[309,820,368],{"emptyLinePlaceholder":367},[309,822,824],{"class":100,"line":823},44,[309,825,368],{"emptyLinePlaceholder":367},[309,827,829,831,833,836],{"class":100,"line":828},45,[309,830,677],{"class":321},[309,832,680],{"class":321},[309,834,835],{"class":392}," convert_with_retry",[309,837,838],{"class":325},"(\n",[309,840,842,845,847,849,851,854,856,859],{"class":100,"line":841},46,[309,843,844],{"class":325},"    client: httpx.AsyncClient, amount_cents: ",[309,846,689],{"class":406},[309,848,692],{"class":325},[309,850,695],{"class":406},[309,852,853],{"class":325},", attempts: ",[309,855,689],{"class":406},[309,857,858],{"class":321}," =",[309,860,861],{"class":406}," 4\n",[309,863,865,867,869],{"class":100,"line":864},47,[309,866,698],{"class":325},[309,868,689],{"class":406},[309,870,432],{"class":325},[309,872,874,877,880,883,886],{"class":100,"line":873},48,[309,875,876],{"class":321},"    for",[309,878,879],{"class":325}," attempt ",[309,881,882],{"class":321},"in",[309,884,885],{"class":406}," range",[309,887,888],{"class":325},"(attempts):\n",[309,890,892,895],{"class":100,"line":891},49,[309,893,894],{"class":321},"        try",[309,896,432],{"class":325},[309,898,900,903,905],{"class":100,"line":899},50,[309,901,902],{"class":321},"            return",[309,904,713],{"class":321},[309,906,907],{"class":325}," convert(client, amount_cents, target)\n",[309,909,911,914,917,920],{"class":100,"line":910},51,[309,912,913],{"class":321},"        except",[309,915,916],{"class":325}," httpx.HTTPStatusError ",[309,918,919],{"class":321},"as",[309,921,922],{"class":325}," exc:\n",[309,924,926,929,932,935,938,941,943,946,949,951,954],{"class":100,"line":925},52,[309,927,928],{"class":321},"            if",[309,930,931],{"class":325}," exc.response.status_code ",[309,933,934],{"class":321},"!=",[309,936,937],{"class":406}," 429",[309,939,940],{"class":321}," or",[309,942,879],{"class":325},[309,944,945],{"class":321},"==",[309,947,948],{"class":325}," attempts ",[309,950,776],{"class":321},[309,952,953],{"class":406}," 1",[309,955,432],{"class":325},[309,957,959],{"class":100,"line":958},53,[309,960,961],{"class":321},"                raise\n",[309,963,965,968,971,973,976,979,981,984],{"class":100,"line":964},54,[309,966,967],{"class":321},"            await",[309,969,970],{"class":325}," asyncio.sleep(",[309,972,539],{"class":406},[309,974,975],{"class":325},"(exc.response.headers.get(",[309,977,978],{"class":509},"\"Retry-After\"",[309,980,410],{"class":325},[309,982,983],{"class":509},"\"1\"",[309,985,986],{"class":325},")))\n",[309,988,990,993,996,998,1001],{"class":100,"line":989},55,[309,991,992],{"class":321},"    raise",[309,994,995],{"class":406}," RuntimeError",[309,997,396],{"class":325},[309,999,1000],{"class":509},"\"unreachable\"",[309,1002,420],{"class":325},[14,1004,1005,1006,1009],{},"The fixture below injects the environment and clears the cached config so each test starts clean. Point the base URL at a ",[27,1007,1008],{},".invalid"," host — that top-level domain is reserved and can never resolve, so an unmocked request fails instantly rather than leaking traffic.",[300,1011,1013],{"className":302,"code":1012,"language":304,"meta":305,"style":305},"# tests\u002Fconftest.py\nimport os\n\nimport pytest\n\nfrom app.rates_client import build_client, get_config\n\nFAKE_BASE = os.getenv(\"TEST_RATES_API_BASE\", \"https:\u002F\u002Frates.invalid\")\n\n\n@pytest.fixture(autouse=True)\ndef rates_env(monkeypatch):\n    monkeypatch.setenv(\"RATES_API_BASE\", FAKE_BASE)\n    monkeypatch.setenv(\"RATES_API_KEY\", os.getenv(\"TEST_RATES_API_KEY\", \"test-key\"))\n    get_config.cache_clear()\n    yield\n    get_config.cache_clear()\n\n\n@pytest.fixture\nasync def rates_client():\n    async with build_client() as client:\n        yield client\n",[27,1014,1015,1020,1026,1030,1037,1041,1053,1057,1077,1081,1085,1101,1111,1124,1144,1149,1154,1158,1162,1166,1171,1183,1199],{"__ignoreMap":305},[309,1016,1017],{"class":100,"line":311},[309,1018,1019],{"class":314},"# tests\u002Fconftest.py\n",[309,1021,1022,1024],{"class":100,"line":318},[309,1023,322],{"class":321},[309,1025,334],{"class":325},[309,1027,1028],{"class":100,"line":329},[309,1029,368],{"emptyLinePlaceholder":367},[309,1031,1032,1034],{"class":100,"line":337},[309,1033,322],{"class":321},[309,1035,1036],{"class":325}," pytest\n",[309,1038,1039],{"class":100,"line":351},[309,1040,368],{"emptyLinePlaceholder":367},[309,1042,1043,1045,1048,1050],{"class":100,"line":364},[309,1044,340],{"class":321},[309,1046,1047],{"class":325}," app.rates_client ",[309,1049,322],{"class":321},[309,1051,1052],{"class":325}," build_client, get_config\n",[309,1054,1055],{"class":100,"line":371},[309,1056,368],{"emptyLinePlaceholder":367},[309,1058,1059,1062,1064,1067,1070,1072,1075],{"class":100,"line":379},[309,1060,1061],{"class":406},"FAKE_BASE",[309,1063,858],{"class":321},[309,1065,1066],{"class":325}," os.getenv(",[309,1068,1069],{"class":509},"\"TEST_RATES_API_BASE\"",[309,1071,410],{"class":325},[309,1073,1074],{"class":509},"\"https:\u002F\u002Frates.invalid\"",[309,1076,420],{"class":325},[309,1078,1079],{"class":100,"line":384},[309,1080,368],{"emptyLinePlaceholder":367},[309,1082,1083],{"class":100,"line":389},[309,1084,368],{"emptyLinePlaceholder":367},[309,1086,1087,1090,1092,1095,1097,1099],{"class":100,"line":423},[309,1088,1089],{"class":392},"@pytest.fixture",[309,1091,396],{"class":325},[309,1093,1094],{"class":399},"autouse",[309,1096,403],{"class":321},[309,1098,407],{"class":406},[309,1100,420],{"class":325},[309,1102,1103,1105,1108],{"class":100,"line":435},[309,1104,480],{"class":321},[309,1106,1107],{"class":392}," rates_env",[309,1109,1110],{"class":325},"(monkeypatch):\n",[309,1112,1113,1116,1118,1120,1122],{"class":100,"line":444},[309,1114,1115],{"class":325},"    monkeypatch.setenv(",[309,1117,510],{"class":509},[309,1119,410],{"class":325},[309,1121,1061],{"class":406},[309,1123,420],{"class":325},[309,1125,1126,1128,1130,1133,1136,1138,1141],{"class":100,"line":452},[309,1127,1115],{"class":325},[309,1129,526],{"class":509},[309,1131,1132],{"class":325},", os.getenv(",[309,1134,1135],{"class":509},"\"TEST_RATES_API_KEY\"",[309,1137,410],{"class":325},[309,1139,1140],{"class":509},"\"test-key\"",[309,1142,1143],{"class":325},"))\n",[309,1145,1146],{"class":100,"line":461},[309,1147,1148],{"class":325},"    get_config.cache_clear()\n",[309,1150,1151],{"class":100,"line":466},[309,1152,1153],{"class":321},"    yield\n",[309,1155,1156],{"class":100,"line":471},[309,1157,1148],{"class":325},[309,1159,1160],{"class":100,"line":477},[309,1161,368],{"emptyLinePlaceholder":367},[309,1163,1164],{"class":100,"line":489},[309,1165,368],{"emptyLinePlaceholder":367},[309,1167,1168],{"class":100,"line":498},[309,1169,1170],{"class":392},"@pytest.fixture\n",[309,1172,1173,1175,1177,1180],{"class":100,"line":516},[309,1174,677],{"class":321},[309,1176,680],{"class":321},[309,1178,1179],{"class":392}," rates_client",[309,1181,1182],{"class":325},"():\n",[309,1184,1185,1188,1191,1194,1196],{"class":100,"line":531},[309,1186,1187],{"class":321},"    async",[309,1189,1190],{"class":321}," with",[309,1192,1193],{"class":325}," build_client() ",[309,1195,919],{"class":321},[309,1197,1198],{"class":325}," client:\n",[309,1200,1201,1204],{"class":100,"line":556},[309,1202,1203],{"class":321},"        yield",[309,1205,1206],{"class":325}," client\n",[14,1208,1209,1210,1213,1214,1217],{},"Now the actual mock. respx ships a pytest plugin, so you get a ",[27,1211,1212],{},"respx_mock"," router fixture for free, and the ",[27,1215,1216],{},"respx"," marker sets a base URL so routes stay short.",[300,1219,1221],{"className":302,"code":1220,"language":304,"meta":305,"style":305},"# tests\u002Ftest_rates_client.py\nimport httpx\nimport pytest\nimport respx\n\nfrom app.rates_client import convert\nfrom tests.conftest import FAKE_BASE\n\npytestmark = pytest.mark.anyio\n\n\n@pytest.mark.respx(base_url=FAKE_BASE)\nasync def test_convert_parses_cents(respx_mock, rates_client):\n    route = respx_mock.post(\"\u002Fv1\u002Fconvert\").mock(\n        return_value=httpx.Response(200, json={\"converted\": 1837, \"rate\": 0.9185})\n    )\n\n    assert await convert(rates_client, 2000, \"EUR\") == 1837\n    assert route.call_count == 1\n",[27,1222,1223,1228,1234,1240,1247,1251,1262,1274,1278,1288,1292,1296,1312,1324,1340,1381,1385,1389,1415],{"__ignoreMap":305},[309,1224,1225],{"class":100,"line":311},[309,1226,1227],{"class":314},"# tests\u002Ftest_rates_client.py\n",[309,1229,1230,1232],{"class":100,"line":318},[309,1231,322],{"class":321},[309,1233,376],{"class":325},[309,1235,1236,1238],{"class":100,"line":329},[309,1237,322],{"class":321},[309,1239,1036],{"class":325},[309,1241,1242,1244],{"class":100,"line":337},[309,1243,322],{"class":321},[309,1245,1246],{"class":325}," respx\n",[309,1248,1249],{"class":100,"line":351},[309,1250,368],{"emptyLinePlaceholder":367},[309,1252,1253,1255,1257,1259],{"class":100,"line":364},[309,1254,340],{"class":321},[309,1256,1047],{"class":325},[309,1258,322],{"class":321},[309,1260,1261],{"class":325}," convert\n",[309,1263,1264,1266,1269,1271],{"class":100,"line":371},[309,1265,340],{"class":321},[309,1267,1268],{"class":325}," tests.conftest ",[309,1270,322],{"class":321},[309,1272,1273],{"class":406}," FAKE_BASE\n",[309,1275,1276],{"class":100,"line":379},[309,1277,368],{"emptyLinePlaceholder":367},[309,1279,1280,1283,1285],{"class":100,"line":384},[309,1281,1282],{"class":325},"pytestmark ",[309,1284,403],{"class":321},[309,1286,1287],{"class":325}," pytest.mark.anyio\n",[309,1289,1290],{"class":100,"line":389},[309,1291,368],{"emptyLinePlaceholder":367},[309,1293,1294],{"class":100,"line":423},[309,1295,368],{"emptyLinePlaceholder":367},[309,1297,1298,1301,1303,1306,1308,1310],{"class":100,"line":435},[309,1299,1300],{"class":392},"@pytest.mark.respx",[309,1302,396],{"class":325},[309,1304,1305],{"class":399},"base_url",[309,1307,403],{"class":321},[309,1309,1061],{"class":406},[309,1311,420],{"class":325},[309,1313,1314,1316,1318,1321],{"class":100,"line":444},[309,1315,677],{"class":321},[309,1317,680],{"class":321},[309,1319,1320],{"class":392}," test_convert_parses_cents",[309,1322,1323],{"class":325},"(respx_mock, rates_client):\n",[309,1325,1326,1329,1331,1334,1337],{"class":100,"line":452},[309,1327,1328],{"class":325},"    route ",[309,1330,403],{"class":321},[309,1332,1333],{"class":325}," respx_mock.post(",[309,1335,1336],{"class":509},"\"\u002Fv1\u002Fconvert\"",[309,1338,1339],{"class":325},").mock(\n",[309,1341,1342,1345,1347,1350,1352,1354,1357,1359,1361,1363,1365,1368,1370,1373,1375,1378],{"class":100,"line":461},[309,1343,1344],{"class":399},"        return_value",[309,1346,403],{"class":321},[309,1348,1349],{"class":325},"httpx.Response(",[309,1351,141],{"class":406},[309,1353,410],{"class":325},[309,1355,1356],{"class":399},"json",[309,1358,403],{"class":321},[309,1360,620],{"class":325},[309,1362,812],{"class":509},[309,1364,626],{"class":325},[309,1366,1367],{"class":406},"1837",[309,1369,410],{"class":325},[309,1371,1372],{"class":509},"\"rate\"",[309,1374,626],{"class":325},[309,1376,1377],{"class":406},"0.9185",[309,1379,1380],{"class":325},"})\n",[309,1382,1383],{"class":100,"line":466},[309,1384,559],{"class":325},[309,1386,1387],{"class":100,"line":471},[309,1388,368],{"emptyLinePlaceholder":367},[309,1390,1391,1394,1396,1399,1402,1404,1407,1410,1412],{"class":100,"line":477},[309,1392,1393],{"class":321},"    assert",[309,1395,713],{"class":321},[309,1397,1398],{"class":325}," convert(rates_client, ",[309,1400,1401],{"class":406},"2000",[309,1403,410],{"class":325},[309,1405,1406],{"class":509},"\"EUR\"",[309,1408,1409],{"class":325},") ",[309,1411,945],{"class":321},[309,1413,1414],{"class":406}," 1837\n",[309,1416,1417,1419,1422,1424],{"class":100,"line":489},[309,1418,1393],{"class":321},[309,1420,1421],{"class":325}," route.call_count ",[309,1423,945],{"class":321},[309,1425,1426],{"class":406}," 1\n",[14,1428,1429,1430,1433,1434,1437],{},"Two defaults do heavy lifting and you should leave both alone. ",[27,1431,1432],{},"assert_all_mocked"," makes any request that matches no route raise immediately, so a typo in a path surfaces as a test failure instead of a real network call. ",[27,1435,1436],{},"assert_all_called"," makes a declared-but-unused route fail the test at teardown, which catches the dead mocks that accumulate after you refactor a code path away. Together they keep the mock set honest as the codebase moves.",[32,1439,41,1444,41,1447,41,1450,41,1457,41,1460,41,1464,41,1469,41,1475,41,1479,41,1483,41,1487,41,1493,41,1496,41,1500,41,1506,41,1510,41,1514,41,1519,41,1522,41,1526,41,1530,41,1533,41,1535,41,1539,41,1543,41,1546,41,1550,41,1554],{"viewBox":1440,"role":35,"ariaLabelledBy":1441,"xmlns":39,"style":40},"0 0 720 340",[1442,1443],"respx-match-t","respx-match-d",[43,1445,1446],{"id":1442},"How respx resolves an outgoing request against registered routes",[47,1448,1449],{"id":1443},"An outgoing POST request is tested against three registered routes in declaration order. The first two fail on method and JSON body lookups, the third matches and returns a canned 200. If none matched, respx raises an all-mocked assertion error.",[51,1451,53,1452,41],{},[55,1453,63,1455,53],{"id":1454,"viewBox":58,"refX":59,"refY":60,"markerWidth":61,"markerHeight":61,"orient":62},"respx-match-arrow",[65,1456],{"d":67,"fill":68},[70,1458],{"x":72,"y":72,"width":73,"height":1459,"fill":75},"340",[70,1461],{"x":78,"y":1462,"width":160,"height":1463,"rx":82,"fill":83,"stroke":123,"style":85},"70","130",[87,1465,1468],{"x":1466,"y":1467,"fill":91,"style":92},"118","96","Outgoing request",[87,1470,1474],{"x":1471,"y":1472,"fill":68,"style":1473},"38","122","font-size:12;font-family:var(--font-sans);","POST \u002Fv1\u002Fconvert",[87,1476,1478],{"x":1471,"y":1477,"fill":68,"style":1473},"144","json: amount=2000",[87,1480,1482],{"x":1471,"y":1481,"fill":68,"style":1473},"166","to: EUR",[87,1484,1486],{"x":1471,"y":1485,"fill":68,"style":1473},"188","hdr: Idempotency-Key",[100,1488],{"x1":163,"y1":1489,"x2":1490,"y2":1491,"stroke":68,"style":1492},"106","256","72","stroke-width:2;marker-end:url(#respx-match-arrow);",[100,1494],{"x1":163,"y1":1495,"x2":1490,"y2":148,"stroke":68,"style":1492},"135",[100,1497],{"x1":163,"y1":1498,"x2":1490,"y2":1499,"stroke":68,"style":1492},"164","212",[70,1501],{"x":110,"y":1502,"width":1503,"height":1504,"rx":1505,"fill":75,"stroke":84,"style":85},"42","330","56","8",[87,1507,1509],{"x":177,"y":1508,"fill":91,"style":1473},"66","route 1: GET \u002Fv1\u002Fconvert",[87,1511,1513],{"x":177,"y":1512,"fill":68,"style":1473},"86","method lookup fails",[87,1515,1518],{"x":1516,"y":152,"fill":142,"style":1517},"610","font-size:14;font-family:var(--font-sans);","skip",[70,1520],{"x":110,"y":1521,"width":1503,"height":1504,"rx":1505,"fill":75,"stroke":84,"style":85},"114",[87,1523,1525],{"x":177,"y":1524,"fill":91,"style":1473},"138","route 2: POST, json__eq to=GBP",[87,1527,1529],{"x":177,"y":1528,"fill":68,"style":1473},"158","body lookup fails",[87,1531,1518],{"x":1516,"y":1532,"fill":142,"style":1517},"148",[70,1534],{"x":110,"y":103,"width":1503,"height":1504,"rx":1505,"fill":75,"stroke":137,"style":85},[87,1536,1538],{"x":177,"y":1537,"fill":91,"style":1473},"210","route 3: POST \u002Fv1\u002Fconvert",[87,1540,1542],{"x":177,"y":1541,"fill":68,"style":1473},"230","all lookups pass",[87,1544,1545],{"x":1516,"y":163,"fill":137,"style":1517},"match",[100,1547],{"x1":1548,"y1":1549,"x2":1548,"y2":177,"stroke":137,"style":1492},"425","246",[87,1551,1553],{"x":1548,"y":1552,"fill":91,"style":173},"298","200 returned, call recorded on route.calls",[87,1555,1557],{"x":1548,"y":1556,"fill":68,"style":173},"320","no match anywhere raises AllMockedAssertionError",[183,1559],{},[186,1561,1563],{"id":1562},"assert-what-you-sent-not-only-what-you-got","Assert what you sent, not only what you got",[14,1565,1566],{},"Half the value of respx is the recorded call list. A test that only checks the return value proves your parser works; it says nothing about whether you sent a valid request. That gap is where the expensive bugs live — a missing idempotency key that double-charges a customer, a currency sent as a float, an auth header that never made it onto the request.",[14,1568,1569,1570,1573],{},"Two techniques cover it. Encode strict expectations in the route pattern so a wrong request simply fails to match, and inspect ",[27,1571,1572],{},"route.calls.last.request"," for anything you want to assert with a clearer message.",[300,1575,1577],{"className":302,"code":1576,"language":304,"meta":305,"style":305},"# tests\u002Ftest_request_payload.py\nimport json\n\nimport httpx\nimport pytest\n\nfrom app.rates_client import convert\nfrom tests.conftest import FAKE_BASE\n\npytestmark = pytest.mark.anyio\n\n\n@pytest.mark.respx(base_url=FAKE_BASE)\nasync def test_request_carries_auth_and_idempotency(respx_mock, rates_client):\n    route = respx_mock.post(\n        \"\u002Fv1\u002Fconvert\",\n        json__eq={\"amount\": 2000, \"to\": \"EUR\"},\n    ).mock(return_value=httpx.Response(200, json={\"converted\": 1837}))\n\n    await convert(rates_client, 2000, \"EUR\")\n\n    request = route.calls.last.request\n    assert request.headers[\"Authorization\"].startswith(\"Bearer \")\n    assert request.headers[\"Idempotency-Key\"] == \"conv-2000-EUR\"\n\n    body = json.loads(request.content)\n    assert isinstance(body[\"amount\"], int)  # cents, never floats\n    assert body[\"to\"] == \"EUR\"\n",[27,1578,1579,1584,1591,1595,1601,1607,1611,1621,1631,1635,1643,1647,1651,1665,1676,1685,1691,1716,1747,1751,1766,1770,1780,1797,1813,1817,1827,1850],{"__ignoreMap":305},[309,1580,1581],{"class":100,"line":311},[309,1582,1583],{"class":314},"# tests\u002Ftest_request_payload.py\n",[309,1585,1586,1588],{"class":100,"line":318},[309,1587,322],{"class":321},[309,1589,1590],{"class":325}," json\n",[309,1592,1593],{"class":100,"line":329},[309,1594,368],{"emptyLinePlaceholder":367},[309,1596,1597,1599],{"class":100,"line":337},[309,1598,322],{"class":321},[309,1600,376],{"class":325},[309,1602,1603,1605],{"class":100,"line":351},[309,1604,322],{"class":321},[309,1606,1036],{"class":325},[309,1608,1609],{"class":100,"line":364},[309,1610,368],{"emptyLinePlaceholder":367},[309,1612,1613,1615,1617,1619],{"class":100,"line":371},[309,1614,340],{"class":321},[309,1616,1047],{"class":325},[309,1618,322],{"class":321},[309,1620,1261],{"class":325},[309,1622,1623,1625,1627,1629],{"class":100,"line":379},[309,1624,340],{"class":321},[309,1626,1268],{"class":325},[309,1628,322],{"class":321},[309,1630,1273],{"class":406},[309,1632,1633],{"class":100,"line":384},[309,1634,368],{"emptyLinePlaceholder":367},[309,1636,1637,1639,1641],{"class":100,"line":389},[309,1638,1282],{"class":325},[309,1640,403],{"class":321},[309,1642,1287],{"class":325},[309,1644,1645],{"class":100,"line":423},[309,1646,368],{"emptyLinePlaceholder":367},[309,1648,1649],{"class":100,"line":435},[309,1650,368],{"emptyLinePlaceholder":367},[309,1652,1653,1655,1657,1659,1661,1663],{"class":100,"line":444},[309,1654,1300],{"class":392},[309,1656,396],{"class":325},[309,1658,1305],{"class":399},[309,1660,403],{"class":321},[309,1662,1061],{"class":406},[309,1664,420],{"class":325},[309,1666,1667,1669,1671,1674],{"class":100,"line":452},[309,1668,677],{"class":321},[309,1670,680],{"class":321},[309,1672,1673],{"class":392}," test_request_carries_auth_and_idempotency",[309,1675,1323],{"class":325},[309,1677,1678,1680,1682],{"class":100,"line":461},[309,1679,1328],{"class":325},[309,1681,403],{"class":321},[309,1683,1684],{"class":325}," respx_mock.post(\n",[309,1686,1687,1689],{"class":100,"line":466},[309,1688,722],{"class":509},[309,1690,725],{"class":325},[309,1692,1693,1696,1698,1700,1702,1704,1706,1708,1710,1712,1714],{"class":100,"line":471},[309,1694,1695],{"class":399},"        json__eq",[309,1697,403],{"class":321},[309,1699,620],{"class":325},[309,1701,738],{"class":509},[309,1703,626],{"class":325},[309,1705,1401],{"class":406},[309,1707,410],{"class":325},[309,1709,744],{"class":509},[309,1711,626],{"class":325},[309,1713,1406],{"class":509},[309,1715,646],{"class":325},[309,1717,1718,1721,1724,1726,1728,1730,1732,1734,1736,1738,1740,1742,1744],{"class":100,"line":477},[309,1719,1720],{"class":325},"    ).mock(",[309,1722,1723],{"class":399},"return_value",[309,1725,403],{"class":321},[309,1727,1349],{"class":325},[309,1729,141],{"class":406},[309,1731,410],{"class":325},[309,1733,1356],{"class":399},[309,1735,403],{"class":321},[309,1737,620],{"class":325},[309,1739,812],{"class":509},[309,1741,626],{"class":325},[309,1743,1367],{"class":406},[309,1745,1746],{"class":325},"}))\n",[309,1748,1749],{"class":100,"line":489},[309,1750,368],{"emptyLinePlaceholder":367},[309,1752,1753,1756,1758,1760,1762,1764],{"class":100,"line":498},[309,1754,1755],{"class":321},"    await",[309,1757,1398],{"class":325},[309,1759,1401],{"class":406},[309,1761,410],{"class":325},[309,1763,1406],{"class":509},[309,1765,420],{"class":325},[309,1767,1768],{"class":100,"line":516},[309,1769,368],{"emptyLinePlaceholder":367},[309,1771,1772,1775,1777],{"class":100,"line":531},[309,1773,1774],{"class":325},"    request ",[309,1776,403],{"class":321},[309,1778,1779],{"class":325}," route.calls.last.request\n",[309,1781,1782,1784,1787,1789,1792,1795],{"class":100,"line":556},[309,1783,1393],{"class":321},[309,1785,1786],{"class":325}," request.headers[",[309,1788,623],{"class":509},[309,1790,1791],{"class":325},"].startswith(",[309,1793,1794],{"class":509},"\"Bearer \"",[309,1796,420],{"class":325},[309,1798,1799,1801,1803,1805,1808,1810],{"class":100,"line":562},[309,1800,1393],{"class":321},[309,1802,1786],{"class":325},[309,1804,759],{"class":509},[309,1806,1807],{"class":325},"] ",[309,1809,945],{"class":321},[309,1811,1812],{"class":509}," \"conv-2000-EUR\"\n",[309,1814,1815],{"class":100,"line":567},[309,1816,368],{"emptyLinePlaceholder":367},[309,1818,1819,1822,1824],{"class":100,"line":572},[309,1820,1821],{"class":325},"    body ",[309,1823,403],{"class":321},[309,1825,1826],{"class":325}," json.loads(request.content)\n",[309,1828,1829,1831,1834,1837,1839,1842,1844,1847],{"class":100,"line":583},[309,1830,1393],{"class":321},[309,1832,1833],{"class":406}," isinstance",[309,1835,1836],{"class":325},"(body[",[309,1838,738],{"class":509},[309,1840,1841],{"class":325},"], ",[309,1843,689],{"class":406},[309,1845,1846],{"class":325},")  ",[309,1848,1849],{"class":314},"# cents, never floats\n",[309,1851,1852,1854,1857,1859,1861,1863],{"class":100,"line":594},[309,1853,1393],{"class":321},[309,1855,1856],{"class":325}," body[",[309,1858,744],{"class":509},[309,1860,1807],{"class":325},[309,1862,945],{"class":321},[309,1864,1865],{"class":509}," \"EUR\"\n",[14,1867,1868,1869,1872],{},"The ",[27,1870,1871],{},"json__eq"," lookup is the strict form: any extra or missing key makes the route miss, and the test fails with an unmatched-request error. Use it on the one or two calls where the payload contract is the point of the test, and use plain path routes plus explicit assertions everywhere else. Over-constraining every route turns a single field rename into forty red tests, which trains your team to loosen the mocks instead of reading them.",[14,1874,1875,1876,1879,1880,1883,1884,1888,1889,1892],{},"Do assert on types, not just values. ",[27,1877,1878],{},"isinstance(body[\"amount\"], int)"," catches the day someone divides by 100 upstream and starts sending ",[27,1881,1882],{},"20.0"," where the vendor expects an integer. If your outbound payloads are built by ",[18,1885,1887],{"href":1886},"\u002Fgetting-started-with-python-apis-for-builders\u002Fparsing-json-responses\u002Fvalidating-json-with-pydantic-v2\u002F","Pydantic v2 models",", you can go further and re-validate ",[27,1890,1891],{},"request.content"," against the same model in the test, which turns the mock into a genuine schema check.",[183,1894],{},[186,1896,1898],{"id":1897},"simulating-timeouts-429s-and-the-retry-loop","Simulating timeouts, 429s, and the retry loop",[14,1900,1901,1902,1905],{},"This is where respx pays for itself. Anything you can raise, you can inject: pass an exception instance as ",[27,1903,1904],{},"side_effect"," and the client sees it exactly as it would see a real socket failure. Pass a list and each successive call pops the next item, so a retry sequence becomes a plain, deterministic test.",[300,1907,1909],{"className":302,"code":1908,"language":304,"meta":305,"style":305},"# tests\u002Ftest_failure_modes.py\nimport asyncio\n\nimport httpx\nimport pytest\n\nfrom app.rates_client import convert, convert_with_retry\nfrom tests.conftest import FAKE_BASE\n\npytestmark = pytest.mark.anyio\n\n\n@pytest.mark.respx(base_url=FAKE_BASE)\nasync def test_timeout_propagates(respx_mock, rates_client):\n    respx_mock.post(\"\u002Fv1\u002Fconvert\").mock(\n        side_effect=httpx.ReadTimeout(\"simulated upstream stall\")\n    )\n\n    with pytest.raises(httpx.ReadTimeout):\n        await convert(rates_client, 2000, \"EUR\")\n\n\n@pytest.mark.respx(base_url=FAKE_BASE)\nasync def test_retries_through_two_429s(respx_mock, rates_client, monkeypatch):\n    slept: list[float] = []\n\n    async def no_sleep(seconds: float) -> None:\n        slept.append(seconds)\n\n    monkeypatch.setattr(asyncio, \"sleep\", no_sleep)\n\n    route = respx_mock.post(\"\u002Fv1\u002Fconvert\").mock(\n        side_effect=[\n            httpx.Response(429, headers={\"Retry-After\": \"1\"}),\n            httpx.Response(429, headers={\"Retry-After\": \"1\"}),\n            httpx.Response(200, json={\"converted\": 1837}),\n        ]\n    )\n\n    assert await convert_with_retry(rates_client, 2000, \"EUR\") == 1837\n    assert route.call_count == 3\n    assert slept == [1.0, 1.0]  # honoured Retry-After, did not use blind backoff\n",[27,1910,1911,1916,1922,1926,1932,1938,1942,1953,1963,1967,1975,1979,1983,1997,2008,2017,2032,2036,2040,2048,2063,2067,2071,2085,2097,2111,2115,2135,2140,2144,2155,2159,2171,2180,2206,2228,2250,2255,2259,2263,2284,2295],{"__ignoreMap":305},[309,1912,1913],{"class":100,"line":311},[309,1914,1915],{"class":314},"# tests\u002Ftest_failure_modes.py\n",[309,1917,1918,1920],{"class":100,"line":318},[309,1919,322],{"class":321},[309,1921,326],{"class":325},[309,1923,1924],{"class":100,"line":329},[309,1925,368],{"emptyLinePlaceholder":367},[309,1927,1928,1930],{"class":100,"line":337},[309,1929,322],{"class":321},[309,1931,376],{"class":325},[309,1933,1934,1936],{"class":100,"line":351},[309,1935,322],{"class":321},[309,1937,1036],{"class":325},[309,1939,1940],{"class":100,"line":364},[309,1941,368],{"emptyLinePlaceholder":367},[309,1943,1944,1946,1948,1950],{"class":100,"line":371},[309,1945,340],{"class":321},[309,1947,1047],{"class":325},[309,1949,322],{"class":321},[309,1951,1952],{"class":325}," convert, convert_with_retry\n",[309,1954,1955,1957,1959,1961],{"class":100,"line":379},[309,1956,340],{"class":321},[309,1958,1268],{"class":325},[309,1960,322],{"class":321},[309,1962,1273],{"class":406},[309,1964,1965],{"class":100,"line":384},[309,1966,368],{"emptyLinePlaceholder":367},[309,1968,1969,1971,1973],{"class":100,"line":389},[309,1970,1282],{"class":325},[309,1972,403],{"class":321},[309,1974,1287],{"class":325},[309,1976,1977],{"class":100,"line":423},[309,1978,368],{"emptyLinePlaceholder":367},[309,1980,1981],{"class":100,"line":435},[309,1982,368],{"emptyLinePlaceholder":367},[309,1984,1985,1987,1989,1991,1993,1995],{"class":100,"line":444},[309,1986,1300],{"class":392},[309,1988,396],{"class":325},[309,1990,1305],{"class":399},[309,1992,403],{"class":321},[309,1994,1061],{"class":406},[309,1996,420],{"class":325},[309,1998,1999,2001,2003,2006],{"class":100,"line":452},[309,2000,677],{"class":321},[309,2002,680],{"class":321},[309,2004,2005],{"class":392}," test_timeout_propagates",[309,2007,1323],{"class":325},[309,2009,2010,2013,2015],{"class":100,"line":461},[309,2011,2012],{"class":325},"    respx_mock.post(",[309,2014,1336],{"class":509},[309,2016,1339],{"class":325},[309,2018,2019,2022,2024,2027,2030],{"class":100,"line":466},[309,2020,2021],{"class":399},"        side_effect",[309,2023,403],{"class":321},[309,2025,2026],{"class":325},"httpx.ReadTimeout(",[309,2028,2029],{"class":509},"\"simulated upstream stall\"",[309,2031,420],{"class":325},[309,2033,2034],{"class":100,"line":471},[309,2035,559],{"class":325},[309,2037,2038],{"class":100,"line":477},[309,2039,368],{"emptyLinePlaceholder":367},[309,2041,2042,2045],{"class":100,"line":489},[309,2043,2044],{"class":321},"    with",[309,2046,2047],{"class":325}," pytest.raises(httpx.ReadTimeout):\n",[309,2049,2050,2053,2055,2057,2059,2061],{"class":100,"line":498},[309,2051,2052],{"class":321},"        await",[309,2054,1398],{"class":325},[309,2056,1401],{"class":406},[309,2058,410],{"class":325},[309,2060,1406],{"class":509},[309,2062,420],{"class":325},[309,2064,2065],{"class":100,"line":516},[309,2066,368],{"emptyLinePlaceholder":367},[309,2068,2069],{"class":100,"line":531},[309,2070,368],{"emptyLinePlaceholder":367},[309,2072,2073,2075,2077,2079,2081,2083],{"class":100,"line":556},[309,2074,1300],{"class":392},[309,2076,396],{"class":325},[309,2078,1305],{"class":399},[309,2080,403],{"class":321},[309,2082,1061],{"class":406},[309,2084,420],{"class":325},[309,2086,2087,2089,2091,2094],{"class":100,"line":562},[309,2088,677],{"class":321},[309,2090,680],{"class":321},[309,2092,2093],{"class":392}," test_retries_through_two_429s",[309,2095,2096],{"class":325},"(respx_mock, rates_client, monkeypatch):\n",[309,2098,2099,2102,2104,2106,2108],{"class":100,"line":567},[309,2100,2101],{"class":325},"    slept: list[",[309,2103,539],{"class":406},[309,2105,1807],{"class":325},[309,2107,403],{"class":321},[309,2109,2110],{"class":325}," []\n",[309,2112,2113],{"class":100,"line":572},[309,2114,368],{"emptyLinePlaceholder":367},[309,2116,2117,2119,2121,2124,2127,2129,2131,2133],{"class":100,"line":583},[309,2118,1187],{"class":321},[309,2120,680],{"class":321},[309,2122,2123],{"class":392}," no_sleep",[309,2125,2126],{"class":325},"(seconds: ",[309,2128,539],{"class":406},[309,2130,698],{"class":325},[309,2132,276],{"class":406},[309,2134,432],{"class":325},[309,2136,2137],{"class":100,"line":594},[309,2138,2139],{"class":325},"        slept.append(seconds)\n",[309,2141,2142],{"class":100,"line":602},[309,2143,368],{"emptyLinePlaceholder":367},[309,2145,2146,2149,2152],{"class":100,"line":612},[309,2147,2148],{"class":325},"    monkeypatch.setattr(asyncio, ",[309,2150,2151],{"class":509},"\"sleep\"",[309,2153,2154],{"class":325},", no_sleep)\n",[309,2156,2157],{"class":100,"line":649},[309,2158,368],{"emptyLinePlaceholder":367},[309,2160,2161,2163,2165,2167,2169],{"class":100,"line":659},[309,2162,1328],{"class":325},[309,2164,403],{"class":321},[309,2166,1333],{"class":325},[309,2168,1336],{"class":509},[309,2170,1339],{"class":325},[309,2172,2173,2175,2177],{"class":100,"line":664},[309,2174,2021],{"class":399},[309,2176,403],{"class":321},[309,2178,2179],{"class":325},"[\n",[309,2181,2182,2185,2188,2190,2193,2195,2197,2199,2201,2203],{"class":100,"line":669},[309,2183,2184],{"class":325},"            httpx.Response(",[309,2186,2187],{"class":406},"429",[309,2189,410],{"class":325},[309,2191,2192],{"class":399},"headers",[309,2194,403],{"class":321},[309,2196,620],{"class":325},[309,2198,978],{"class":509},[309,2200,626],{"class":325},[309,2202,983],{"class":509},[309,2204,2205],{"class":325},"}),\n",[309,2207,2208,2210,2212,2214,2216,2218,2220,2222,2224,2226],{"class":100,"line":674},[309,2209,2184],{"class":325},[309,2211,2187],{"class":406},[309,2213,410],{"class":325},[309,2215,2192],{"class":399},[309,2217,403],{"class":321},[309,2219,620],{"class":325},[309,2221,978],{"class":509},[309,2223,626],{"class":325},[309,2225,983],{"class":509},[309,2227,2205],{"class":325},[309,2229,2230,2232,2234,2236,2238,2240,2242,2244,2246,2248],{"class":100,"line":705},[309,2231,2184],{"class":325},[309,2233,141],{"class":406},[309,2235,410],{"class":325},[309,2237,1356],{"class":399},[309,2239,403],{"class":321},[309,2241,620],{"class":325},[309,2243,812],{"class":509},[309,2245,626],{"class":325},[309,2247,1367],{"class":406},[309,2249,2205],{"class":325},[309,2251,2252],{"class":100,"line":719},[309,2253,2254],{"class":325},"        ]\n",[309,2256,2257],{"class":100,"line":728},[309,2258,559],{"class":325},[309,2260,2261],{"class":100,"line":750},[309,2262,368],{"emptyLinePlaceholder":367},[309,2264,2265,2267,2269,2272,2274,2276,2278,2280,2282],{"class":100,"line":790},[309,2266,1393],{"class":321},[309,2268,713],{"class":321},[309,2270,2271],{"class":325}," convert_with_retry(rates_client, ",[309,2273,1401],{"class":406},[309,2275,410],{"class":325},[309,2277,1406],{"class":509},[309,2279,1409],{"class":325},[309,2281,945],{"class":321},[309,2283,1414],{"class":406},[309,2285,2286,2288,2290,2292],{"class":100,"line":795},[309,2287,1393],{"class":321},[309,2289,1421],{"class":325},[309,2291,945],{"class":321},[309,2293,2294],{"class":406}," 3\n",[309,2296,2297,2299,2302,2304,2307,2310,2312,2314,2317],{"class":100,"line":801},[309,2298,1393],{"class":321},[309,2300,2301],{"class":325}," slept ",[309,2303,945],{"class":321},[309,2305,2306],{"class":325}," [",[309,2308,2309],{"class":406},"1.0",[309,2311,410],{"class":325},[309,2313,2309],{"class":406},[309,2315,2316],{"class":325},"]  ",[309,2318,2319],{"class":314},"# honoured Retry-After, did not use blind backoff\n",[14,2321,2322,2323,2326,2327,2331,2332,2335,2336,2340,2341,2345],{},"Patching ",[27,2324,2325],{},"asyncio.sleep"," is the trick that keeps this test instant. Without it, a realistic backoff schedule turns three retries into nearly two seconds of wall clock, and a suite with thirty such tests spends a minute doing nothing. Capturing the sleep durations also lets you assert the ",[2328,2329,2330],"em",{},"policy",", not just the outcome — this test proves the client honoured ",[27,2333,2334],{},"Retry-After"," rather than falling back to a fixed delay, which is the behaviour that keeps you inside a vendor's quota. If you drive retries with tenacity, the same pattern applies; see ",[18,2337,2339],{"href":2338},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fretrying-failed-http-requests-with-tenacity\u002F","retrying failed HTTP requests with tenacity"," for the decorator form, and ",[18,2342,2344],{"href":2343},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fdebugging-429-too-many-requests-errors\u002F","debugging 429 errors"," for reading the headers correctly.",[32,2347,41,2351,41,2354,41,2357,41,2359,41,2362,41,2366,41,2369,41,2373,41,2376,41,2378,41,2381,41,2384,41,2386,41,2389,41,2396,41,2399,41,2401,41,2404,41,2407,41,2409,41,2412,41,2415,41,2419],{"viewBox":34,"role":35,"ariaLabelledBy":2348,"xmlns":39,"style":40},[2349,2350],"respx-retry-t","respx-retry-d",[43,2352,2353],{"id":2349},"Retry timeline under two 429 responses",[47,2355,2356],{"id":2350},"Timeline of three attempts at 0, 1 and 2 seconds. The first two return 429 with a Retry-After of one second and the third returns 200. Real elapsed time is two seconds; with a patched sleep the same test runs in about four milliseconds.",[70,2358],{"x":72,"y":72,"width":73,"height":74,"fill":75},[100,2360],{"x1":81,"y1":141,"x2":2361,"y2":141,"stroke":84,"style":85},"660",[2363,2364],"circle",{"cx":81,"cy":141,"r":59,"fill":75,"stroke":142,"style":2365},"stroke-width:3;",[87,2367,2187],{"x":81,"y":2368,"fill":91,"style":173},"176",[87,2370,2372],{"x":81,"y":2371,"fill":68,"style":173},"228","0.0 s",[2363,2374],{"cx":2375,"cy":141,"r":59,"fill":75,"stroke":142,"style":2365},"333",[87,2377,2187],{"x":2375,"y":2368,"fill":91,"style":173},[87,2379,2380],{"x":2375,"y":2371,"fill":68,"style":173},"1.0 s",[2363,2382],{"cx":2383,"cy":141,"r":59,"fill":75,"stroke":137,"style":2365},"606",[87,2385,141],{"x":2383,"y":2368,"fill":91,"style":173},[87,2387,2388],{"x":2383,"y":2371,"fill":68,"style":173},"2.0 s",[70,2390],{"x":1491,"y":2391,"width":2392,"height":2393,"rx":2394,"fill":83,"stroke":84,"style":2395},"192","249","16","4","stroke-width:1;",[87,2397,2398],{"x":160,"y":122,"fill":68,"style":97},"Retry-After: 1",[100,2400],{"x1":160,"y1":1528,"x2":160,"y2":1485,"stroke":84,"style":2395},[70,2402],{"x":2403,"y":2391,"width":2392,"height":2393,"rx":2394,"fill":83,"stroke":84,"style":2395},"345",[87,2405,2398],{"x":2406,"y":122,"fill":68,"style":97},"469",[100,2408],{"x1":2406,"y1":1528,"x2":2406,"y2":1485,"stroke":84,"style":2395},[87,2410,2411],{"x":121,"y":147,"fill":91,"style":92},"Real client: 2.0 s of waiting per test",[87,2413,2414],{"x":121,"y":152,"fill":91,"style":92},"respx + patched sleep: same 3 calls in ~4 ms",[87,2416,2418],{"x":121,"y":2417,"fill":68,"style":173},"104","30 such tests: 60 s of waiting, or under 0.2 s",[87,2420,2422],{"x":121,"y":2421,"fill":68,"style":173},"272","Shaded spans are the sleeps you assert on, not the sleeps you serve.",[14,2424,2425,2426,2428],{},"Cover four failure modes for every vendor you depend on, because these are the ones that actually page you: a connect timeout, a read timeout mid-stream, a 429 with ",[27,2427,2334],{},", and a 500 that never recovers. The fourth matters most commercially — it decides whether a vendor outage degrades one feature or takes your whole API down. Write the test that asserts your endpoint still returns a useful response when the vendor is dead, then make it pass.",[183,2430],{},[186,2432,2434],{"id":2433},"when-to-record-real-responses-instead","When to record real responses instead",[14,2436,2437,2438,2441],{},"Hand-written mocks encode what you ",[2328,2439,2440],{},"believe"," the vendor returns. Belief drifts. The fix is not to abandon respx for full HTTP cassettes — cassette libraries record headers, timings and cookies you never asserted on, and those recordings rot into unreadable diffs. Record the part that carries meaning: the response body.",[14,2443,2444,2445,2448],{},"Capture a real payload once with a small script that hits the vendor sandbox, redacts secrets, and writes JSON into ",[27,2446,2447],{},"tests\u002Ffixtures\u002F",". Load those files into respx responses so your routes replay a genuine payload while the routing stays declarative and readable.",[300,2450,2452],{"className":302,"code":2451,"language":304,"meta":305,"style":305},"# tests\u002Ftest_recorded_payloads.py\nimport json\nimport os\nfrom pathlib import Path\n\nimport httpx\nimport pytest\n\nfrom app.rates_client import convert\nfrom tests.conftest import FAKE_BASE\n\nFIXTURES = Path(os.getenv(\"TEST_FIXTURE_DIR\", \"tests\u002Ffixtures\"))\npytestmark = pytest.mark.anyio\n\n\ndef load(name: str) -> dict:\n    return json.loads((FIXTURES \u002F name).read_text(encoding=\"utf-8\"))\n\n\n@pytest.mark.respx(base_url=FAKE_BASE)\nasync def test_parses_recorded_vendor_payload(respx_mock, rates_client):\n    respx_mock.post(\"\u002Fv1\u002Fconvert\").mock(\n        return_value=httpx.Response(200, json=load(\"convert_200.json\"))\n    )\n    assert await convert(rates_client, 2000, \"EUR\") == 1837\n\n\n@pytest.mark.skipif(\n    not os.getenv(\"RUN_CONTRACT_TESTS\"), reason=\"nightly job only\"\n)\n@pytest.mark.anyio\nasync def test_live_contract_still_matches_fixture():\n    \"\"\"Hits the real sandbox. Fails when the vendor changes shape.\"\"\"\n    async with httpx.AsyncClient(\n        base_url=os.environ[\"RATES_SANDBOX_BASE\"],\n        headers={\"Authorization\": f\"Bearer {os.environ['RATES_SANDBOX_KEY']}\"},\n    ) as client:\n        live = await client.post(\"\u002Fv1\u002Fconvert\", json={\"amount\": 2000, \"to\": \"EUR\"})\n    assert live.status_code == 200\n    assert set(live.json()) >= set(load(\"convert_200.json\"))\n",[27,2453,2454,2459,2465,2471,2483,2487,2493,2499,2503,2513,2523,2527,2547,2555,2559,2563,2582,2607,2611,2615,2629,2640,2648,2672,2676,2696,2700,2704,2711,2732,2736,2741,2752,2757,2765,2778,2810,2819,2857,2869],{"__ignoreMap":305},[309,2455,2456],{"class":100,"line":311},[309,2457,2458],{"class":314},"# tests\u002Ftest_recorded_payloads.py\n",[309,2460,2461,2463],{"class":100,"line":318},[309,2462,322],{"class":321},[309,2464,1590],{"class":325},[309,2466,2467,2469],{"class":100,"line":329},[309,2468,322],{"class":321},[309,2470,334],{"class":325},[309,2472,2473,2475,2478,2480],{"class":100,"line":337},[309,2474,340],{"class":321},[309,2476,2477],{"class":325}," pathlib ",[309,2479,322],{"class":321},[309,2481,2482],{"class":325}," Path\n",[309,2484,2485],{"class":100,"line":351},[309,2486,368],{"emptyLinePlaceholder":367},[309,2488,2489,2491],{"class":100,"line":364},[309,2490,322],{"class":321},[309,2492,376],{"class":325},[309,2494,2495,2497],{"class":100,"line":371},[309,2496,322],{"class":321},[309,2498,1036],{"class":325},[309,2500,2501],{"class":100,"line":379},[309,2502,368],{"emptyLinePlaceholder":367},[309,2504,2505,2507,2509,2511],{"class":100,"line":384},[309,2506,340],{"class":321},[309,2508,1047],{"class":325},[309,2510,322],{"class":321},[309,2512,1261],{"class":325},[309,2514,2515,2517,2519,2521],{"class":100,"line":389},[309,2516,340],{"class":321},[309,2518,1268],{"class":325},[309,2520,322],{"class":321},[309,2522,1273],{"class":406},[309,2524,2525],{"class":100,"line":423},[309,2526,368],{"emptyLinePlaceholder":367},[309,2528,2529,2532,2534,2537,2540,2542,2545],{"class":100,"line":435},[309,2530,2531],{"class":406},"FIXTURES",[309,2533,858],{"class":321},[309,2535,2536],{"class":325}," Path(os.getenv(",[309,2538,2539],{"class":509},"\"TEST_FIXTURE_DIR\"",[309,2541,410],{"class":325},[309,2543,2544],{"class":509},"\"tests\u002Ffixtures\"",[309,2546,1143],{"class":325},[309,2548,2549,2551,2553],{"class":100,"line":444},[309,2550,1282],{"class":325},[309,2552,403],{"class":321},[309,2554,1287],{"class":325},[309,2556,2557],{"class":100,"line":452},[309,2558,368],{"emptyLinePlaceholder":367},[309,2560,2561],{"class":100,"line":461},[309,2562,368],{"emptyLinePlaceholder":367},[309,2564,2565,2567,2570,2573,2575,2577,2580],{"class":100,"line":466},[309,2566,480],{"class":321},[309,2568,2569],{"class":392}," load",[309,2571,2572],{"class":325},"(name: ",[309,2574,695],{"class":406},[309,2576,698],{"class":325},[309,2578,2579],{"class":406},"dict",[309,2581,432],{"class":325},[309,2583,2584,2586,2589,2591,2594,2597,2600,2602,2605],{"class":100,"line":471},[309,2585,492],{"class":321},[309,2587,2588],{"class":325}," json.loads((",[309,2590,2531],{"class":406},[309,2592,2593],{"class":321}," \u002F",[309,2595,2596],{"class":325}," name).read_text(",[309,2598,2599],{"class":399},"encoding",[309,2601,403],{"class":321},[309,2603,2604],{"class":509},"\"utf-8\"",[309,2606,1143],{"class":325},[309,2608,2609],{"class":100,"line":477},[309,2610,368],{"emptyLinePlaceholder":367},[309,2612,2613],{"class":100,"line":489},[309,2614,368],{"emptyLinePlaceholder":367},[309,2616,2617,2619,2621,2623,2625,2627],{"class":100,"line":498},[309,2618,1300],{"class":392},[309,2620,396],{"class":325},[309,2622,1305],{"class":399},[309,2624,403],{"class":321},[309,2626,1061],{"class":406},[309,2628,420],{"class":325},[309,2630,2631,2633,2635,2638],{"class":100,"line":516},[309,2632,677],{"class":321},[309,2634,680],{"class":321},[309,2636,2637],{"class":392}," test_parses_recorded_vendor_payload",[309,2639,1323],{"class":325},[309,2641,2642,2644,2646],{"class":100,"line":531},[309,2643,2012],{"class":325},[309,2645,1336],{"class":509},[309,2647,1339],{"class":325},[309,2649,2650,2652,2654,2656,2658,2660,2662,2664,2667,2670],{"class":100,"line":556},[309,2651,1344],{"class":399},[309,2653,403],{"class":321},[309,2655,1349],{"class":325},[309,2657,141],{"class":406},[309,2659,410],{"class":325},[309,2661,1356],{"class":399},[309,2663,403],{"class":321},[309,2665,2666],{"class":325},"load(",[309,2668,2669],{"class":509},"\"convert_200.json\"",[309,2671,1143],{"class":325},[309,2673,2674],{"class":100,"line":562},[309,2675,559],{"class":325},[309,2677,2678,2680,2682,2684,2686,2688,2690,2692,2694],{"class":100,"line":567},[309,2679,1393],{"class":321},[309,2681,713],{"class":321},[309,2683,1398],{"class":325},[309,2685,1401],{"class":406},[309,2687,410],{"class":325},[309,2689,1406],{"class":509},[309,2691,1409],{"class":325},[309,2693,945],{"class":321},[309,2695,1414],{"class":406},[309,2697,2698],{"class":100,"line":572},[309,2699,368],{"emptyLinePlaceholder":367},[309,2701,2702],{"class":100,"line":583},[309,2703,368],{"emptyLinePlaceholder":367},[309,2705,2706,2709],{"class":100,"line":594},[309,2707,2708],{"class":392},"@pytest.mark.skipif",[309,2710,838],{"class":325},[309,2712,2713,2716,2718,2721,2724,2727,2729],{"class":100,"line":602},[309,2714,2715],{"class":321},"    not",[309,2717,1066],{"class":325},[309,2719,2720],{"class":509},"\"RUN_CONTRACT_TESTS\"",[309,2722,2723],{"class":325},"), ",[309,2725,2726],{"class":399},"reason",[309,2728,403],{"class":321},[309,2730,2731],{"class":509},"\"nightly job only\"\n",[309,2733,2734],{"class":100,"line":612},[309,2735,420],{"class":325},[309,2737,2738],{"class":100,"line":649},[309,2739,2740],{"class":392},"@pytest.mark.anyio\n",[309,2742,2743,2745,2747,2750],{"class":100,"line":659},[309,2744,677],{"class":321},[309,2746,680],{"class":321},[309,2748,2749],{"class":392}," test_live_contract_still_matches_fixture",[309,2751,1182],{"class":325},[309,2753,2754],{"class":100,"line":664},[309,2755,2756],{"class":509},"    \"\"\"Hits the real sandbox. Fails when the vendor changes shape.\"\"\"\n",[309,2758,2759,2761,2763],{"class":100,"line":669},[309,2760,1187],{"class":321},[309,2762,1190],{"class":321},[309,2764,599],{"class":325},[309,2766,2767,2769,2771,2773,2776],{"class":100,"line":674},[309,2768,501],{"class":399},[309,2770,403],{"class":321},[309,2772,506],{"class":325},[309,2774,2775],{"class":509},"\"RATES_SANDBOX_BASE\"",[309,2777,513],{"class":325},[309,2779,2780,2782,2784,2786,2788,2790,2792,2794,2796,2798,2801,2804,2806,2808],{"class":100,"line":705},[309,2781,615],{"class":399},[309,2783,403],{"class":321},[309,2785,620],{"class":325},[309,2787,623],{"class":509},[309,2789,626],{"class":325},[309,2791,629],{"class":321},[309,2793,632],{"class":509},[309,2795,620],{"class":406},[309,2797,506],{"class":325},[309,2799,2800],{"class":509},"'RATES_SANDBOX_KEY'",[309,2802,2803],{"class":325},"]",[309,2805,640],{"class":406},[309,2807,643],{"class":509},[309,2809,646],{"class":325},[309,2811,2812,2815,2817],{"class":100,"line":719},[309,2813,2814],{"class":325},"    ) ",[309,2816,919],{"class":321},[309,2818,1198],{"class":325},[309,2820,2821,2824,2826,2828,2831,2833,2835,2837,2839,2841,2843,2845,2847,2849,2851,2853,2855],{"class":100,"line":728},[309,2822,2823],{"class":325},"        live ",[309,2825,403],{"class":321},[309,2827,713],{"class":321},[309,2829,2830],{"class":325}," client.post(",[309,2832,1336],{"class":509},[309,2834,410],{"class":325},[309,2836,1356],{"class":399},[309,2838,403],{"class":321},[309,2840,620],{"class":325},[309,2842,738],{"class":509},[309,2844,626],{"class":325},[309,2846,1401],{"class":406},[309,2848,410],{"class":325},[309,2850,744],{"class":509},[309,2852,626],{"class":325},[309,2854,1406],{"class":509},[309,2856,1380],{"class":325},[309,2858,2859,2861,2864,2866],{"class":100,"line":750},[309,2860,1393],{"class":321},[309,2862,2863],{"class":325}," live.status_code ",[309,2865,945],{"class":321},[309,2867,2868],{"class":406}," 200\n",[309,2870,2871,2873,2876,2879,2882,2884,2887,2889],{"class":100,"line":790},[309,2872,1393],{"class":321},[309,2874,2875],{"class":406}," set",[309,2877,2878],{"class":325},"(live.json()) ",[309,2880,2881],{"class":321},">=",[309,2883,2875],{"class":406},[309,2885,2886],{"class":325},"(load(",[309,2888,2669],{"class":509},[309,2890,1143],{"class":325},[14,2892,2893,2894,2897],{},"That second test is the whole strategy in eight lines. It never runs in your pull-request suite, so it cannot flake on a vendor outage and cannot slow anyone down. It runs nightly with ",[27,2895,2896],{},"RUN_CONTRACT_TESTS=1",", and when the vendor drops a field or renames one, you find out from a scheduled job at 3am instead of from a customer. Comparing key sets rather than whole payloads keeps it stable against harmless additions while still catching removals.",[32,2899,41,2904,41,2907,41,2910,41,2917,41,2919,41,2923,41,2926,41,2930,41,2934,41,2936,41,2940,41,2945,41,2950,41,2954,41,2958,41,2962,41,2965,41,2968,41,2971,41,2974,41,2978,41,2981,41,2984,41,2987,41,2989,41,2992],{"viewBox":2900,"role":35,"ariaLabelledBy":2901,"xmlns":39,"style":40},"0 0 720 320",[2902,2903],"respx-choose-t","respx-choose-d",[43,2905,2906],{"id":2902},"Choosing between a hand-written route, a recorded payload, and a live call",[47,2908,2909],{"id":2903},"Decision tree: simple or error-path responses use a hand-written respx route; complex vendor payloads use a recorded JSON fixture replayed through respx; verifying the vendor contract uses a nightly live sandbox call outside the pull-request suite.",[51,2911,53,2912,41],{},[55,2913,63,2915,53],{"id":2914,"viewBox":58,"refX":59,"refY":60,"markerWidth":61,"markerHeight":61,"orient":62},"respx-choose-arrow",[65,2916],{"d":67,"fill":68},[70,2918],{"x":72,"y":72,"width":73,"height":1556,"fill":75},[70,2920],{"x":1490,"y":78,"width":2921,"height":2922,"rx":82,"fill":75,"stroke":123,"style":85},"208","54",[87,2924,2925],{"x":121,"y":1502,"fill":91,"style":92},"What is this test",[87,2927,2929],{"x":121,"y":2928,"fill":91,"style":92},"62","actually proving?",[100,2931],{"x1":1503,"y1":152,"x2":80,"y2":2932,"stroke":68,"style":2933},"126","stroke-width:2;marker-end:url(#respx-choose-arrow);",[100,2935],{"x1":121,"y1":152,"x2":121,"y2":2932,"stroke":68,"style":2933},[100,2937],{"x1":2938,"y1":152,"x2":2939,"y2":2932,"stroke":68,"style":2933},"390","580",[70,2941],{"x":2942,"y":1463,"width":2943,"height":2944,"rx":82,"fill":75,"stroke":137,"style":85},"24","216","94",[87,2946,2949],{"x":2947,"y":2948,"fill":68,"style":173},"132","154","my branching logic",[87,2951,2953],{"x":2947,"y":2952,"fill":91,"style":92},"180","Hand-written route",[87,2955,2957],{"x":2947,"y":2956,"fill":68,"style":173},"204","errors, 429s, timeouts",[70,2959],{"x":2960,"y":1463,"width":2943,"height":2944,"rx":82,"fill":75,"stroke":2961,"style":85},"252","var(--c-yellow)",[87,2963,2964],{"x":121,"y":2948,"fill":68,"style":173},"my parsing of a payload",[87,2966,2967],{"x":121,"y":2952,"fill":91,"style":92},"Recorded JSON fixture",[87,2969,2970],{"x":121,"y":2956,"fill":68,"style":173},"replayed via respx",[70,2972],{"x":2973,"y":1463,"width":2943,"height":2944,"rx":82,"fill":75,"stroke":142,"style":85},"480",[87,2975,2977],{"x":2976,"y":2948,"fill":68,"style":173},"588","the vendor keeps promises",[87,2979,2980],{"x":2976,"y":2952,"fill":91,"style":92},"Live sandbox call",[87,2982,2983],{"x":2976,"y":2956,"fill":68,"style":173},"nightly job only",[87,2985,2986],{"x":2947,"y":1490,"fill":68,"style":173},"runs on every commit",[87,2988,2986],{"x":121,"y":1490,"fill":68,"style":173},[87,2990,2991],{"x":2976,"y":1490,"fill":68,"style":173},"never gates a merge",[87,2993,2995],{"x":121,"y":2994,"fill":68,"style":173},"294","Keep vendor outages out of the path that blocks your deploys.",[183,2997],{},[186,2999,3001],{"id":3000},"when-to-choose-respx-and-when-to-skip-it","When to choose respx, and when to skip it",[14,3003,3004,3005,3009],{},"Choose respx whenever your code is the HTTP client and you are past the toy stage — which for a commercial API is essentially day one. It costs one dependency and a few lines per test, and it removes an entire class of flaky failures from your build. At any traffic level it is the right default, because the cost model is flat: mocked calls consume no vendor quota, so a suite that runs 400 times a day costs nothing in metered API spend. That last point is real money when you depend on an ",[18,3006,3008],{"href":3007},"\u002Fautomating-side-hustle-operations-with-apis\u002Fautomating-ai-workflows-with-python-apis\u002Fcontrolling-llm-api-costs-in-production\u002F","LLM provider whose per-token costs"," make an accidentally-live test suite a genuine line item.",[14,3011,3012,3013,3017,3018,3021,3022,3025,3026,3030],{},"Skip respx in two situations. First, when the third party ships a first-party test harness that models state you would otherwise have to reimplement — Stripe's test clocks are the clear example, and ",[18,3014,3016],{"href":3015},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks\u002F","testing Stripe integrations with test clocks"," explains why you should not hand-mock subscription lifecycles. Second, when the calls are ",[2328,3019,3020],{},"inbound"," rather than outbound. respx intercepts requests your process makes; testing your own endpoints is the job of an httpx ",[27,3023,3024],{},"ASGITransport"," client, covered in ",[18,3027,3029],{"href":3028},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx\u002F","testing async FastAPI endpoints with httpx",".",[3032,3033,3035],"h3",{"id":3034},"migrating-an-existing-suite","Migrating an existing suite",[14,3037,3038,3039,3041,3042,3045,3046,3048,3049,3052,3053,3057],{},"If you already have monkeypatched client functions, switch incrementally in four steps. First, add ",[27,3040,1216],{}," to your dev dependencies and turn on the plugin's assertions by leaving the defaults. Second, pick the module with the most ",[27,3043,3044],{},"monkeypatch.setattr"," calls against your own HTTP helpers and replace each one with a route that mocks the same endpoint. Third, delete the now-unused fake return values, and let ",[27,3047,1436],{}," tell you which routes were never exercised. Fourth, add one recorded-payload test per vendor endpoint plus a single nightly contract test. If you are still on ",[27,3050,3051],{},"requests"," rather than httpx, migrate the client first — ",[18,3054,3056],{"href":3055},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fhttpx-vs-requests-for-async\u002F","httpx versus requests for async work"," covers that move, and it is a prerequisite because respx only patches httpx transports.",[183,3059],{},[186,3061,3063],{"id":3062},"builder-verdict","Builder verdict",[14,3065,3066,3067,3070],{},"respx wins, and it is not close. For the price of one dev dependency you get tests that exercise your real client code, run in microseconds, never burn vendor quota, and can reproduce a timeout or a rate-limit storm on demand — failures you would otherwise ship untested because they are hard to trigger. Compared with a local fake server it saves you a process and a port; compared with monkeypatching it tests the code that actually breaks; compared with a live sandbox it keeps a vendor's bad afternoon out of your deploy pipeline. The shipping-velocity argument is the strongest one: a suite that can simulate a 429 in four milliseconds means you write the resilience test ",[2328,3068,3069],{},"before"," the incident rather than after it, and resilience you tested is the difference between a degraded feature and a refunded month. Pair it with recorded JSON payloads for the shapes you parse and one nightly live contract job per vendor, and you have covered both halves of the risk — your code being wrong, and their server changing — for roughly an hour of setup.",[183,3072],{},[186,3074,3076],{"id":3075},"faq","FAQ",[14,3078,3079,3082],{},[224,3080,3081],{},"Does mocking with respx cost me vendor API quota during CI?","\nNo, and that is a large part of its commercial case. Mocked calls never leave the process, so a suite running hundreds of times a day consumes zero metered requests. Only the nightly contract job touches the vendor, which keeps your test-driven spend to a handful of calls a day instead of thousands.",[14,3084,3085,3088],{},[224,3086,3087],{},"How do I keep mocks from drifting after a vendor changes their API?","\nRecord real response bodies as JSON fixtures and replay them through respx, then run one live contract test per endpoint on a schedule that compares the live key set against the fixture. When the vendor renames a field, the nightly job fails and you fix it on your timetable rather than during an outage.",[14,3090,3091,3094],{},[224,3092,3093],{},"Should I mock my payment provider or use its sandbox?","\nUse the provider's own test infrastructure for anything involving state over time — subscriptions, dunning, renewals. Use respx for the thin edges: asserting you sent the right idempotency key, and simulating the 500s and timeouts the sandbox will not produce on demand.",[14,3096,3097,3100],{},[224,3098,3099],{},"Will these tests still pass after I rotate an API key?","\nYes, because the client reads its key from the environment and the fixture injects a fake value. Rotation only affects the nightly contract job, so store that credential as a separate secret and treat its failure as an alert about the rotation, not about your code.",[14,3102,3103,3106],{},[224,3104,3105],{},"Is it risky to migrate a large monkeypatched suite to respx?","\nLow risk, because the two coexist. Convert one module at a time and keep the old doubles until each route is green. The main surprise is that respx fails tests that previously passed, usually because your client was building a URL or header you never asserted on — that is the bug it was hired to find.",[183,3108],{},[186,3110,3112],{"id":3111},"related","Related",[14,3114,3115],{},[224,3116,3117],{},"Same track:",[3119,3120,3121,3127,3133],"ul",{},[3122,3123,3124,3126],"li",{},[18,3125,21],{"href":20}," — the parent guide with the fixtures, event-loop config and CI job these tests plug into.",[3122,3128,3129,3132],{},[18,3130,3131],{"href":3028},"Testing Async FastAPI Endpoints with httpx"," — the inbound half: testing your own routes through an ASGI transport.",[3122,3134,3135,3139],{},[18,3136,3138],{"href":3137},"\u002Fscaling-and-operating-production-python-apis\u002F","Scaling and Operating Production Python APIs"," — the wider operations area this page sits in.",[14,3141,3142],{},[224,3143,3144],{},"Other tracks:",[3119,3146,3147,3153],{},[3122,3148,3149,3152],{},[18,3150,3151],{"href":2338},"Retrying Failed HTTP Requests with tenacity"," — the retry policy you assert on in the 429 test.",[3122,3154,3155,3158],{},[18,3156,3157],{"href":3015},"Testing Stripe Integrations with Test Clocks"," — when a vendor's own harness beats a hand-written mock.",[3160,3161,3162],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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":305,"searchDepth":318,"depth":318,"links":3164},[3165,3166,3167,3168,3169,3170,3173,3174,3175],{"id":188,"depth":318,"text":189},{"id":294,"depth":318,"text":295},{"id":1562,"depth":318,"text":1563},{"id":1897,"depth":318,"text":1898},{"id":2433,"depth":318,"text":2434},{"id":3000,"depth":318,"text":3001,"children":3171},[3172],{"id":3034,"depth":329,"text":3035},{"id":3062,"depth":318,"text":3063},{"id":3075,"depth":318,"text":3076},{"id":3111,"depth":318,"text":3112},"Mock third-party HTTP APIs in pytest with respx: route matching, side effects, asserting request payloads, simulating timeouts and 429s, and when to record.","md",{"pageTitle":3179,"type":3180,"datePublished":3181,"dateModified":3181},"Mocking External APIs with respx: Fast, Honest Tests","article","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx",{"title":5,"description":3176},"scaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx\u002Findex","fOLKILjLtd-TeCjRAtCk_Zbx6tCIKl-ecWDukxV-8KE",{"@context":3187,"@type":3188,"mainEntity":3189},"https:\u002F\u002Fschema.org","FAQPage",[3190,3195,3198,3201,3204],{"@type":3191,"name":3081,"acceptedAnswer":3192},"Question",{"@type":3193,"text":3194},"Answer","No, and that is a large part of its commercial case. Mocked calls never leave the process, so a suite running hundreds of times a day consumes zero metered requests. Only the nightly contract job touches the vendor, which keeps your test-driven spend to a handful of calls a day instead of thousands.",{"@type":3191,"name":3087,"acceptedAnswer":3196},{"@type":3193,"text":3197},"Record real response bodies as JSON fixtures and replay them through respx, then run one live contract test per endpoint on a schedule that compares the live key set against the fixture. When the vendor renames a field, the nightly job fails and you fix it on your timetable rather than during an outage.",{"@type":3191,"name":3093,"acceptedAnswer":3199},{"@type":3193,"text":3200},"Use the provider's own test infrastructure for anything involving state over time — subscriptions, dunning, renewals. Use respx for the thin edges: asserting you sent the right idempotency key, and simulating the 500s and timeouts the sandbox will not produce on demand.",{"@type":3191,"name":3099,"acceptedAnswer":3202},{"@type":3193,"text":3203},"Yes, because the client reads its key from the environment and the fixture injects a fake value. Rotation only affects the nightly contract job, so store that credential as a separate secret and treat its failure as an alert about the rotation, not about your code.",{"@type":3191,"name":3105,"acceptedAnswer":3205},{"@type":3193,"text":3206},"Low risk, because the two coexist. Convert one module at a time and keep the old doubles until each route is green. The main surprise is that respx fails tests that previously passed, usually because your client was building a URL or header you never asserted on — that is the bug it was hired to find. ---",1784887028387]