[{"data":1,"prerenderedAt":911},["ShallowReactive",2],{"page-\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fsimulating-timeouts-and-connection-errors\u002F":3},{"id":4,"title":5,"body":6,"description":874,"extension":875,"meta":876,"navigation":107,"path":907,"seo":908,"stem":909,"__hash__":910},"content\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fsimulating-timeouts-and-connection-errors\u002Findex.md","Simulating Timeouts and Connection Errors",{"type":7,"value":8,"toc":863},"minimark",[9,13,25,30,69,73,304,454,458,467,474,478,529,533,536,547,607,610,677,681,688,759,763,766,772,775,779,785,813,819,823,852,859],[10,11,12],"p",{},"Every HTTP client has a failure path — a timeout, a refused connection, a reset halfway through a response — and it is the code that runs during incidents, when correctness matters most. It is also the code tests exercise least, because producing a real timeout means waiting for one, and producing a real connection reset means breaking something. Transport-level fakes solve both: they raise the exact exceptions the HTTP library raises, instantly, on whichever request the test chooses.",[10,14,15,16,20,21,24],{},"Failure-path tests also document behaviour that is otherwise only discoverable during an outage: how many times the client retries, which errors it considers retryable, what the caller receives when it finally gives up. Writing those down as tests turns an operational question — \"what does checkout do when billing is slow?\" — into something answered by reading a test file rather than by waiting for the next incident. The key detail is raising the library's own exception types. Code that catches ",[17,18,19],"code",{},"httpx.ConnectTimeout"," does not catch a generic ",[17,22,23],{},"TimeoutError",", so a test raising the wrong type exercises a path that never runs in production and misses the one that does.",[26,27,29],"h2",{"id":28},"prerequisites","Prerequisites",[31,32,33,51,61],"ul",{},[34,35,36,39,40,43,44,39,47,50],"li",{},[17,37,38],{},"respx >= 0.21"," for ",[17,41,42],{},"httpx",", or ",[17,45,46],{},"responses >= 0.25",[17,48,49],{},"requests",".",[34,52,53,56,57,60],{},[17,54,55],{},"pytest >= 8.0",", with ",[17,58,59],{},"pytest-asyncio"," for async clients.",[34,62,63,64,50],{},"The transport-faking approach from ",[65,66,68],"a",{"href":67},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fmocking-httpx-clients-with-respx\u002F","mocking httpx clients with respx",[26,70,72],{"id":71},"solution","Solution",[74,75,80],"pre",{"className":76,"code":77,"language":78,"meta":79,"style":79},"language-python shiki shiki-themes github-light github-dark","import httpx\nimport pytest\nimport respx\n\n\n@respx.mock\ndef test_connect_timeout_becomes_a_domain_error():\n    respx.get(\"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\").mock(\n        side_effect=httpx.ConnectTimeout(\"connect timed out\")   # the real type\n    )\n\n    with pytest.raises(BillingUnavailable):\n        BillingClient(\"https:\u002F\u002Fbilling.test\").fetch(\"inv_1\")\n\n\n@respx.mock\ndef test_retry_recovers_after_two_read_timeouts():\n    route = respx.get(\"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\").mock(side_effect=[\n        httpx.ReadTimeout(\"slow\"),\n        httpx.ReadTimeout(\"slow\"),\n        httpx.Response(200, json={\"id\": \"inv_1\", \"total_minor\": 1234}),\n    ])\n\n    invoice = BillingClient(\"https:\u002F\u002Fbilling.test\", retries=3).fetch(\"inv_1\")\n\n    assert invoice.total_minor == 1234\n    assert route.call_count == 3                 # two failures, one success\n\n\n@respx.mock\ndef test_refused_connection_is_not_retried_forever():\n    route = respx.get(\"https:\u002F\u002Fbilling.test\u002Fhealth\").mock(\n        side_effect=httpx.ConnectError(\"connection refused\")\n    )\n\n    with pytest.raises(BillingUnavailable):\n        BillingClient(\"https:\u002F\u002Fbilling.test\", retries=3).health()\n\n    assert route.call_count == 3                 # bounded, not infinite\n","python","",[17,81,82,90,96,102,109,114,120,126,132,138,144,149,155,161,166,171,176,182,188,194,199,205,211,216,222,227,233,239,244,249,254,260,266,272,277,282,287,293,298],{"__ignoreMap":79},[83,84,87],"span",{"class":85,"line":86},"line",1,[83,88,89],{},"import httpx\n",[83,91,93],{"class":85,"line":92},2,[83,94,95],{},"import pytest\n",[83,97,99],{"class":85,"line":98},3,[83,100,101],{},"import respx\n",[83,103,105],{"class":85,"line":104},4,[83,106,108],{"emptyLinePlaceholder":107},true,"\n",[83,110,112],{"class":85,"line":111},5,[83,113,108],{"emptyLinePlaceholder":107},[83,115,117],{"class":85,"line":116},6,[83,118,119],{},"@respx.mock\n",[83,121,123],{"class":85,"line":122},7,[83,124,125],{},"def test_connect_timeout_becomes_a_domain_error():\n",[83,127,129],{"class":85,"line":128},8,[83,130,131],{},"    respx.get(\"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\").mock(\n",[83,133,135],{"class":85,"line":134},9,[83,136,137],{},"        side_effect=httpx.ConnectTimeout(\"connect timed out\")   # the real type\n",[83,139,141],{"class":85,"line":140},10,[83,142,143],{},"    )\n",[83,145,147],{"class":85,"line":146},11,[83,148,108],{"emptyLinePlaceholder":107},[83,150,152],{"class":85,"line":151},12,[83,153,154],{},"    with pytest.raises(BillingUnavailable):\n",[83,156,158],{"class":85,"line":157},13,[83,159,160],{},"        BillingClient(\"https:\u002F\u002Fbilling.test\").fetch(\"inv_1\")\n",[83,162,164],{"class":85,"line":163},14,[83,165,108],{"emptyLinePlaceholder":107},[83,167,169],{"class":85,"line":168},15,[83,170,108],{"emptyLinePlaceholder":107},[83,172,174],{"class":85,"line":173},16,[83,175,119],{},[83,177,179],{"class":85,"line":178},17,[83,180,181],{},"def test_retry_recovers_after_two_read_timeouts():\n",[83,183,185],{"class":85,"line":184},18,[83,186,187],{},"    route = respx.get(\"https:\u002F\u002Fbilling.test\u002Finvoices\u002Finv_1\").mock(side_effect=[\n",[83,189,191],{"class":85,"line":190},19,[83,192,193],{},"        httpx.ReadTimeout(\"slow\"),\n",[83,195,197],{"class":85,"line":196},20,[83,198,193],{},[83,200,202],{"class":85,"line":201},21,[83,203,204],{},"        httpx.Response(200, json={\"id\": \"inv_1\", \"total_minor\": 1234}),\n",[83,206,208],{"class":85,"line":207},22,[83,209,210],{},"    ])\n",[83,212,214],{"class":85,"line":213},23,[83,215,108],{"emptyLinePlaceholder":107},[83,217,219],{"class":85,"line":218},24,[83,220,221],{},"    invoice = BillingClient(\"https:\u002F\u002Fbilling.test\", retries=3).fetch(\"inv_1\")\n",[83,223,225],{"class":85,"line":224},25,[83,226,108],{"emptyLinePlaceholder":107},[83,228,230],{"class":85,"line":229},26,[83,231,232],{},"    assert invoice.total_minor == 1234\n",[83,234,236],{"class":85,"line":235},27,[83,237,238],{},"    assert route.call_count == 3                 # two failures, one success\n",[83,240,242],{"class":85,"line":241},28,[83,243,108],{"emptyLinePlaceholder":107},[83,245,247],{"class":85,"line":246},29,[83,248,108],{"emptyLinePlaceholder":107},[83,250,252],{"class":85,"line":251},30,[83,253,119],{},[83,255,257],{"class":85,"line":256},31,[83,258,259],{},"def test_refused_connection_is_not_retried_forever():\n",[83,261,263],{"class":85,"line":262},32,[83,264,265],{},"    route = respx.get(\"https:\u002F\u002Fbilling.test\u002Fhealth\").mock(\n",[83,267,269],{"class":85,"line":268},33,[83,270,271],{},"        side_effect=httpx.ConnectError(\"connection refused\")\n",[83,273,275],{"class":85,"line":274},34,[83,276,143],{},[83,278,280],{"class":85,"line":279},35,[83,281,108],{"emptyLinePlaceholder":107},[83,283,285],{"class":85,"line":284},36,[83,286,154],{},[83,288,290],{"class":85,"line":289},37,[83,291,292],{},"        BillingClient(\"https:\u002F\u002Fbilling.test\", retries=3).health()\n",[83,294,296],{"class":85,"line":295},38,[83,297,108],{"emptyLinePlaceholder":107},[83,299,301],{"class":85,"line":300},39,[83,302,303],{},"    assert route.call_count == 3                 # bounded, not infinite\n",[305,306,309,450],"figure",{"className":307},[308],"diagram",[310,311,318,319,318,323,318,327,318,345,318,353,318,362,318,370,318,376,318,382,318,385,318,389,318,393,318,397,318,401,318,408,318,413,318,417,318,422,318,426,318,429,318,432,318,436,318,440,318,443,318,446],"svg",{"viewBox":312,"role":313,"ariaLabelledBy":314,"xmlns":317},"0 0 820 262","img",[315,316],"nf-t","nf-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[320,321,322],"title",{"id":315},"Where network failures occur in a request",[324,325,326],"desc",{"id":316},"A request's lifecycle has three failure points. ConnectError or ConnectTimeout happens before any bytes are sent. ReadTimeout happens after the request is sent while waiting for a response. A RemoteProtocolError or read error mid-body happens after a status line has arrived. Each has different retry safety and needs its own test.",[328,329,330,331,318],"defs",{},"\n    ",[332,333,340],"marker",{"id":334,"viewBox":335,"refX":336,"refY":337,"markerWidth":338,"markerHeight":338,"orient":339},"nf-a","0 0 10 10","9","5","7","auto-start-reverse",[341,342],"path",{"d":343,"fill":344},"M0 0 L10 5 L0 10 z","#3d405b",[346,347],"rect",{"x":348,"y":348,"width":349,"height":350,"rx":351,"fill":352},"0","820","262","14","#fffdf8",[354,355,361],"text",{"x":356,"y":357,"textAnchor":358,"fontSize":359,"fontWeight":360,"fill":344},"410","28","middle","16","700","Three failure points, three different meanings",[346,363],{"x":364,"y":365,"width":366,"height":365,"rx":367,"fill":368,"stroke":344,"strokeWidth":369},"26","60","220","10","#f4f1de","1.6",[354,371,375],{"x":372,"y":373,"textAnchor":358,"fontSize":374,"fontWeight":360,"fill":344},"136","95","11.5","connect",[85,377],{"x1":378,"y1":379,"x2":380,"y2":379,"stroke":344,"strokeWidth":369,"markerEnd":381},"250","90","296","url(#nf-a)",[346,383],{"x":384,"y":365,"width":366,"height":365,"rx":367,"fill":368,"stroke":344,"strokeWidth":369},"302",[354,386,388],{"x":387,"y":373,"textAnchor":358,"fontSize":374,"fontWeight":360,"fill":344},"412","send + wait",[85,390],{"x1":391,"y1":379,"x2":392,"y2":379,"stroke":344,"strokeWidth":369,"markerEnd":381},"526","572",[346,394],{"x":395,"y":365,"width":396,"height":365,"rx":367,"fill":368,"stroke":344,"strokeWidth":369},"578","216",[354,398,400],{"x":399,"y":373,"textAnchor":358,"fontSize":374,"fontWeight":360,"fill":344},"686","read body",[346,402],{"x":364,"y":403,"width":366,"height":404,"rx":367,"fill":405,"stroke":406,"strokeWidth":407},"140","100","#e6f0ea","#81b29a","2",[354,409,412],{"x":372,"y":410,"textAnchor":358,"fontSize":411,"fontWeight":360,"fill":344},"164","11","ConnectError \u002F Timeout",[354,414,416],{"x":372,"y":415,"textAnchor":358,"fontSize":411,"fill":344},"188","nothing was sent",[354,418,421],{"x":372,"y":419,"textAnchor":358,"fontSize":411,"fill":420},"212","#2a5f49","always safe to retry",[346,423],{"x":384,"y":403,"width":366,"height":404,"rx":367,"fill":424,"stroke":425,"strokeWidth":407},"#f7f0da","#f2cc8f",[354,427,428],{"x":387,"y":410,"textAnchor":358,"fontSize":411,"fontWeight":360,"fill":344},"ReadTimeout",[354,430,431],{"x":387,"y":415,"textAnchor":358,"fontSize":411,"fill":344},"request may have run",[354,433,435],{"x":387,"y":419,"textAnchor":358,"fontSize":411,"fill":434},"#8a5a00","retry only if idempotent",[346,437],{"x":395,"y":403,"width":396,"height":404,"rx":367,"fill":438,"stroke":439,"strokeWidth":407},"#fbe9e3","#e07a5f",[354,441,442],{"x":399,"y":410,"textAnchor":358,"fontSize":411,"fontWeight":360,"fill":344},"error mid-body",[354,444,445],{"x":399,"y":415,"textAnchor":358,"fontSize":411,"fill":344},"server acted, reply lost",[354,447,449],{"x":399,"y":419,"textAnchor":358,"fontSize":411,"fill":448},"#8f3d22","partial data handling",[451,452,453],"figcaption",{},"A client that treats all three alike will either retry non-idempotent requests after a read timeout or give up on connection errors that were safe to retry.",[26,455,457],{"id":456},"why-this-works","Why this works",[10,459,460,463,464,466],{},[17,461,462],{},"respx"," replaces ",[17,465,42],{},"'s transport, the layer that actually opens sockets. When a route's side effect is an exception, the transport raises it at the point a real network error would surface, so everything above — the client's retry wrapper, its error translation, its logging — runs exactly as in production. The test controls which request fails, how many times, and with which error, without any real network involved.",[10,468,469,470,473],{},"The call history on each route is equally useful. ",[17,471,472],{},"route.calls"," records every request that reached the fake, including headers and body, so a test can assert not only how many attempts were made but what each one sent — which is how idempotency and retry headers are verified below. Because failure is scripted rather than waited for, the tests run in milliseconds. The client's configured timeouts are irrelevant; the exception arrives immediately, carrying the same type and message the client's error handling inspects.",[26,475,477],{"id":476},"edge-cases-and-failure-modes","Edge cases and failure modes",[31,479,480,494,504,510,519],{},[34,481,482,486,487,489,490,493],{},[483,484,485],"strong",{},"Generic exceptions."," Raising ",[17,488,23],{}," or ",[17,491,492],{},"Exception"," bypasses handlers written for the library's types. Always use the library's exception classes.",[34,495,496,499,500,503],{},[483,497,498],{},"Retrying non-idempotent requests."," A ",[17,501,502],{},"POST"," that timed out while reading may already have been processed. Test that the client does not blindly retry it, or that it sends an idempotency key if it does.",[34,505,506,509],{},[483,507,508],{},"Timeouts on the fake itself."," Wrapping a route in a real sleep to \"simulate slowness\" makes tests slow and still does not trigger the client's timeout reliably. Raise the timeout exception directly.",[34,511,512,515,516,518],{},[483,513,514],{},"Unmatched routes."," ",[17,517,462],{}," raises for requests that match no route by default, which is the right behaviour; do not disable it, or an unexpected URL silently succeeds.",[34,520,521,524,525,528],{},[483,522,523],{},"Async clients."," The same routes work with ",[17,526,527],{},"httpx.AsyncClient","; the exceptions are raised on await. Run the tests under an async runner.",[26,530,532],{"id":531},"testing-idempotency-under-read-timeouts","Testing idempotency under read timeouts",[10,534,535],{},"The middle column of the diagram above is where real incidents come from. A read timeout means the request left the client and the response never came back — so the server may have charged the card, created the order, or sent the email. Retrying blindly doubles the effect. The tests that matter are the ones that pin down what the client does in exactly that case.",[10,537,538,539,542,543,546],{},"For safe methods — ",[17,540,541],{},"GET",", ",[17,544,545],{},"HEAD"," — retrying after a read timeout is fine, and the test is the retry-recovers test above. For unsafe methods, one of two behaviours is correct, and each gets its own test. Either the client does not retry at all and surfaces an ambiguous-outcome error the caller must handle; or it retries with an idempotency key that lets the server recognise the duplicate. The second is testable directly: record the headers of every attempt and assert they carry the same key.",[74,548,550],{"className":76,"code":549,"language":78,"meta":79,"style":79},"@respx.mock\ndef test_charge_retry_reuses_the_idempotency_key():\n    route = respx.post(\"https:\u002F\u002Fpay.test\u002Fcharges\").mock(side_effect=[\n        httpx.ReadTimeout(\"slow\"),\n        httpx.Response(201, json={\"id\": \"ch_1\"}),\n    ])\n\n    PaymentsClient(\"https:\u002F\u002Fpay.test\", retries=2).charge(amount_minor=4999)\n\n    keys = {call.request.headers[\"Idempotency-Key\"] for call in route.calls}\n    assert len(keys) == 1                        # same key on every attempt\n    assert route.call_count == 2\n",[17,551,552,556,561,566,570,575,579,583,588,592,597,602],{"__ignoreMap":79},[83,553,554],{"class":85,"line":86},[83,555,119],{},[83,557,558],{"class":85,"line":92},[83,559,560],{},"def test_charge_retry_reuses_the_idempotency_key():\n",[83,562,563],{"class":85,"line":98},[83,564,565],{},"    route = respx.post(\"https:\u002F\u002Fpay.test\u002Fcharges\").mock(side_effect=[\n",[83,567,568],{"class":85,"line":104},[83,569,193],{},[83,571,572],{"class":85,"line":111},[83,573,574],{},"        httpx.Response(201, json={\"id\": \"ch_1\"}),\n",[83,576,577],{"class":85,"line":116},[83,578,210],{},[83,580,581],{"class":85,"line":122},[83,582,108],{"emptyLinePlaceholder":107},[83,584,585],{"class":85,"line":128},[83,586,587],{},"    PaymentsClient(\"https:\u002F\u002Fpay.test\", retries=2).charge(amount_minor=4999)\n",[83,589,590],{"class":85,"line":134},[83,591,108],{"emptyLinePlaceholder":107},[83,593,594],{"class":85,"line":140},[83,595,596],{},"    keys = {call.request.headers[\"Idempotency-Key\"] for call in route.calls}\n",[83,598,599],{"class":85,"line":146},[83,600,601],{},"    assert len(keys) == 1                        # same key on every attempt\n",[83,603,604],{"class":85,"line":151},[83,605,606],{},"    assert route.call_count == 2\n",[10,608,609],{},"That single assertion — one distinct key across every attempt — is what separates a client that is safe to retry from one that double-charges customers during a slow afternoon at the payment provider. It is short, deterministic, and worth having on every client that retries unsafe requests.",[305,611,613,674],{"className":612},[308],[310,614,318,619,318,622,318,625,318,629,318,634,318,639,318,645,318,650,318,654,318,658,318,661,318,665,318,668,318,671],{"viewBox":615,"role":313,"ariaLabelledBy":616,"xmlns":317},"0 0 800 236",[617,618],"idem-t","idem-d",[320,620,621],{"id":617},"Retrying an unsafe request after a read timeout",[324,623,624],{"id":618},"A POST charge request times out while reading the response, after the server may already have processed it. Without an idempotency key, the retry creates a second charge. With the same idempotency key on both attempts, the server recognises the retry and returns the original charge, so the customer is charged once.",[346,626],{"x":348,"y":348,"width":627,"height":628,"rx":351,"fill":352},"800","236",[354,630,633],{"x":631,"y":357,"textAnchor":358,"fontSize":632,"fontWeight":360,"fill":344},"400","15.5","The retry is safe only if the server can recognise it",[346,635],{"x":364,"y":636,"width":637,"height":410,"rx":638,"fill":438,"stroke":439,"strokeWidth":407},"50","360","12",[354,640,644],{"x":641,"y":642,"textAnchor":358,"fontSize":643,"fontWeight":360,"fill":344},"206","76","12.5","no key",[354,646,649],{"x":647,"y":648,"fontSize":411,"fill":344},"44","106","attempt 1: charged, reply lost",[354,651,653],{"x":647,"y":652,"fontSize":411,"fill":344},"128","attempt 2: charged again",[354,655,657],{"x":647,"y":656,"fontSize":411,"fontWeight":360,"fill":448},"170","customer charged twice",[346,659],{"x":660,"y":636,"width":637,"height":410,"rx":638,"fill":405,"stroke":406,"strokeWidth":407},"414",[354,662,664],{"x":663,"y":642,"textAnchor":358,"fontSize":643,"fontWeight":360,"fill":344},"594","same Idempotency-Key",[354,666,649],{"x":667,"y":648,"fontSize":411,"fill":344},"432",[354,669,670],{"x":667,"y":652,"fontSize":411,"fill":344},"attempt 2: server returns ch_1",[354,672,673],{"x":667,"y":656,"fontSize":411,"fontWeight":360,"fill":420},"charged once",[451,675,676],{},"The test asserts the left column cannot happen by checking every attempt carried the same key.",[26,678,680],{"id":679},"partial-responses","Partial responses",[10,682,683,684,687],{},"The rightmost column covers the case most suites never test: the server started responding and the connection died mid-body. For a streamed download that means a truncated file; for JSON it means a parse error on half a document. A streamed response whose chunk iterator raises after the first chunk reproduces it exactly, and the assertion is that the client reports a transport failure rather than returning or parsing partial data. Clients that validate the declared ",[17,685,686],{},"Content-Length"," against the bytes received, or that parse only complete documents, pass; clients that hand whatever arrived to the caller fail — and that failure is precisely the bug worth finding before it corrupts a record in production.",[305,689,691,756],{"className":690},[308],[310,692,318,697,318,700,318,703,318,705,318,708,318,712,318,717,318,721,318,725,318,733,318,737,318,741,318,744,318,748,318,750,318,753],{"viewBox":693,"role":313,"ariaLabelledBy":694,"xmlns":317},"0 0 800 220",[695,696],"part-t","part-d",[320,698,699],{"id":695},"Handling a response that dies mid-body",[324,701,702],{"id":696},"A streamed response delivers its status line and first chunk, then the connection breaks. A careless client returns or parses the partial data, producing a truncated file or a broken record. A careful client detects the short read against the declared length and raises a transport error instead.",[346,704],{"x":348,"y":348,"width":627,"height":366,"rx":351,"fill":352},[354,706,707],{"x":631,"y":357,"textAnchor":358,"fontSize":632,"fontWeight":360,"fill":344},"200 OK, then nothing",[346,709],{"x":364,"y":636,"width":710,"height":365,"rx":367,"fill":405,"stroke":406,"strokeWidth":711},"200","1.8",[354,713,716],{"x":714,"y":715,"textAnchor":358,"fontSize":374,"fill":344},"126","85","status + chunk 1",[346,718],{"x":719,"y":636,"width":720,"height":365,"rx":367,"fill":438,"stroke":439,"strokeWidth":407},"246","160",[354,722,724],{"x":723,"y":715,"textAnchor":358,"fontSize":374,"fill":344},"326","connection reset",[346,726],{"x":727,"y":636,"width":728,"height":365,"rx":367,"fill":352,"stroke":729,"strokeWidth":730,"strokeDashArray":731},"426","348","rgba(61,64,91,0.35)","1.5",[337,732],"4",[354,734,736],{"x":735,"y":715,"textAnchor":358,"fontSize":374,"fill":344},"600","chunks that never arrive",[346,738],{"x":364,"y":739,"width":637,"height":740,"rx":367,"fill":438,"stroke":439,"strokeWidth":407},"130","70",[354,742,743],{"x":641,"y":720,"textAnchor":358,"fontSize":374,"fontWeight":360,"fill":344},"careless client",[354,745,747],{"x":641,"y":746,"textAnchor":358,"fontSize":411,"fill":448},"182","returns truncated data as success",[346,749],{"x":660,"y":739,"width":637,"height":740,"rx":367,"fill":405,"stroke":406,"strokeWidth":407},[354,751,752],{"x":663,"y":720,"textAnchor":358,"fontSize":374,"fontWeight":360,"fill":344},"careful client",[354,754,755],{"x":663,"y":746,"textAnchor":358,"fontSize":411,"fill":420},"detects short read, raises",[451,757,758],{},"Only a test that breaks the stream partway can tell these two clients apart; both pass every whole-response test.",[26,760,762],{"id":761},"building-a-failure-matrix-for-a-client","Building a failure matrix for a client",[10,764,765],{},"A client with retries, timeouts and error translation has a small, finite set of behaviours worth pinning down, and writing them as a table before writing tests makes gaps obvious. The rows are the failure types — connect error, connect timeout, read timeout, a 5xx status, a 429 with a retry-after header, a mid-body reset. The columns are the request kinds the client makes — safe reads, unsafe writes with an idempotency key, unsafe writes without one. Each cell states the expected outcome: retried and recovered, retried and surfaced after the limit, not retried and surfaced immediately.",[10,767,768,769,771],{},"Most clients turn out to need a dozen or so cells, and each is a three-line test with ",[17,770,462],{}," and a side-effect sequence. The table itself is worth keeping as a comment at the top of the test module, because it is the clearest statement anywhere in the codebase of how the client behaves when the network misbehaves — the question every on-call engineer eventually asks, usually at an inconvenient hour.",[10,773,774],{},"Two cells tend to be missing from suites that never built the table. The first is the 429 row: clients that retry 5xx responses aggressively often treat rate limiting the same way and hammer a provider that asked them to back off. The second is the unsafe-write-without-key row under a read timeout, which is exactly the double-charge scenario above. Filling those two cells alone justifies the exercise, and the rest of the table comes almost for free once the test pattern is established. When the client's retry policy later changes, the table is also the first thing to update, which keeps the tests and the documented behaviour in step without anyone having to remember they are related. A dozen short tests and one comment block are a small price for that. They also run in well under a second.",[26,776,778],{"id":777},"frequently-asked-questions","Frequently Asked Questions",[10,780,781,784],{},[483,782,783],{},"Should a test simulate a timeout by actually waiting?","\nNo. Raise the library's timeout exception from the transport fake instead. The code under test sees exactly what it would see after a real timeout, and the test runs in milliseconds rather than waiting out the configured deadline.",[10,786,787,790,791,542,793,796,797,800,801,542,804,796,806,809,810,812],{},[483,788,789],{},"Which exception types should the fake raise?","\nThe ones the real HTTP library raises: ",[17,792,19],{},[17,794,795],{},"httpx.ReadTimeout"," and ",[17,798,799],{},"httpx.ConnectError"," for httpx; ",[17,802,803],{},"requests.exceptions.ConnectTimeout",[17,805,428],{},[17,807,808],{},"ConnectionError"," for requests. Raising a generic ",[17,811,23],{}," tests a path production never takes.",[10,814,815,818],{},[483,816,817],{},"How do I test a failure that happens after some data arrived?","\nUse a streamed response whose iterator raises partway through, or a side effect sequence that returns a partial body and then raises a read error on the next call. That exercises the partial-read handling that simple error tests skip.",[26,820,822],{"id":821},"related","Related",[31,824,825,831,838,845],{},[34,826,827,830],{},[65,828,829],{"href":67},"Mocking httpx Clients with respx"," — the transport fake used here.",[34,832,833,837],{},[65,834,836],{"href":835},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fmocking-requests-with-the-responses-library\u002F","Mocking requests with the responses Library"," — the equivalent for requests.",[34,839,840,844],{},[65,841,843],{"href":842},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fcontrolling-time-and-randomness-in-tests\u002Ftesting-retry-and-backoff-logic-without-waiting\u002F","Testing Retry and Backoff Logic Without Waiting"," — asserting the delays between these attempts.",[34,846,847,851],{},[65,848,850],{"href":849},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fdeep-dive-into-unittestmock\u002Fdriving-mocks-with-side-effect-sequences\u002F","Driving Mocks with side_effect Sequences"," — scripting failure-then-success.",[10,853,854,855],{},"← Back to ",[65,856,858],{"href":857},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002F","Mocking Network and HTTP Calls",[860,861,862],"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":79,"searchDepth":92,"depth":92,"links":864},[865,866,867,868,869,870,871,872,873],{"id":28,"depth":92,"text":29},{"id":71,"depth":92,"text":72},{"id":456,"depth":92,"text":457},{"id":476,"depth":92,"text":477},{"id":531,"depth":92,"text":532},{"id":679,"depth":92,"text":680},{"id":761,"depth":92,"text":762},{"id":777,"depth":92,"text":778},{"id":821,"depth":92,"text":822},"Test HTTP failure paths deterministically: raising timeouts and connection errors with respx and responses, partial failures, retries, and asserting what the caller saw.","md",{"slug":877,"type":878,"breadcrumb":879,"datePublished":880,"dateModified":880,"faq":881,"howto":888},"simulating-timeouts-and-connection-errors","article","Network Failures","2026-09-18",[882,884,886],{"q":783,"a":883},"No. Raise the library's timeout exception from the transport fake instead. The code under test sees exactly what it would see after a real timeout, and the test runs in milliseconds rather than waiting out the configured deadline.",{"q":789,"a":885},"The ones the real HTTP library raises: httpx.ConnectTimeout, httpx.ReadTimeout and httpx.ConnectError for httpx; requests.exceptions.ConnectTimeout, ReadTimeout and ConnectionError for requests. Raising a generic TimeoutError tests a path production never takes.",{"q":817,"a":887},"Use a streamed response whose iterator raises partway through, or a side effect sequence that returns a partial body and then raises a read error on the next call. That exercises the partial-read handling that simple error tests skip.",{"name":889,"description":890,"steps":891},"How to simulate network failures in tests","Raise the HTTP library's real exception types from a transport-level fake, script mixed failures and successes, and assert on what the caller observed.",[892,895,898,901,904],{"name":893,"text":894},"Fake at the transport","Use respx for httpx or responses for requests so the real client code, including retries, runs.",{"name":896,"text":897},"Raise the library's own exceptions","Configure routes to raise ConnectTimeout, ReadTimeout or ConnectError rather than generic errors.",{"name":899,"text":900},"Script mixed outcomes","Use side_effect sequences to fail a set number of times before succeeding.",{"name":902,"text":903},"Assert on the caller's view","Check the returned value, the raised domain error and the number of attempts.",{"name":905,"text":906},"Cover partial failures","Simulate errors after a status line or mid-stream to test partial-read handling.","\u002Fadvanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fsimulating-timeouts-and-connection-errors",{"title":5,"description":874},"advanced-mocking-test-doubles-in-python\u002Fmocking-network-and-http-calls\u002Fsimulating-timeouts-and-connection-errors\u002Findex","ksgIE40Sar_2a3xRjIiR9hHFVKyvU5PGWRAPFO0wkZg",1789718768870]