diff --git a/benchmarks/results/darwin/0.2.9/benchmark.json b/benchmarks/results/darwin/0.2.9/benchmark.json new file mode 100644 index 0000000..829588a --- /dev/null +++ b/benchmarks/results/darwin/0.2.9/benchmark.json @@ -0,0 +1,4187 @@ +{ + "metadata": { + "timestamp": "2025-12-15T01:10:09.648388", + "version": "0.2.9", + "num_sequential": 25, + "num_concurrent": 25, + "concurrent_workers": 5, + "warmup_requests": 5, + "library_versions": { + "httpmorph": "0.2.9", + "requests": "2.31.0", + "httpx": "0.28.1", + "aiohttp": "3.13.2", + "urllib3": "1.26.16", + "urllib": "built-in (Python 3.11.5)", + "pycurl": "PycURL/7.45.2 libcurl/8.7.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.12 nghttp2/1.64.0", + "curl_cffi": "0.13.0" + }, + "os": "Darwin", + "os_version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:30 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6020", + "platform": "macOS-15.6-arm64-arm-64bit", + "processor": "arm", + "python_version": "3.11.5", + "python_implementation": "CPython", + "cpu_count": 10, + "memory_gb": 16.0 + }, + "results": { + "httpmorph": { + "seq_local_http": { + "mean_ms": 0.144250076264143, + "median_ms": 0.12904219329357147, + "min_ms": 0.11579087004065514, + "max_ms": 0.37025008350610733, + "stdev_ms": 0.05006722647617671, + "p50_ms": 0.12904219329357147, + "p95_ms": 0.16645900905132294, + "p99_ms": 0.37025008350610733, + "timings": [ + 0.17029186710715294, + 0.16429228708148003, + 0.1776670105755329, + 0.14312518760561943, + 0.13950001448392868, + 0.128250103443861, + 0.13416633009910583, + 0.13083405792713165, + 0.1261672005057335, + 0.141083262860775, + 0.12629199773073196, + 0.12641586363315582, + 0.12524984776973724, + 0.12908270582556725, + 0.12191710993647575, + 0.12904219329357147, + 0.1215832307934761, + 0.11700019240379333, + 0.12075016275048256, + 0.13066595420241356, + 0.11579087004065514, + 0.12687500566244125, + 0.12350035831332207, + 0.16645900905132294, + 0.37025008350610733 + ], + "q1_avg_ms": 0.1538544117162625, + "q2_avg_ms": 0.13082645212610564, + "q3_avg_ms": 0.12397921333710353, + "q4_avg_ms": 0.16489877764667785, + "trend_slope_ms_per_req": 0.0010403414041950153, + "cv_pct": 34.70863085333577, + "first_avg_ms": 0.1538544117162625, + "last_avg_ms": 0.16489877764667785 + }, + "seq_remote_http": { + "mean_ms": 189.6774117462337, + "median_ms": 189.84154192730784, + "min_ms": 188.34858387708664, + "max_ms": 190.50570763647556, + "stdev_ms": 0.6530622394955856, + "p50_ms": 189.84154192730784, + "p95_ms": 189.54333383589983, + "p99_ms": 190.50570763647556, + "timings": [ + 189.57120925188065, + 190.06854109466076, + 190.25241630151868, + 190.05720876157284, + 189.47750004008412, + 190.02050021663308, + 188.34858387708664, + 190.18229190260172, + 188.38083278387785, + 190.4080407693982, + 190.07949996739626, + 189.25416702404618, + 189.39574994146824, + 190.27758296579123, + 188.4126248769462, + 189.49566688388586, + 189.84154192730784, + 190.24679204449058, + 190.50570763647556, + 190.1871250011027, + 190.33454218879342, + 189.04987536370754, + 188.8714167289436, + 189.54333383589983, + 189.67254227027297 + ], + "q1_avg_ms": 189.9078959443917, + "q2_avg_ms": 189.44223605406782, + "q3_avg_ms": 189.61165977331498, + "q4_avg_ms": 189.73779186074222, + "trend_slope_ms_per_req": -0.00411841946725662, + "cv_pct": 0.34430153463360563, + "first_avg_ms": 189.9078959443917, + "last_avg_ms": 189.73779186074222 + }, + "seq_remote_https": { + "mean_ms": 188.4381414949894, + "median_ms": 188.4418330155313, + "min_ms": 187.0090840384364, + "max_ms": 190.81912469118834, + "stdev_ms": 0.8449359190350675, + "p50_ms": 188.4418330155313, + "p95_ms": 188.58520779758692, + "p99_ms": 190.81912469118834, + "timings": [ + 187.90966598317027, + 188.4943749755621, + 188.6577089317143, + 187.94995779171586, + 188.89924976974726, + 188.62583301961422, + 188.53712501004338, + 188.2600407116115, + 188.573291990906, + 188.48320841789246, + 188.1528333760798, + 187.53720773383975, + 188.2349168881774, + 188.58583364635706, + 188.69404215365648, + 188.18883318454027, + 188.33008268848062, + 187.0090840384364, + 187.34895810484886, + 188.4418330155313, + 188.44058271497488, + 190.73612475767732, + 190.81912469118834, + 188.58520779758692, + 187.45841598138213 + ], + "q1_avg_ms": 188.42279841192067, + "q2_avg_ms": 188.25728454006216, + "q3_avg_ms": 188.17379876660803, + "q4_avg_ms": 188.83289243759853, + "trend_slope_ms_per_req": 0.01862136969486108, + "cv_pct": 0.4483890110206455, + "first_avg_ms": 188.42279841192067, + "last_avg_ms": 188.83289243759853 + }, + "seq_remote_http2": { + "mean_ms": 187.654005009681, + "median_ms": 187.54591699689627, + "min_ms": 186.04316702112556, + "max_ms": 189.89283312112093, + "stdev_ms": 0.8082841150545461, + "p50_ms": 187.54591699689627, + "p95_ms": 187.16995883733034, + "p99_ms": 189.89283312112093, + "timings": [ + 187.36383318901062, + 189.50912496075034, + 188.3255410939455, + 187.7267910167575, + 187.10925010964274, + 187.79466673731804, + 187.92762514203787, + 187.27683322504163, + 188.14404215663671, + 187.2760420665145, + 187.47183308005333, + 187.0649168267846, + 187.55004182457924, + 187.54591699689627, + 188.11724986881018, + 186.8582502938807, + 187.73358268663287, + 187.18333402648568, + 186.04316702112556, + 187.8428333438933, + 188.31029068678617, + 187.43020901456475, + 186.6819579154253, + 187.16995883733034, + 189.89283312112093 + ], + "q1_avg_ms": 187.97153451790413, + "q2_avg_ms": 187.52688208284476, + "q3_avg_ms": 187.49806261621416, + "q4_avg_ms": 187.62446427717805, + "trend_slope_ms_per_req": -0.013415408678925954, + "cv_pct": 0.43073107606354955, + "first_avg_ms": 187.97153451790413, + "last_avg_ms": 187.62446427717805 + }, + "seq_proxy_http": { + "mean_ms": 1170.2142515033484, + "median_ms": 726.183167193085, + "min_ms": 603.3246251754463, + "max_ms": 5809.221083763987, + "stdev_ms": 1386.2637330171565, + "p50_ms": 726.183167193085, + "p95_ms": 884.1561251319945, + "p99_ms": 5809.221083763987, + "timings": [ + 693.8903327099979, + 726.183167193085, + 948.5019580461085, + 607.7698329463601, + 612.3942909762263, + 746.2264588102698, + 610.6534576974809, + 625.8991663344204, + 903.435249812901, + 643.5836660675704, + 5809.221083763987, + 1468.1351245380938, + 840.0403331033885, + 709.55054089427, + 5656.697084195912, + 608.6151250638068, + 679.5627907849848, + 955.5643331259489, + 607.0230002515018, + 603.3246251754463, + 906.0230422765017, + 921.511874999851, + 798.9799580536783, + 884.1561251319945, + 688.4136656299233 + ], + "q1_avg_ms": 722.4943401136746, + "q2_avg_ms": 1676.8212913690757, + "q3_avg_ms": 1575.0050345280517, + "q4_avg_ms": 772.7760416455567, + "trend_slope_ms_per_req": 3.008979333278078, + "cv_pct": 118.46238680107118, + "first_avg_ms": 722.4943401136746, + "last_avg_ms": 772.7760416455567 + }, + "seq_proxy_https": { + "mean_ms": 2048.5309833846986, + "median_ms": 1359.2293746769428, + "min_ms": 1184.538250323385, + "max_ms": 6690.919375047088, + "stdev_ms": 1749.1460291239796, + "p50_ms": 1359.2293746769428, + "p95_ms": 1606.7098332569003, + "p99_ms": 6690.919375047088, + "timings": [ + 1601.1774577200413, + 1250.220709014684, + 1195.6598344258964, + 6690.919375047088, + 6411.0235422849655, + 1467.7027082070708, + 1192.6315841265023, + 1390.3912920504808, + 1498.8950416445732, + 1213.4404159151018, + 1209.4079586677253, + 1628.278583753854, + 1190.7485420815647, + 1184.538250323385, + 1559.113415889442, + 1233.0532502382994, + 1228.1472496688366, + 1290.6245831400156, + 1453.4712908789515, + 3490.8317080698907, + 6485.494750086218, + 1359.2293746769428, + 1194.4374996237457, + 1606.7098332569003, + 1187.1263338252902 + ], + "q1_avg_ms": 3102.783937783291, + "q2_avg_ms": 1355.5074793597062, + "q3_avg_ms": 1281.0375485569239, + "q4_avg_ms": 2396.7572557739913, + "trend_slope_ms_per_req": -26.145593229179774, + "cv_pct": 85.38538315070743, + "first_avg_ms": 3102.783937783291, + "last_avg_ms": 2396.7572557739913 + }, + "seq_proxy_http2": { + "mean_ms": 1782.8064450621605, + "median_ms": 1332.3746249079704, + "min_ms": 1150.150750298053, + "max_ms": 6502.383749932051, + "stdev_ms": 1431.3320620736904, + "p50_ms": 1332.3746249079704, + "p95_ms": 1212.6606251113117, + "p99_ms": 6502.383749932051, + "timings": [ + 1220.32504202798, + 1213.3909589610994, + 1243.8919167034328, + 1475.500916596502, + 1150.150750298053, + 1513.5972923599184, + 1658.949709031731, + 1290.150625165552, + 1202.7312079444528, + 1348.8140418194234, + 1457.295374944806, + 1227.6534168049693, + 1390.6690408475697, + 1332.3746249079704, + 1564.1718748956919, + 1578.7571663968265, + 1285.4978749528527, + 1187.6254589296877, + 6471.038791816682, + 1190.9928331151605, + 6502.383749932051, + 1427.2542502731085, + 2136.7144999094307, + 1212.6606251113117, + 1287.5690828077495 + ], + "q1_avg_ms": 1302.8094794911642, + "q2_avg_ms": 1364.2657292851557, + "q3_avg_ms": 1389.8493401550998, + "q4_avg_ms": 2889.801976137928, + "trend_slope_ms_per_req": 61.193701807552806, + "cv_pct": 80.28533136830704, + "first_avg_ms": 1302.8094794911642, + "last_avg_ms": 2889.801976137928 + }, + "conc_local_http": { + "total_time_s": 0.008241292089223862, + "req_per_sec": 3033.504907888105, + "avg_ms": 0.32965168356895447, + "mean_completion_ms": 6.249804683029652, + "median_completion_ms": 6.365000270307064, + "min_completion_ms": 3.773665986955166, + "max_completion_ms": 8.106666151434183, + "p95_completion_ms": 8.044333197176456, + "completion_times": [ + 3.773665986955166, + 4.105375148355961, + 4.315875004976988, + 4.575291182845831, + 4.865041002631187, + 5.09420782327652, + 5.2305408753454685, + 5.391540937125683, + 5.51295792683959, + 5.707832984626293, + 5.903500132262707, + 6.156166084110737, + 6.365000270307064, + 6.536958273500204, + 6.728624925017357, + 6.937915924936533, + 7.076832931488752, + 7.300457917153835, + 7.491915952414274, + 7.588500156998634, + 7.706833072006702, + 7.783208042383194, + 7.94587517157197, + 8.044333197176456, + 8.106666151434183 + ], + "trend_slope_ms_per_req": 0.18136907906199878, + "cv_pct": 21.46897278090493, + "first_avg_ms": 4.454909358173609, + "last_avg_ms": 7.809618820569345 + }, + "conc_remote_http": { + "total_time_s": 1.1501105413772166, + "req_per_sec": 21.737041006565672, + "avg_ms": 46.00442165508866, + "mean_completion_ms": 758.9461722224951, + "median_completion_ms": 758.1484173424542, + "min_completion_ms": 375.79300021752715, + "max_completion_ms": 1149.6657920069993, + "p95_completion_ms": 1149.5833341032267, + "completion_times": [ + 375.79300021752715, + 376.00608402863145, + 380.2274172194302, + 382.4120839126408, + 383.81112506613135, + 565.1902090758085, + 565.404792316258, + 568.838834296912, + 573.9950840361416, + 574.2766670882702, + 753.0818339437246, + 753.2144170254469, + 758.1484173424542, + 765.6166669912636, + 765.8257503062487, + 940.0669173337519, + 940.3072502464056, + 947.1647092141211, + 957.3477092199028, + 957.5368342921138, + 1126.8273340538144, + 1126.9627502188087, + 1136.3492920063436, + 1149.5833341032267, + 1149.6657920069993 + ], + "trend_slope_ms_per_req": 36.60950032277749, + "cv_pct": 36.05815041461019, + "first_avg_ms": 410.57331992002827, + "last_avg_ms": 1086.3247208430298 + }, + "conc_remote_https": { + "total_time_s": 1.4320545829832554, + "req_per_sec": 17.457435140439976, + "avg_ms": 57.282183319330215, + "mean_completion_ms": 956.723891440779, + "median_completion_ms": 941.660207696259, + "min_completion_ms": 560.4509576223791, + "max_completion_ms": 1431.7953325808048, + "p95_completion_ms": 1317.345999646932, + "completion_times": [ + 560.4509576223791, + 567.9397499188781, + 568.0558327585459, + 569.3518328480422, + 614.0013327822089, + 746.5834999457002, + 754.400166682899, + 754.4672917574644, + 754.8355418257415, + 820.2426247298717, + 933.7103329598904, + 941.5214997716248, + 941.660207696259, + 941.7423750273883, + 1024.9482919462025, + 1120.8506668917835, + 1128.59204178676, + 1128.7249997258186, + 1128.7957075983286, + 1227.4528327398002, + 1306.8758328445256, + 1316.4625419303775, + 1317.2897920012474, + 1317.345999646932, + 1431.7953325808048 + ], + "trend_slope_ms_per_req": 37.32479019663655, + "cv_pct": 28.978534315576947, + "first_avg_ms": 604.3972009792924, + "last_avg_ms": 1292.2882913345736 + }, + "conc_remote_http2": { + "total_time_s": 1.3584012500941753, + "req_per_sec": 18.403987774795407, + "avg_ms": 54.33605000376701, + "mean_completion_ms": 955.9186600707471, + "median_completion_ms": 962.1397918090224, + "min_completion_ms": 564.5022499375045, + "max_completion_ms": 1357.9008746892214, + "p95_completion_ms": 1346.47799981758, + "completion_times": [ + 564.5022499375045, + 568.516374565661, + 579.2411249130964, + 581.0591247864068, + 585.0832499563694, + 751.1947499588132, + 755.3754169493914, + 770.4078336246312, + 772.0770416781306, + 779.1688339784741, + 938.2739588618279, + 943.2995417155325, + 962.1397918090224, + 962.5814999453723, + 971.3900838978589, + 1126.114041544497, + 1131.1677498742938, + 1153.9336666464806, + 1154.0567916817963, + 1164.1076668165624, + 1314.206708688289, + 1319.3555418401957, + 1346.3345835916698, + 1346.47799981758, + 1357.9008746892214 + ], + "trend_slope_ms_per_req": 36.91520913862265, + "cv_pct": 28.760881837557694, + "first_avg_ms": 604.9328123529752, + "last_avg_ms": 1286.062881017902 + }, + "conc_proxy_http": { + "total_time_s": 12.430236999876797, + "req_per_sec": 2.011224725662736, + "avg_ms": 497.2094799950719, + "mean_completion_ms": 5036.29699524492, + "median_completion_ms": 5786.063958890736, + "min_completion_ms": 614.9575840681791, + "max_completion_ms": 12429.302708711475, + "p95_completion_ms": 8746.669666841626, + "completion_times": [ + 614.9575840681791, + 725.1000837422907, + 1369.175999891013, + 1405.7828336954117, + 1979.6370416879654, + 2020.957708824426, + 2804.2382919229567, + 2861.907749902457, + 3500.7834169082344, + 4147.429083939642, + 4856.2532919459045, + 5687.37025000155, + 5786.063958890736, + 5809.135999996215, + 5829.319499898702, + 6291.447292082012, + 6502.3774998262525, + 6514.0215419232845, + 6615.033541806042, + 7128.095333930105, + 7204.497749917209, + 7227.4817088618875, + 7850.38504190743, + 8746.669666841626, + 12429.302708711475 + ], + "trend_slope_ms_per_req": 374.1517737149619, + "cv_pct": 56.87621170204801, + "first_avg_ms": 1352.6018753182143, + "last_avg_ms": 8171.637964567968 + }, + "conc_proxy_https": { + "total_time_s": 20.445954207796603, + "req_per_sec": 1.2227357914392087, + "avg_ms": 817.8381683118641, + "mean_completion_ms": 6897.201451789588, + "median_completion_ms": 6959.186541847885, + "min_completion_ms": 1190.3670001775026, + "max_completion_ms": 20445.160792209208, + "p95_completion_ms": 11888.901041820645, + "completion_times": [ + 1190.3670001775026, + 1191.6307918727398, + 1298.0802082456648, + 1434.6318752504885, + 2393.1604172103107, + 2623.5165828838944, + 2637.129125185311, + 3399.977874942124, + 3776.343416888267, + 5048.391167074442, + 5441.192917060107, + 6714.169625192881, + 6959.186541847885, + 8106.24362481758, + 8575.574582908303, + 8988.61641716212, + 9057.52612510696, + 9295.74149986729, + 9733.438916970044, + 10278.099916875362, + 10299.92025019601, + 10723.808832932264, + 10929.22675004229, + 11888.901041820645, + 20445.160792209208 + ], + "trend_slope_ms_per_req": 583.1851069865605, + "cv_pct": 66.17997839347146, + "first_avg_ms": 1688.5644792734336, + "last_avg_ms": 12042.650928720832 + }, + "conc_proxy_http2": { + "total_time_s": 35.1433290829882, + "req_per_sec": 0.7113725606633472, + "avg_ms": 1405.733163319528, + "mean_completion_ms": 13965.175486691296, + "median_completion_ms": 12849.97829189524, + "min_completion_ms": 1189.2949999310076, + "max_completion_ms": 35142.593207769096, + "p95_completion_ms": 31500.604500062764, + "completion_times": [ + 1189.2949999310076, + 3261.0407499596477, + 3263.406875077635, + 3266.8745829723775, + 3288.0373750813305, + 3299.7741671279073, + 6053.9959580637515, + 6407.765166833997, + 8988.040249794722, + 9319.094167090952, + 9650.663542095572, + 11162.34558308497, + 12849.97829189524, + 13428.79920778796, + 13430.737666785717, + 17468.075667042285, + 17833.929541986436, + 19290.80795776099, + 19817.63087492436, + 21332.81487505883, + 23597.013750113547, + 24300.268666818738, + 29985.799542162567, + 31500.604500062764, + 35142.593207769096 + ], + "trend_slope_ms_per_req": 1292.2742124037961, + "cv_pct": 69.87367229137426, + "first_avg_ms": 2928.0714583583176, + "last_avg_ms": 26525.246488129986 + }, + "async_local_http": { + "total_time_s": 0.12917670886963606, + "req_per_sec": 193.53334063673793, + "avg_ms": 5.167068354785442 + }, + "async_remote_http": { + "total_time_s": 0.5212441668845713, + "req_per_sec": 47.96216742227105, + "avg_ms": 20.849766675382853 + }, + "async_remote_https": { + "total_time_s": 2.5816080840304494, + "req_per_sec": 9.683886626574854, + "avg_ms": 103.26432336121798 + }, + "async_remote_http2": { + "total_time_s": 1.618254499975592, + "req_per_sec": 15.448744310846703, + "avg_ms": 64.73017999902368 + }, + "async_proxy_http": { + "total_time_s": 7.851042291149497, + "req_per_sec": 3.1842905786130555, + "avg_ms": 314.0416916459799 + }, + "async_proxy_https": { + "total_time_s": 13.718291707802564, + "req_per_sec": 1.8223843414687508, + "avg_ms": 548.7316683121026 + }, + "async_proxy_http2": { + "total_time_s": 16.658585249911994, + "req_per_sec": 1.5007276803492104, + "avg_ms": 666.3434099964797 + } + }, + "requests": { + "seq_local_http": { + "mean_ms": 1.162621658295393, + "median_ms": 1.1517498642206192, + "min_ms": 0.8187079802155495, + "max_ms": 1.607667189091444, + "stdev_ms": 0.2367250209985829, + "p50_ms": 1.1517498642206192, + "p95_ms": 0.9412080980837345, + "p99_ms": 1.607667189091444, + "timings": [ + 1.607667189091444, + 1.5984168276190758, + 1.5465840697288513, + 1.4682910405099392, + 1.325125340372324, + 1.2163748033344746, + 1.232209149748087, + 1.3577500358223915, + 1.265041995793581, + 1.3229581527411938, + 1.1517498642206192, + 1.0377909056842327, + 0.9905421175062656, + 1.1203750036656857, + 1.1984161101281643, + 1.002333126962185, + 1.0020830668509007, + 1.272125169634819, + 1.054458785802126, + 0.9314999915659428, + 0.9075826965272427, + 0.8313748985528946, + 0.8648750372231007, + 0.9412080980837345, + 0.8187079802155495 + ], + "q1_avg_ms": 1.4604098784426849, + "q2_avg_ms": 1.2279166840016842, + "q3_avg_ms": 1.0976457657913368, + "q4_avg_ms": 0.9071010697100844, + "trend_slope_ms_per_req": -0.02916386745010431, + "cv_pct": 20.361311808491788, + "first_avg_ms": 1.4604098784426849, + "last_avg_ms": 0.9071010697100844 + }, + "seq_remote_http": { + "mean_ms": 194.141895044595, + "median_ms": 194.07758302986622, + "min_ms": 193.15520906820893, + "max_ms": 195.61712490394711, + "stdev_ms": 0.6475938580391262, + "p50_ms": 194.07758302986622, + "p95_ms": 193.83454183116555, + "p99_ms": 195.61712490394711, + "timings": [ + 193.48191563040018, + 194.66241681948304, + 195.61712490394711, + 194.39070811495185, + 194.07758302986622, + 193.70295805856586, + 193.15520906820893, + 195.0382092036307, + 193.23345785960555, + 193.90970841050148, + 195.41391590610147, + 194.07766731455922, + 194.34408331289887, + 193.95612506195903, + 193.64558393135667, + 193.4145838022232, + 193.7164170667529, + 194.5770001038909, + 194.56291664391756, + 193.4233750216663, + 194.54329181462526, + 193.73075012117624, + 194.41454112529755, + 193.83454183116555, + 194.62329195812345 + ], + "q1_avg_ms": 194.3221177595357, + "q2_avg_ms": 194.13802796043456, + "q3_avg_ms": 193.94229887984693, + "q4_avg_ms": 194.1618155022817, + "trend_slope_ms_per_req": -0.006675786840227934, + "cv_pct": 0.3335672899918752, + "first_avg_ms": 194.3221177595357, + "last_avg_ms": 194.1618155022817 + }, + "seq_remote_https": { + "mean_ms": 189.34633834287524, + "median_ms": 189.25187503919005, + "min_ms": 186.9304170832038, + "max_ms": 191.19833270087838, + "stdev_ms": 1.4190377948842643, + "p50_ms": 189.25187503919005, + "p95_ms": 191.08545826748013, + "p99_ms": 191.19833270087838, + "timings": [ + 190.2975421398878, + 191.19833270087838, + 190.98633294925094, + 189.18370781466365, + 189.9540419690311, + 190.76808309182525, + 190.99600007757545, + 188.31366673111916, + 190.59329200536013, + 188.37895896285772, + 187.13808292523026, + 187.50762520357966, + 189.25187503919005, + 189.20129211619496, + 189.77133370935917, + 187.24083295091987, + 186.9304170832038, + 188.3756248280406, + 187.3988751322031, + 188.7110830284655, + 189.60237503051758, + 191.0811671987176, + 190.8788327127695, + 191.08545826748013, + 188.81362490355968 + ], + "q1_avg_ms": 190.39800677758953, + "q2_avg_ms": 188.82127098428705, + "q3_avg_ms": 188.46189595448473, + "q4_avg_ms": 189.6530594676733, + "trend_slope_ms_per_req": -0.04049038299574302, + "cv_pct": 0.7494403151935366, + "first_avg_ms": 190.39800677758953, + "last_avg_ms": 189.6530594676733 + }, + "seq_proxy_http": { + "mean_ms": 374.34930488467216, + "median_ms": 343.79641618579626, + "min_ms": 338.35229091346264, + "max_ms": 710.8548749238253, + "stdev_ms": 83.11094633081758, + "p50_ms": 343.79641618579626, + "p95_ms": 338.35229091346264, + "p99_ms": 710.8548749238253, + "timings": [ + 345.59070877730846, + 375.2997498959303, + 710.8548749238253, + 339.50154203921556, + 340.71462508291006, + 341.75066696479917, + 560.012917034328, + 340.3382087126374, + 339.2547909170389, + 341.5122078731656, + 341.1340001039207, + 342.60529186576605, + 350.6572498008609, + 352.4147910065949, + 344.2087909206748, + 342.1781659126282, + 341.5150409564376, + 343.0377501063049, + 357.1142079308629, + 388.4467501193285, + 383.2957921549678, + 391.486959066242, + 363.65883285179734, + 338.35229091346264, + 343.79641618579626 + ], + "q1_avg_ms": 408.9520279473315, + "q2_avg_ms": 377.4762360844761, + "q3_avg_ms": 345.6686314505835, + "q4_avg_ms": 366.5930356032082, + "trend_slope_ms_per_req": -3.0318614625586915, + "cv_pct": 22.20144267569083, + "first_avg_ms": 408.9520279473315, + "last_avg_ms": 366.5930356032082 + }, + "seq_proxy_https": { + "mean_ms": 467.72862343117595, + "median_ms": 466.43858309835196, + "min_ms": 461.4193341694772, + "max_ms": 481.95466585457325, + "stdev_ms": 5.364563121218678, + "p50_ms": 466.43858309835196, + "p95_ms": 462.1134172193706, + "p99_ms": 481.95466585457325, + "timings": [ + 464.2585003748536, + 463.7600420974195, + 463.9688339084387, + 463.8784169219434, + 464.7005000151694, + 464.82954174280167, + 466.4679584093392, + 475.748083088547, + 467.6550840958953, + 471.13495925441384, + 478.0935407616198, + 467.06933388486505, + 469.9390409514308, + 466.53375029563904, + 466.8558747507632, + 466.43858309835196, + 469.89633282646537, + 481.95466585457325, + 464.8527908138931, + 462.784459348768, + 465.11879190802574, + 477.7865828946233, + 465.95716709271073, + 462.1134172193706, + 461.4193341694772 + ], + "q1_avg_ms": 464.23263917677104, + "q2_avg_ms": 471.02815991578, + "q3_avg_ms": 470.2697079628706, + "q4_avg_ms": 465.7189347781241, + "trend_slope_ms_per_req": 0.05777813458385376, + "cv_pct": 1.1469392405077061, + "first_avg_ms": 464.23263917677104, + "last_avg_ms": 465.7189347781241 + }, + "conc_local_http": { + "total_time_s": 0.0320713329128921, + "req_per_sec": 779.5123473009894, + "avg_ms": 1.2828533165156841, + "mean_completion_ms": 22.000511698424816, + "median_completion_ms": 22.65175012871623, + "min_completion_ms": 9.591124951839447, + "max_completion_ms": 31.860291957855225, + "p95_completion_ms": 31.748208217322826, + "completion_times": [ + 9.591124951839447, + 10.614583268761635, + 11.199875269085169, + 13.327416963875294, + 14.236750081181526, + 14.63312515988946, + 16.057082917541265, + 17.561625223606825, + 18.169499933719635, + 18.97616731002927, + 20.617333240807056, + 21.22312504798174, + 22.65175012871623, + 23.834249936044216, + 25.24866722524166, + 25.517582893371582, + 25.77975019812584, + 27.556207962334156, + 27.97050029039383, + 28.893208131194115, + 30.0288749858737, + 31.037208158522844, + 31.67858300730586, + 31.748208217322826, + 31.860291957855225 + ], + "trend_slope_ms_per_req": 0.9795577809787713, + "cv_pct": 32.913939840430984, + "first_avg_ms": 12.267145949105421, + "last_avg_ms": 30.459553535495484 + }, + "conc_remote_http": { + "total_time_s": 1.2554045412689447, + "req_per_sec": 19.913899606202126, + "avg_ms": 50.21618165075779, + "mean_completion_ms": 861.0089987702668, + "median_completion_ms": 858.4204171784222, + "min_completion_ms": 480.38833402097225, + "max_completion_ms": 1255.0359591841698, + "p95_completion_ms": 1254.586084280163, + "completion_times": [ + 480.38833402097225, + 482.00216703116894, + 482.2643343359232, + 484.6918750554323, + 484.9491249769926, + 667.0025000348687, + 671.023459173739, + 671.65237525478, + 672.5266673602164, + 673.0480422265828, + 852.3957920260727, + 858.1953751854599, + 858.4204171784222, + 858.6158752441406, + 859.0717501938343, + 1039.291167166084, + 1047.6448340341449, + 1047.982667107135, + 1048.2062501832843, + 1048.5362503677607, + 1233.4962501190603, + 1239.8400423116982, + 1254.3573752045631, + 1254.586084280163, + 1255.0359591841698 + ], + "trend_slope_ms_per_req": 36.71407263260335, + "cv_pct": 31.934627656537295, + "first_avg_ms": 513.549722575893, + "last_avg_ms": 1190.5797445215285 + }, + "conc_remote_https": { + "total_time_s": 1.499727957881987, + "req_per_sec": 16.66968990516561, + "avg_ms": 59.989118315279484, + "mean_completion_ms": 1098.4988186694682, + "median_completion_ms": 1092.557500116527, + "min_completion_ms": 707.098042126745, + "max_completion_ms": 1499.4230838492513, + "p95_completion_ms": 1499.2259168066084, + "completion_times": [ + 707.098042126745, + 714.6909171715379, + 716.8205841444433, + 725.8779588155448, + 726.6138340346515, + 895.92200005427, + 902.0840837620199, + 904.6392501331866, + 919.0196669660509, + 919.3126247264445, + 1083.4755841642618, + 1091.6536669246852, + 1092.557500116527, + 1112.3007917776704, + 1112.6601248979568, + 1271.09833387658, + 1280.0137088634074, + 1280.3832087665796, + 1305.7246669195592, + 1306.115083862096, + 1458.8102921843529, + 1468.101624865085, + 1468.8479169271886, + 1499.2259168066084, + 1499.4230838492513 + ], + "trend_slope_ms_per_req": 36.88146758788767, + "cv_pct": 25.015687841777957, + "first_avg_ms": 747.8372227245321, + "last_avg_ms": 1429.4640836305916 + }, + "conc_proxy_http": { + "total_time_s": 2.747519333381206, + "req_per_sec": 9.099117045787631, + "avg_ms": 109.90077333524823, + "mean_completion_ms": 1738.8073234446347, + "median_completion_ms": 1749.8945835977793, + "min_completion_ms": 723.8233336247504, + "max_completion_ms": 2747.2244589589536, + "p95_completion_ms": 2570.418749935925, + "completion_times": [ + 723.8233336247504, + 755.3478749468923, + 915.943874977529, + 1063.1399168632925, + 1068.4638335369527, + 1083.6860835552216, + 1331.1335416510701, + 1386.5827918052673, + 1403.8180415518582, + 1412.417999934405, + 1559.4637915492058, + 1741.6587499901652, + 1749.8945835977793, + 1753.3540837466717, + 1946.8824588693678, + 2043.4417915530503, + 2084.626874886453, + 2085.2319998666644, + 2149.986374657601, + 2417.5295839086175, + 2426.7602916806936, + 2506.848916877061, + 2542.503083590418, + 2570.418749935925, + 2747.2244589589536 + ], + "trend_slope_ms_per_req": 82.70618830592586, + "cv_pct": 35.167211700600596, + "first_avg_ms": 935.0674862507731, + "last_avg_ms": 2480.181637087039 + }, + "conc_proxy_https": { + "total_time_s": 6.691830916330218, + "req_per_sec": 3.735898338224889, + "avg_ms": 267.67323665320873, + "mean_completion_ms": 2560.768060274422, + "median_completion_ms": 2436.846250202507, + "min_completion_ms": 1391.1971668712795, + "max_completion_ms": 6691.3318750448525, + "p95_completion_ms": 3417.9582921788096, + "completion_times": [ + 1391.1971668712795, + 1440.7239998690784, + 1444.1177500411868, + 1560.2547922171652, + 1739.7855841554701, + 1836.554542183876, + 1839.6731251850724, + 1900.6149591878057, + 2086.212874855846, + 2234.7519588656723, + 2235.510875005275, + 2238.982167094946, + 2436.846250202507, + 2624.4825422763824, + 2627.954500261694, + 2628.6772922612727, + 2783.0972499214113, + 2966.9690001755953, + 3022.9071248322725, + 3023.2123751193285, + 3124.4583749212325, + 3306.691417004913, + 3416.2354171276093, + 3417.9582921788096, + 6691.3318750448525 + ], + "trend_slope_ms_per_req": 121.34243268698741, + "cv_pct": 41.90622382857742, + "first_avg_ms": 1568.772305889676, + "last_avg_ms": 3714.684982318431 + } + }, + "httpx": { + "seq_local_http": { + "mean_ms": 0.6146181933581829, + "median_ms": 0.582708977162838, + "min_ms": 0.5057081580162048, + "max_ms": 0.783165916800499, + "stdev_ms": 0.08507179217609606, + "p50_ms": 0.582708977162838, + "p95_ms": 0.5557090044021606, + "p99_ms": 0.783165916800499, + "timings": [ + 0.7401658222079277, + 0.5959169939160347, + 0.6492915563285351, + 0.559207983314991, + 0.5920836701989174, + 0.5772081203758717, + 0.6303749978542328, + 0.7438329048454762, + 0.7316656410694122, + 0.5925842560827732, + 0.5452092736959457, + 0.7187081500887871, + 0.5554161034524441, + 0.5806246772408485, + 0.5510407499969006, + 0.5311667919158936, + 0.5163326859474182, + 0.5057081580162048, + 0.5374159663915634, + 0.582708977162838, + 0.5526668392121792, + 0.6792498752474785, + 0.783165916800499, + 0.5557090044021606, + 0.7579997181892395 + ], + "q1_avg_ms": 0.6189790243903796, + "q2_avg_ms": 0.6603958706061045, + "q3_avg_ms": 0.540048194428285, + "q4_avg_ms": 0.635559471057994, + "trend_slope_ms_per_req": -0.0007741321594669268, + "cv_pct": 13.841404809590221, + "first_avg_ms": 0.6189790243903796, + "last_avg_ms": 0.635559471057994 + }, + "seq_remote_http": { + "mean_ms": 381.1719783395529, + "median_ms": 378.83100006729364, + "min_ms": 374.8513753525913, + "max_ms": 409.5292501151562, + "stdev_ms": 8.03819985590015, + "p50_ms": 378.83100006729364, + "p95_ms": 379.1756662540138, + "p99_ms": 409.5292501151562, + "timings": [ + 376.10104167833924, + 374.87895879894495, + 376.8551661632955, + 380.7047917507589, + 388.5894166305661, + 374.8513753525913, + 379.80725010856986, + 400.7198750041425, + 381.85454113408923, + 378.2685003243387, + 375.95529202371836, + 377.3219999857247, + 378.83100006729364, + 380.412541795522, + 378.5457918420434, + 376.87495816498995, + 381.06341706588864, + 375.9500002488494, + 384.07258316874504, + 375.4000002518296, + 385.3447078727186, + 380.1126657053828, + 409.5292501151562, + 379.1756662540138, + 378.07866698130965 + ], + "q1_avg_ms": 378.66345839574933, + "q2_avg_ms": 382.3212430967639, + "q3_avg_ms": 378.6129515307645, + "q4_avg_ms": 384.53050576416507, + "trend_slope_ms_per_req": 0.2099682602028434, + "cv_pct": 2.10881185204533, + "first_avg_ms": 378.66345839574933, + "last_avg_ms": 384.53050576416507 + }, + "seq_remote_https": { + "mean_ms": 571.5474549867213, + "median_ms": 566.8797078542411, + "min_ms": 559.4729580916464, + "max_ms": 610.7578752562404, + "stdev_ms": 13.014390710056453, + "p50_ms": 566.8797078542411, + "p95_ms": 565.4296670109034, + "p99_ms": 610.7578752562404, + "timings": [ + 582.60487485677, + 610.7578752562404, + 566.1199167370796, + 565.0218329392374, + 564.3547498621047, + 568.0108750239015, + 562.5335001386702, + 563.532792031765, + 567.9453327320516, + 568.1814588606358, + 568.6119580641389, + 568.1808749213815, + 566.5722922421992, + 559.4729580916464, + 581.2791250646114, + 566.6352910920978, + 562.7827080897987, + 569.7320420295, + 579.0415829978883, + 566.8797078542411, + 560.7340829446912, + 565.740458201617, + 608.6095003411174, + 565.4296670109034, + 579.9209172837436 + ], + "q1_avg_ms": 576.1450207792222, + "q2_avg_ms": 566.4976527914405, + "q3_avg_ms": 567.7457361016423, + "q4_avg_ms": 575.1937023763146, + "trend_slope_ms_per_req": -0.011504354815070447, + "cv_pct": 2.2770446437135856, + "first_avg_ms": 576.1450207792222, + "last_avg_ms": 575.1937023763146 + }, + "seq_remote_http2": { + "mean_ms": 571.0760398767889, + "median_ms": 567.0302920043468, + "min_ms": 554.7492909245193, + "max_ms": 607.4514579959214, + "stdev_ms": 12.567179897533938, + "p50_ms": 567.0302920043468, + "p95_ms": 566.9469167478383, + "p99_ms": 607.4514579959214, + "timings": [ + 567.4522500485182, + 562.6826668158174, + 607.4514579959214, + 568.7822080217302, + 570.7910000346601, + 578.7093746475875, + 566.7615826241672, + 576.9869997166097, + 567.4616666510701, + 554.7492909245193, + 563.6603748425841, + 589.3772500567138, + 561.4455416798592, + 566.9951657764614, + 567.0302920043468, + 565.7539172098041, + 573.530875146389, + 604.4112504459918, + 563.7029581703246, + 565.7110000029206, + 565.5832076445222, + 557.2130410000682, + 572.8112086653709, + 566.9469167478383, + 570.8995000459254 + ], + "q1_avg_ms": 575.9781595940391, + "q2_avg_ms": 569.8328608026108, + "q3_avg_ms": 573.1945070438087, + "q4_avg_ms": 566.1239760395672, + "trend_slope_ms_per_req": -0.2572955685452773, + "cv_pct": 2.2006141074042156, + "first_avg_ms": 575.9781595940391, + "last_avg_ms": 566.1239760395672 + }, + "seq_proxy_http": { + "mean_ms": 490.3380333632231, + "median_ms": 479.16570771485567, + "min_ms": 475.59129213914275, + "max_ms": 564.8164167068899, + "stdev_ms": 24.858640547046548, + "p50_ms": 479.16570771485567, + "p95_ms": 479.53725000843406, + "p99_ms": 564.8164167068899, + "timings": [ + 517.9494158364832, + 475.59129213914275, + 564.8164167068899, + 476.5935828909278, + 477.20266599208117, + 476.2202091515064, + 506.44433312118053, + 476.08595760539174, + 477.24570892751217, + 477.0802496932447, + 477.52062510699034, + 483.128750231117, + 542.9926668293774, + 481.38904199004173, + 540.9164582379162, + 480.3975000977516, + 477.56033297628164, + 477.8032498434186, + 476.3242918998003, + 480.38133420050144, + 479.16570771485567, + 498.8085418008268, + 480.2562091499567, + 479.53725000843406, + 477.039041928947 + ], + "q1_avg_ms": 498.06226378617185, + "q2_avg_ms": 482.9176041142394, + "q3_avg_ms": 500.17654166246456, + "q4_avg_ms": 481.6446252433317, + "trend_slope_ms_per_req": -0.8340198209939095, + "cv_pct": 5.069694548583436, + "first_avg_ms": 498.06226378617185, + "last_avg_ms": 481.6446252433317 + }, + "seq_proxy_https": { + "mean_ms": 343.6147982813418, + "median_ms": 341.88474994152784, + "min_ms": 338.13483314588666, + "max_ms": 360.92987516894937, + "stdev_ms": 5.495510738827074, + "p50_ms": 341.88474994152784, + "p95_ms": 340.39041586220264, + "p99_ms": 360.92987516894937, + "timings": [ + 348.4135000035167, + 342.4807502888143, + 339.33679200708866, + 340.82579240202904, + 341.88474994152784, + 345.38650000467896, + 338.13483314588666, + 347.02929109334946, + 339.2225420102477, + 339.59729177877307, + 341.71987511217594, + 338.5527920909226, + 344.50691705569625, + 341.71695820987225, + 342.84354094415903, + 339.63937498629093, + 342.54316659644246, + 357.00987512245774, + 349.7429583221674, + 342.8590833209455, + 340.73495771735907, + 360.92987516894937, + 341.67070779949427, + 340.39041586220264, + 343.19741604849696 + ], + "q1_avg_ms": 343.05468077460927, + "q2_avg_ms": 340.70943753855926, + "q3_avg_ms": 344.70997215248644, + "q4_avg_ms": 345.64648774851645, + "trend_slope_ms_per_req": 0.17705452234412616, + "cv_pct": 1.5993230694120193, + "first_avg_ms": 343.05468077460927, + "last_avg_ms": 345.64648774851645 + }, + "seq_proxy_http2": { + "mean_ms": 320.45710830017924, + "median_ms": 319.1261668689549, + "min_ms": 317.2134580090642, + "max_ms": 352.8759158216417, + "stdev_ms": 6.955205121251149, + "p50_ms": 319.1261668689549, + "p95_ms": 319.2277499474585, + "p99_ms": 352.8759158216417, + "timings": [ + 317.72354105487466, + 317.2134580090642, + 317.41050025448203, + 319.1261668689549, + 319.58324974402785, + 321.30695786327124, + 317.4769161269069, + 319.29191714152694, + 324.7182094492018, + 352.8759158216417, + 317.4085416831076, + 319.8814997449517, + 317.2643752768636, + 319.11487504839897, + 320.4940417781472, + 318.3346656151116, + 318.99158423766494, + 319.53166611492634, + 320.58624969795346, + 317.2926250845194, + 318.6951670795679, + 319.84295789152384, + 317.83641688525677, + 319.2277499474585, + 320.19845908507705 + ], + "q1_avg_ms": 318.7273122991125, + "q2_avg_ms": 325.2754999945561, + "q3_avg_ms": 318.95520134518546, + "q4_avg_ms": 319.0970893816224, + "trend_slope_ms_per_req": -0.05685354403864879, + "cv_pct": 2.1704012615429504, + "first_avg_ms": 318.7273122991125, + "last_avg_ms": 319.0970893816224 + }, + "conc_local_http": { + "total_time_s": 0.03221816709265113, + "req_per_sec": 775.9597226032895, + "avg_ms": 1.2887266837060452, + "mean_completion_ms": 22.20360368490219, + "median_completion_ms": 23.036791943013668, + "min_completion_ms": 7.621416822075844, + "max_completion_ms": 32.014042139053345, + "p95_completion_ms": 31.973250210285187, + "completion_times": [ + 7.621416822075844, + 9.725125040858984, + 10.380084160715342, + 13.0004589445889, + 14.657834079116583, + 15.423750039190054, + 16.68725023046136, + 17.985458951443434, + 18.604500219225883, + 19.67150019481778, + 21.953499875962734, + 22.605834063142538, + 23.036791943013668, + 23.80745904520154, + 24.630750063806772, + 26.019874960184097, + 27.13941689580679, + 27.47595915570855, + 28.334209229797125, + 29.549541883170605, + 30.050292145460844, + 31.031916849315166, + 31.709874980151653, + 31.973250210285187, + 32.014042139053345 + ], + "trend_slope_ms_per_req": 1.0093209914003427, + "cv_pct": 33.81033277883096, + "first_avg_ms": 11.801444847757617, + "last_avg_ms": 30.66616106246199 + }, + "conc_remote_http": { + "total_time_s": 2.035670042037964, + "req_per_sec": 12.28096866571354, + "avg_ms": 81.42680168151855, + "mean_completion_ms": 1250.9295987896621, + "median_completion_ms": 1247.6750840432942, + "min_completion_ms": 482.53112519159913, + "max_completion_ms": 2035.4810003191233, + "p95_completion_ms": 2003.299292176962, + "completion_times": [ + 482.53112519159913, + 493.34445921704173, + 493.67558397352695, + 494.01062494143844, + 494.3019999191165, + 861.3035841844976, + 867.4752502702177, + 870.4380840063095, + 870.9079171530902, + 901.4416253194213, + 1241.3514172658324, + 1247.4858341738582, + 1247.6750840432942, + 1253.4254170022905, + 1278.3475001342595, + 1613.5767921805382, + 1622.6228340528905, + 1622.9075421579182, + 1628.4165000542998, + 1660.2505003102124, + 1983.5512503050268, + 2002.695667091757, + 2002.7230842970312, + 2003.299292176962, + 2035.4810003191233 + ], + "trend_slope_ms_per_req": 73.05488936065768, + "cv_pct": 43.66650952672528, + "first_avg_ms": 553.1945629045367, + "last_avg_ms": 1902.3453277934875 + }, + "conc_remote_https": { + "total_time_s": 2.871707458049059, + "req_per_sec": 8.705622130808601, + "avg_ms": 114.86829832196236, + "mean_completion_ms": 1716.189413163811, + "median_completion_ms": 1701.6520001925528, + "min_completion_ms": 566.4597083814442, + "max_completion_ms": 2871.370416134596, + "p95_completion_ms": 2871.1399999447167, + "completion_times": [ + 566.4597083814442, + 566.7588752694428, + 567.1262913383543, + 569.7116660885513, + 570.3260833397508, + 1138.7155000120401, + 1139.1475000418723, + 1139.543415978551, + 1147.5361250340939, + 1178.2409162260592, + 1697.2417500801384, + 1701.2752913869917, + 1701.6520001925528, + 1705.2632910199463, + 1745.1604162342846, + 2264.7251253947616, + 2293.154790997505, + 2303.9809581823647, + 2305.0588332116604, + 2305.476250126958, + 2831.7517079412937, + 2860.0987503305078, + 2863.8196662068367, + 2871.1399999447167, + 2871.370416134596 + ], + "trend_slope_ms_per_req": 110.46590534791063, + "cv_pct": 48.1907027351706, + "first_avg_ms": 663.183020738264, + "last_avg_ms": 2701.2450891280814 + }, + "conc_remote_http2": { + "total_time_s": 2.9270690409466624, + "req_per_sec": 8.540966970807968, + "avg_ms": 117.0827616378665, + "mean_completion_ms": 1735.4421179555357, + "median_completion_ms": 1740.551124792546, + "min_completion_ms": 576.339874882251, + "max_completion_ms": 2926.4712911099195, + "p95_completion_ms": 2889.430457726121, + "completion_times": [ + 576.339874882251, + 577.7048747986555, + 578.3334579318762, + 580.7773750275373, + 604.4803750701249, + 1147.3385831341147, + 1148.4840409830213, + 1170.7557081244886, + 1183.9007907547057, + 1187.7169581130147, + 1705.9116656892002, + 1713.679582811892, + 1740.551124792546, + 1748.7967908382416, + 1750.3707497380674, + 2273.2749581336975, + 2293.901375029236, + 2319.5207910612226, + 2320.2683748677373, + 2355.111749842763, + 2842.1848327852786, + 2861.7996657267213, + 2888.9474999159575, + 2889.430457726121, + 2926.4712911099195 + ], + "trend_slope_ms_per_req": 110.93630031001969, + "cv_pct": 47.767679278761186, + "first_avg_ms": 677.4957568074266, + "last_avg_ms": 2726.3162674249284 + }, + "conc_proxy_http": { + "total_time_s": 8.390083624981344, + "req_per_sec": 2.979708083667115, + "avg_ms": 335.60334499925375, + "mean_completion_ms": 1843.9417900517583, + "median_completion_ms": 1725.5583330988884, + "min_completion_ms": 697.7480831556022, + "max_completion_ms": 8389.556417241693, + "p95_completion_ms": 2519.8098751716316, + "completion_times": [ + 697.7480831556022, + 698.6715001985431, + 699.7227082028985, + 721.8263330869377, + 1040.3051669709384, + 1041.7484170757234, + 1042.2688331454992, + 1083.238000050187, + 1383.6532919667661, + 1384.162874892354, + 1384.415457956493, + 1436.5587919019163, + 1725.5583330988884, + 1726.3384582474828, + 1727.1680003032088, + 1796.7199170961976, + 2067.1963752247393, + 2068.5226251371205, + 2075.153958052397, + 2152.0905420184135, + 2410.176625009626, + 2410.3437080048025, + 2415.590458083898, + 2519.8098751716316, + 8389.556417241693 + ], + "trend_slope_ms_per_req": 138.07060356251895, + "cv_pct": 80.64192748975951, + "first_avg_ms": 816.6703681151072, + "last_avg_ms": 3196.103083368923 + }, + "conc_proxy_https": { + "total_time_s": 6.48180487472564, + "req_per_sec": 3.8569504147651767, + "avg_ms": 259.2721949890256, + "mean_completion_ms": 2655.1821528188884, + "median_completion_ms": 2557.821874972433, + "min_completion_ms": 1193.9187906682491, + "max_completion_ms": 6481.073040980846, + "p95_completion_ms": 6130.551415961236, + "completion_times": [ + 1193.9187906682491, + 1195.5946660600603, + 1195.9520410746336, + 1535.7682909816504, + 1537.5807080417871, + 1538.0339156836271, + 1877.0221658051014, + 1878.957665991038, + 1879.3992497958243, + 2217.318832874298, + 2217.7995829842985, + 2219.156624749303, + 2557.821874972433, + 2559.1471660882235, + 2560.4269579052925, + 2899.254416115582, + 2900.8426247164607, + 2901.259457692504, + 3241.9867496937513, + 3242.3172499984503, + 3249.334457796067, + 3584.406916052103, + 3584.6289577893913, + 6130.551415961236, + 6481.073040980846 + ], + "trend_slope_ms_per_req": 158.56694414316175, + "cv_pct": 49.95356576845873, + "first_avg_ms": 1366.1414020850013, + "last_avg_ms": 4216.328398324549 + }, + "conc_proxy_http2": { + "total_time_s": 6.525823541916907, + "req_per_sec": 3.8309341096061043, + "avg_ms": 261.0329416766763, + "mean_completion_ms": 3026.4459631219506, + "median_completion_ms": 2935.4410832747817, + "min_completion_ms": 1199.1271250881255, + "max_completion_ms": 6524.457124993205, + "p95_completion_ms": 4251.225000247359, + "completion_times": [ + 1199.1271250881255, + 1522.7824160829186, + 1664.092875085771, + 1943.9759580418468, + 2088.2399161346257, + 2090.542041230947, + 2091.4449160918593, + 2511.6744581609964, + 2512.1427080594003, + 2512.5317913480103, + 2933.1620410084724, + 2934.9041250534356, + 2935.4410832747817, + 2936.1920412629843, + 3356.461958028376, + 3358.8502909988165, + 3359.7506252117455, + 3360.567250289023, + 3829.2914582416415, + 3830.5009161122143, + 3831.546708010137, + 3832.642999943346, + 4249.601250048727, + 4251.225000247359, + 6524.457124993205 + ], + "trend_slope_ms_per_req": 141.8037693399506, + "cv_pct": 37.15636907024675, + "first_avg_ms": 1751.4600552773725, + "last_avg_ms": 4335.609351085232 + }, + "async_local_http": { + "total_time_s": 0.19016412505879998, + "req_per_sec": 131.4653854520133, + "avg_ms": 7.606565002351999, + "mean_completion_ms": 93.39868193492293, + "median_completion_ms": 88.63500040024519, + "min_completion_ms": 27.795917354524136, + "max_completion_ms": 190.02650026232004, + "p95_completion_ms": 189.98958310112357, + "completion_times": [ + 27.795917354524136, + 27.992583345621824, + 29.197792056947947, + 29.857792425900698, + 30.366625171154737, + 30.44287534430623, + 57.26229213178158, + 57.73229245096445, + 58.29633306711912, + 58.95137507468462, + 59.19529218226671, + 88.0445004440844, + 88.63500040024519, + 89.007125236094, + 89.800167363137, + 90.6827081926167, + 129.015083424747, + 132.942917291075, + 133.15783301368356, + 133.19008331745863, + 133.7546673603356, + 189.70445822924376, + 189.92525013163686, + 189.98958310112357, + 190.02650026232004 + ], + "trend_slope_ms_per_req": 7.3789470030281405, + "cv_pct": 60.178625530669436, + "first_avg_ms": 29.27559761640926, + "last_avg_ms": 165.67833934511458 + }, + "async_remote_http": { + "total_time_s": 0.482210208196193, + "req_per_sec": 51.84460962267404, + "avg_ms": 19.28840832784772, + "mean_completion_ms": 458.77365509048104, + "median_completion_ms": 458.5516252554953, + "min_completion_ms": 449.795083142817, + "max_completion_ms": 482.118375133723, + "p95_completion_ms": 473.1633332557976, + "completion_times": [ + 449.795083142817, + 452.3600419051945, + 452.36937515437603, + 452.3756252601743, + 452.3820001631975, + 452.3885832168162, + 453.5429999232292, + 453.55566684156656, + 454.0818752720952, + 454.09000013023615, + 454.0968332439661, + 454.20899987220764, + 458.5516252554953, + 459.09058302640915, + 459.2828331515193, + 460.1447922177613, + 460.1532919332385, + 460.15908289700747, + 460.7438752427697, + 462.5040418468416, + 462.51116693019867, + 462.5174170359969, + 473.1538752093911, + 473.1633332557976, + 482.118375133723 + ], + "trend_slope_ms_per_req": 0.9243110291516552, + "cv_pct": 1.6868176295204387, + "first_avg_ms": 451.94511814042926, + "last_avg_ms": 468.1017263792455 + }, + "async_remote_https": { + "total_time_s": 2.6665787091478705, + "req_per_sec": 9.375309235851875, + "avg_ms": 106.66314836591482, + "mean_completion_ms": 1553.9703128300607, + "median_completion_ms": 1622.3161658272147, + "min_completion_ms": 621.8785000964999, + "max_completion_ms": 2666.53358284384, + "p95_completion_ms": 2665.8854577690363, + "completion_times": [ + 621.8785000964999, + 636.6559159941971, + 637.0737077668309, + 638.1292911246419, + 642.496207728982, + 643.9977078698575, + 645.0526658445597, + 646.2044157087803, + 664.6817079745233, + 1616.923457942903, + 1621.0041660815477, + 1621.0899157449603, + 1622.3161658272147, + 1624.0316247567534, + 1624.4483748450875, + 1624.4607497937977, + 1630.781332962215, + 1660.6664159335196, + 2611.4574996754527, + 2620.299458038062, + 2620.3378327190876, + 2621.2348747067153, + 2621.6167910024524, + 2665.8854577690363, + 2666.53358284384 + ], + "trend_slope_ms_per_req": 103.82707061102757, + "cv_pct": 52.02262117897608, + "first_avg_ms": 636.7052217635015, + "last_avg_ms": 2632.4807852506638 + }, + "async_remote_http2": { + "total_time_s": 1.6199876246973872, + "req_per_sec": 15.43221665330313, + "avg_ms": 64.79950498789549, + "mean_completion_ms": 1015.8615619689226, + "median_completion_ms": 615.4024167917669, + "min_completion_ms": 607.4495841749012, + "max_completion_ms": 1619.9393747374415, + "p95_completion_ms": 1619.3881668150425, + "completion_times": [ + 607.4495841749012, + 608.4080417640507, + 608.4314589388669, + 608.727834187448, + 614.1685000620782, + 614.1839171759784, + 614.6569168195128, + 614.6689588204026, + 614.973499905318, + 614.9811251088977, + 614.9872089736164, + 614.9933338165283, + 615.4024167917669, + 615.6444167718291, + 635.5451671406627, + 1615.6112500466406, + 1616.5800420567393, + 1617.9264998063445, + 1617.9443751461804, + 1617.9607091471553, + 1617.9747921414673, + 1617.9872918874025, + 1618.0041669867933, + 1619.3881668150425, + 1619.9393747374415 + ], + "trend_slope_ms_per_req": 58.14823456932432, + "cv_pct": 49.391640270845315, + "first_avg_ms": 610.2282227172205, + "last_avg_ms": 1618.4569824087832 + }, + "async_proxy_http": { + "total_time_s": 10.211201500147581, + "req_per_sec": 2.4482917117675798, + "avg_ms": 408.44806000590324, + "mean_completion_ms": 2605.8473515696824, + "median_completion_ms": 830.1948327571154, + "min_completion_ms": 646.6014580801129, + "max_completion_ms": 10211.054749786854, + "p95_completion_ms": 5925.437666941434, + "completion_times": [ + 646.6014580801129, + 646.6517918743193, + 648.0003749020398, + 648.0425829067826, + 649.7467500157654, + 655.3172077983618, + 709.6520001068711, + 739.160249941051, + 758.9775831438601, + 765.6225827522576, + 781.3366670161486, + 794.0517920069396, + 830.1948327571154, + 934.7008327022195, + 1128.9137918502092, + 1280.4350000806153, + 1343.3512919582427, + 5770.093000028282, + 5837.619333062321, + 5838.377375155687, + 5841.302624903619, + 5873.142041731626, + 5888.400207739323, + 5925.437666941434, + 10211.054749786854 + ], + "trend_slope_ms_per_req": 315.94157606184194, + "cv_pct": 107.01120819983475, + "first_avg_ms": 649.0600275962303, + "last_avg_ms": 6487.904857045838 + }, + "async_proxy_https": { + "total_time_s": 9.389842166565359, + "req_per_sec": 2.6624515680378646, + "avg_ms": 375.59368666261435, + "mean_completion_ms": 2589.5948897860944, + "median_completion_ms": 1484.0411669574678, + "min_completion_ms": 1248.2671667821705, + "max_completion_ms": 9389.763707760721, + "p95_completion_ms": 6766.011958010495, + "completion_times": [ + 1248.2671667821705, + 1250.7442496716976, + 1250.763749703765, + 1253.201249986887, + 1255.7337079197168, + 1271.3755830191076, + 1315.3173327445984, + 1315.4632919467986, + 1355.2738330326974, + 1417.8436668589711, + 1477.9754998162389, + 1478.531874716282, + 1484.0411669574678, + 1484.871749766171, + 1494.7943328879774, + 1495.8564168773592, + 1603.5692496225238, + 1669.343332760036, + 1814.27212478593, + 2077.471874654293, + 6500.267124734819, + 6529.980166815221, + 6539.1378328204155, + 6766.011958010495, + 9389.763707760721 + ], + "trend_slope_ms_per_req": 240.2845555046, + "cv_pct": 92.23658276456618, + "first_avg_ms": 1255.0142845138907, + "last_avg_ms": 5659.557827083127 + }, + "async_proxy_http2": { + "total_time_s": 10.31532825017348, + "req_per_sec": 2.4235777469882804, + "avg_ms": 412.6131300069392, + "mean_completion_ms": 2399.30708296597, + "median_completion_ms": 1357.419665902853, + "min_completion_ms": 1318.0975830182433, + "max_completion_ms": 10315.248374827206, + "p95_completion_ms": 6653.170874807984, + "completion_times": [ + 1318.0975830182433, + 1335.2675000205636, + 1345.1139577664435, + 1347.3623329773545, + 1349.6247911825776, + 1351.699666120112, + 1353.5969578661025, + 1356.3418327830732, + 1356.6933749243617, + 1356.9194581359625, + 1356.9536660797894, + 1357.1347501128912, + 1357.419665902853, + 1384.491499979049, + 1474.5117910206318, + 1502.730708103627, + 1517.9669577628374, + 1593.6719998717308, + 1601.343791000545, + 1730.5996660143137, + 1777.4664578028023, + 6236.10133305192, + 6653.148083016276, + 6653.170874807984, + 10315.248374827206 + ], + "trend_slope_ms_per_req": 210.48838855770344, + "cv_pct": 98.29550726330825, + "first_avg_ms": 1341.1943051808823, + "last_avg_ms": 4995.296940074435 + } + }, + "aiohttp": { + "async_local_http": { + "total_time_s": 0.21341962506994605, + "req_per_sec": 117.14011769914089, + "avg_ms": 8.536785002797842, + "mean_completion_ms": 132.24309977144003, + "median_completion_ms": 151.4206249266863, + "min_completion_ms": 29.856250155717134, + "max_completion_ms": 213.3849998936057, + "p95_completion_ms": 213.24712503701448, + "completion_times": [ + 29.856250155717134, + 29.930375050753355, + 30.32712498679757, + 30.68150021135807, + 31.073333229869604, + 31.26629116013646, + 118.78129094839096, + 119.0579580143094, + 119.30279107764363, + 119.47462521493435, + 119.59516583010554, + 150.9495829232037, + 151.4206249266863, + 151.43195819109678, + 151.44483326002955, + 151.6826250590384, + 182.57220787927508, + 183.15491592511535, + 183.7512501515448, + 184.04779117554426, + 184.1592499986291, + 212.57741609588265, + 212.90620788931847, + 213.24712503701448, + 213.3849998936057 + ], + "trend_slope_ms_per_req": 8.538483903528405, + "cv_pct": 49.784098543180015, + "first_avg_ms": 30.5224791324387, + "last_avg_ms": 200.58200574879135 + }, + "async_remote_http": { + "total_time_s": 0.4215480419807136, + "req_per_sec": 59.30522149393303, + "avg_ms": 16.861921679228544, + "mean_completion_ms": 392.28296311572194, + "median_completion_ms": 388.63666635006666, + "min_completion_ms": 388.0120003595948, + "max_completion_ms": 421.47316597402096, + "p95_completion_ms": 409.00958329439163, + "completion_times": [ + 388.0120003595948, + 388.2742081768811, + 388.29654129222035, + 388.31870816648006, + 388.33941612392664, + 388.3762499317527, + 388.3964163251221, + 388.4143750183284, + 388.4293753653765, + 388.44554126262665, + 388.5473753325641, + 388.6189581826329, + 388.63666635006666, + 388.77379102632403, + 391.11920818686485, + 391.19454100728035, + 391.21720800176263, + 391.32625004276633, + 391.92850003018975, + 393.3120830915868, + 394.7809161618352, + 394.813165999949, + 399.019833188504, + 409.00958329439163, + 421.47316597402096 + ], + "trend_slope_ms_per_req": 0.724490531720221, + "cv_pct": 1.95045494553597, + "first_avg_ms": 388.2695206751426, + "last_avg_ms": 400.6196068200682 + }, + "async_remote_https": { + "total_time_s": 1.6158367497846484, + "req_per_sec": 15.47186001514812, + "avg_ms": 64.63346999138594, + "mean_completion_ms": 1159.7812111489475, + "median_completion_ms": 1589.3306657671928, + "min_completion_ms": 568.5832076705992, + "max_completion_ms": 1615.781500004232, + "p95_completion_ms": 1613.6688748374581, + "completion_times": [ + 568.5832076705992, + 575.41837496683, + 577.3831657133996, + 592.7950828336179, + 592.8545407950878, + 595.2245825901628, + 595.2372499741614, + 595.274040941149, + 595.2835828065872, + 595.2931656502187, + 599.8523747548461, + 1581.9223746657372, + 1589.3306657671928, + 1589.3724579364061, + 1612.9894158802927, + 1613.4798326529562, + 1613.4955827146769, + 1613.5116247460246, + 1613.5251657105982, + 1613.5426657274365, + 1613.5546248406172, + 1613.5722496546805, + 1613.5838748887181, + 1613.6688748374581, + 1615.781500004232 + ], + "trend_slope_ms_per_req": 60.91693187204118, + "cv_pct": 44.50362549503791, + "first_avg_ms": 583.7098257616162, + "last_avg_ms": 1613.8898508091058 + }, + "async_proxy_http": { + "total_time_s": 5.904115791898221, + "req_per_sec": 4.234334298508448, + "avg_ms": 236.16463167592883, + "mean_completion_ms": 2048.6131764762104, + "median_completion_ms": 774.0246248431504, + "min_completion_ms": 632.8567918390036, + "max_completion_ms": 5904.020332731307, + "p95_completion_ms": 5903.692207764834, + "completion_times": [ + 632.8567918390036, + 632.9407077282667, + 632.9738749191165, + 635.7389576733112, + 642.1393747441471, + 650.9516667574644, + 682.5956250540912, + 730.5844170041382, + 739.3889999948442, + 743.0541249923408, + 765.9283326938748, + 771.01029176265, + 774.0246248431504, + 797.0921667292714, + 797.9625826701522, + 895.3044996596873, + 989.6402079612017, + 1809.5812499523163, + 1900.5636246874928, + 5733.172792010009, + 5733.925458043814, + 5817.1743750572205, + 5899.012124631554, + 5903.692207764834, + 5904.020332731307 + ], + "trend_slope_ms_per_req": 239.29697329321732, + "cv_pct": 107.05462929423612, + "first_avg_ms": 637.9335622768849, + "last_avg_ms": 5270.222987846604 + }, + "async_proxy_https": { + "total_time_s": 7.96602529194206, + "req_per_sec": 3.1383279720801864, + "avg_ms": 318.6410116776824, + "mean_completion_ms": 2490.202595219016, + "median_completion_ms": 1423.9390003494918, + "min_completion_ms": 1203.7602923810482, + "max_completion_ms": 7965.972583275288, + "p95_completion_ms": 6735.114125069231, + "completion_times": [ + 1203.7602923810482, + 1254.8653334379196, + 1276.1284583248198, + 1276.1530419811606, + 1276.2054172344506, + 1276.2223333120346, + 1276.2425001710653, + 1276.2601249851286, + 1276.2791672721505, + 1279.595167376101, + 1358.1022080034018, + 1401.8202084116638, + 1423.9390003494918, + 1441.0105422139168, + 1467.8540001623333, + 1497.6643333211541, + 1574.2304581217468, + 1645.939042326063, + 1681.3912922516465, + 1884.2778750695288, + 6209.998750127852, + 6622.415000107139, + 6673.623625189066, + 6735.114125069231, + 7965.972583275288 + ], + "trend_slope_ms_per_req": 225.8702483572639, + "cv_pct": 90.05119678966697, + "first_avg_ms": 1260.5558127785723, + "last_avg_ms": 5396.11332158425 + } + }, + "urllib3": { + "seq_local_http": { + "mean_ms": 0.5624216794967651, + "median_ms": 0.5249171517789364, + "min_ms": 0.3837086260318756, + "max_ms": 1.2374999932944775, + "stdev_ms": 0.18805148123385393, + "p50_ms": 0.5249171517789364, + "p95_ms": 0.9387503378093243, + "p99_ms": 1.2374999932944775, + "timings": [ + 0.6048749200999737, + 0.5457079969346523, + 0.5479161627590656, + 0.47075003385543823, + 0.5249171517789364, + 0.7149581797420979, + 0.6451248191297054, + 0.5600829608738422, + 0.5676252767443657, + 0.6465422920882702, + 0.5159997381269932, + 0.5292501300573349, + 0.46633370220661163, + 0.516749918460846, + 0.5060830153524876, + 0.46733301132917404, + 0.4310416989028454, + 0.39633363485336304, + 0.38529234007000923, + 0.3837086260318756, + 0.39900001138448715, + 0.391125213354826, + 1.2374999932944775, + 0.9387503378093243, + 0.6675408221781254 + ], + "q1_avg_ms": 0.5681874075283607, + "q2_avg_ms": 0.5774375361700853, + "q3_avg_ms": 0.4639791635175546, + "q4_avg_ms": 0.6289881920175893, + "trend_slope_ms_per_req": 0.0034111924469470978, + "cv_pct": 33.436029955693684, + "first_avg_ms": 0.5681874075283607, + "last_avg_ms": 0.6289881920175893 + }, + "seq_remote_http": { + "mean_ms": 202.21201665699482, + "median_ms": 201.9270001910627, + "min_ms": 200.3970411606133, + "max_ms": 205.46783320605755, + "stdev_ms": 1.0926398440888088, + "p50_ms": 201.9270001910627, + "p95_ms": 204.20779194682837, + "p99_ms": 205.46783320605755, + "timings": [ + 202.48795906081796, + 201.8296248279512, + 201.51087502017617, + 201.82095794007182, + 202.91941706091166, + 203.45320785418153, + 201.49012515321374, + 202.06466736271977, + 205.46783320605755, + 201.19391614571214, + 203.04204197600484, + 201.5861668623984, + 202.6051669381559, + 201.15595823153853, + 201.9270001910627, + 201.98974991217256, + 200.3970411606133, + 202.15758262202144, + 200.79408306628466, + 201.84249989688396, + 201.68716693297029, + 201.8344160169363, + 203.2661670818925, + 204.20779194682837, + 202.56899995729327 + ], + "q1_avg_ms": 202.33700696068504, + "q2_avg_ms": 202.4741251176844, + "q3_avg_ms": 201.70541650926074, + "q4_avg_ms": 202.3144464141556, + "trend_slope_ms_per_req": -0.0004570920450183061, + "cv_pct": 0.5403436759854958, + "first_avg_ms": 202.33700696068504, + "last_avg_ms": 202.3144464141556 + }, + "seq_remote_https": { + "mean_ms": 202.19380328431726, + "median_ms": 201.42233418300748, + "min_ms": 200.35016676411033, + "max_ms": 218.91854098066688, + "stdev_ms": 3.57429787345547, + "p50_ms": 201.42233418300748, + "p95_ms": 202.11349986493587, + "p99_ms": 218.91854098066688, + "timings": [ + 202.10095774382353, + 201.29474997520447, + 201.11049991101027, + 218.91854098066688, + 200.56112483143806, + 200.7400831207633, + 201.2667078524828, + 201.5883750282228, + 202.0352091640234, + 201.600749976933, + 200.95358276739717, + 200.5977090448141, + 200.60375006869435, + 201.5494997613132, + 201.30016608163714, + 200.980625115335, + 201.85337495058775, + 203.7824997678399, + 203.21529172360897, + 201.20508316904306, + 202.25545903667808, + 201.44504122436047, + 201.42233418300748, + 202.11349986493587, + 200.35016676411033 + ], + "q1_avg_ms": 204.12099276048443, + "q2_avg_ms": 201.3403889723122, + "q3_avg_ms": 201.67831929090121, + "q4_avg_ms": 201.71526799510633, + "trend_slope_ms_per_req": -0.09816719326548852, + "cv_pct": 1.7677583661797132, + "first_avg_ms": 204.12099276048443, + "last_avg_ms": 201.71526799510633 + }, + "seq_proxy_http": { + "mean_ms": 339.93284683674574, + "median_ms": 337.8008343279362, + "min_ms": 334.16762482374907, + "max_ms": 365.05004204809666, + "stdev_ms": 6.245216494684107, + "p50_ms": 337.8008343279362, + "p95_ms": 336.02654188871384, + "p99_ms": 365.05004204809666, + "timings": [ + 335.2159592323005, + 336.50970878079534, + 338.7751253321767, + 337.8148339688778, + 336.12237544730306, + 335.8867079950869, + 340.7305418513715, + 337.4489997513592, + 340.7715829089284, + 344.86491698771715, + 336.09929168596864, + 337.7392082475126, + 344.437625259161, + 338.9486246742308, + 348.49445801228285, + 337.8008343279362, + 336.6945837624371, + 340.5514173209667, + 341.96558268740773, + 334.16762482374907, + 336.71416714787483, + 365.05004204809666, + 337.49483386054635, + 336.02654188871384, + 341.99558291584253 + ], + "q1_avg_ms": 336.72078512609005, + "q2_avg_ms": 339.6090902388096, + "q3_avg_ms": 341.1545905595024, + "q4_avg_ms": 341.91633933889017, + "trend_slope_ms_per_req": 0.24036707320752052, + "cv_pct": 1.8371912431526218, + "first_avg_ms": 336.72078512609005, + "last_avg_ms": 341.91633933889017 + }, + "seq_proxy_https": { + "mean_ms": 483.13734345138073, + "median_ms": 462.7290000207722, + "min_ms": 458.35587475448847, + "max_ms": 565.0391662493348, + "stdev_ms": 37.05839680844899, + "p50_ms": 462.7290000207722, + "p95_ms": 458.8861670345068, + "p99_ms": 565.0391662493348, + "timings": [ + 496.95016676560044, + 458.873167168349, + 470.44454189017415, + 495.74270797893405, + 527.7360840700567, + 459.54233407974243, + 565.0391662493348, + 459.7725421190262, + 563.5908339172602, + 471.2948747910559, + 538.4571668691933, + 458.83895829319954, + 477.3300001397729, + 462.99579180777073, + 459.6104579977691, + 459.96362483128905, + 462.7290000207722, + 458.35587475448847, + 562.9274169914424, + 460.4739579372108, + 465.88895888999104, + 461.0756658948958, + 460.8398340642452, + 458.8861670345068, + 461.07429172843695 + ], + "q1_avg_ms": 484.8815003254761, + "q2_avg_ms": 509.4989237065117, + "q3_avg_ms": 463.49745825864375, + "q4_avg_ms": 475.88089893438985, + "trend_slope_ms_per_req": -1.4903116118735993, + "cv_pct": 7.670364816703154, + "first_avg_ms": 484.8815003254761, + "last_avg_ms": 475.88089893438985 + }, + "conc_local_http": { + "total_time_s": 0.01977804210036993, + "req_per_sec": 1264.028050558776, + "avg_ms": 0.7911216840147972, + "mean_completion_ms": 13.146789744496346, + "median_completion_ms": 13.113458175212145, + "min_completion_ms": 6.018000189214945, + "max_completion_ms": 19.601957872509956, + "p95_completion_ms": 19.50625004246831, + "completion_times": [ + 6.018000189214945, + 7.15795811265707, + 7.912457920610905, + 8.11962503939867, + 8.793666027486324, + 9.478708263486624, + 10.05245791748166, + 10.564208030700684, + 11.38029107823968, + 11.998999863862991, + 12.399707920849323, + 12.850165832787752, + 13.113458175212145, + 13.82108312100172, + 14.206749852746725, + 14.60366602987051, + 15.17158281058073, + 15.330333262681961, + 15.717290807515383, + 16.336875036358833, + 16.606749966740608, + 18.874875269830227, + 19.0526251681149, + 19.50625004246831, + 19.601957872509956 + ], + "trend_slope_ms_per_req": 0.5407178899846398, + "cv_pct": 30.43424291392943, + "first_avg_ms": 7.913402592142423, + "last_avg_ms": 17.956660594791174 + }, + "conc_remote_http": { + "total_time_s": 1.1989072081632912, + "req_per_sec": 20.852322706691908, + "avg_ms": 47.95628832653165, + "mean_completion_ms": 764.6720764413476, + "median_completion_ms": 748.5269997268915, + "min_completion_ms": 373.6220826394856, + "max_completion_ms": 1198.4162498265505, + "p95_completion_ms": 1168.795249890536, + "completion_times": [ + 373.6220826394856, + 376.2627076357603, + 376.9802078604698, + 391.94404194131494, + 400.99316695705056, + 560.905082616955, + 561.919666826725, + 562.5566248781979, + 585.4334998875856, + 600.8043326437473, + 746.2936248630285, + 748.0386248789728, + 748.5269997268915, + 780.0892917439342, + 799.3145827203989, + 932.9338748939335, + 934.4347496517003, + 934.7534580156207, + 973.5362916253507, + 998.6367500387132, + 1118.446874897927, + 1121.5474996715784, + 1121.6163747012615, + 1168.795249890536, + 1198.4162498265505 + ], + "trend_slope_ms_per_req": 37.14854016720962, + "cv_pct": 36.07042073919766, + "first_avg_ms": 413.4512149418394, + "last_avg_ms": 1100.1421843788453 + }, + "conc_remote_https": { + "total_time_s": 1.4661937500350177, + "req_per_sec": 17.05095250842729, + "avg_ms": 58.64775000140071, + "mean_completion_ms": 1070.2128170989454, + "median_completion_ms": 1071.4379590936005, + "min_completion_ms": 685.2652090601623, + "max_completion_ms": 1465.9126671031117, + "p95_completion_ms": 1463.3434168063104, + "completion_times": [ + 685.2652090601623, + 686.1725421622396, + 692.7249999716878, + 693.99966718629, + 694.5221251808107, + 873.7644171342254, + 874.3844591081142, + 882.2842920199037, + 885.7282497920096, + 886.0562918707728, + 1060.8346248045564, + 1061.6000001318753, + 1071.4379590936005, + 1077.165000140667, + 1077.8535841964185, + 1247.9533338919282, + 1248.470709193498, + 1260.210709180683, + 1267.783250194043, + 1268.6426672153175, + 1437.0504589751363, + 1439.5088339224458, + 1452.6509591378272, + 1463.3434168063104, + 1465.9126671031117 + ], + "trend_slope_ms_per_req": 36.743195650454325, + "cv_pct": 25.642951251551644, + "first_avg_ms": 721.0748267825693, + "last_avg_ms": 1399.2703219077416 + }, + "conc_proxy_http": { + "total_time_s": 5.7845485410653055, + "req_per_sec": 4.32185845144553, + "avg_ms": 231.38194164261222, + "mean_completion_ms": 1834.0126021392643, + "median_completion_ms": 1665.2789590880275, + "min_completion_ms": 616.2888752296567, + "max_completion_ms": 5783.991834148765, + "p95_completion_ms": 2849.0627091377974, + "completion_times": [ + 616.2888752296567, + 674.5618339627981, + 748.0967501178384, + 851.0247091762722, + 959.7282921895385, + 1050.0080841593444, + 1171.1456249468029, + 1257.7097918838263, + 1299.3655418977141, + 1399.741125293076, + 1590.0729172863066, + 1637.930542230606, + 1665.2789590880275, + 1745.3333749435842, + 1976.4662091620266, + 2012.0421671308577, + 2095.3133748844266, + 2105.9883339330554, + 2318.3787502348423, + 2429.831333924085, + 2435.2206252515316, + 2518.8755840063095, + 2658.85770926252, + 2849.0627091377974, + 5783.991834148765 + ], + "trend_slope_ms_per_req": 121.95276103663043, + "cv_pct": 57.46672946469322, + "first_avg_ms": 816.6180908059081, + "last_avg_ms": 2999.1740779951215 + }, + "conc_proxy_https": { + "total_time_s": 3.587047250010073, + "req_per_sec": 6.969520683043636, + "avg_ms": 143.48189000040293, + "mean_completion_ms": 2472.0581077970564, + "median_completion_ms": 2461.682708002627, + "min_completion_ms": 1385.937666054815, + "max_completion_ms": 3586.7230826988816, + "p95_completion_ms": 3376.4963326975703, + "completion_times": [ + 1385.937666054815, + 1505.9927497059107, + 1623.2648747973144, + 1738.4528326801956, + 1777.830165810883, + 1855.638540815562, + 2042.5391658209264, + 2077.1466246806085, + 2155.413749627769, + 2195.5562909133732, + 2322.4742906168103, + 2416.499000042677, + 2461.682708002627, + 2535.940165631473, + 2536.530874669552, + 2779.9618747085333, + 2847.8661659173667, + 2882.068374659866, + 2910.159124992788, + 3089.5186658017337, + 3119.761500041932, + 3273.0372077785432, + 3304.960665758699, + 3376.4963326975703, + 3586.7230826988816 + ], + "trend_slope_ms_per_req": 85.60515577081017, + "cv_pct": 25.55362767520155, + "first_avg_ms": 1647.8528049774468, + "last_avg_ms": 3237.2366542528785 + } + }, + "urllib": { + "seq_local_http": { + "mean_ms": 14.932354912161827, + "median_ms": 14.410082716494799, + "min_ms": 14.175374992191792, + "max_ms": 23.65033281967044, + "stdev_ms": 1.8797077590410405, + "p50_ms": 14.410082716494799, + "p95_ms": 14.175374992191792, + "p99_ms": 23.65033281967044, + "timings": [ + 15.000249724835157, + 15.788166783750057, + 15.973750036209822, + 15.429999679327011, + 14.543750323355198, + 14.263750053942204, + 14.58995882421732, + 23.65033281967044, + 14.325916301459074, + 14.737874735146761, + 14.388041105121374, + 14.479625038802624, + 14.268042054027319, + 14.22491716220975, + 14.410082716494799, + 14.532207977026701, + 14.352291822433472, + 14.421708881855011, + 14.212541282176971, + 14.276624657213688, + 14.182457700371742, + 14.573040883988142, + 14.243749901652336, + 14.175374992191792, + 14.264417346566916 + ], + "q1_avg_ms": 15.166611100236574, + "q2_avg_ms": 16.028624804069597, + "q3_avg_ms": 14.368208435674509, + "q4_avg_ms": 14.27545810916594, + "trend_slope_ms_per_req": -0.07962561176659969, + "cv_pct": 12.588153510268437, + "first_avg_ms": 15.166611100236574, + "last_avg_ms": 14.27545810916594 + }, + "seq_remote_http": { + "mean_ms": 380.1023116707802, + "median_ms": 375.8959583938122, + "min_ms": 371.15683406591415, + "max_ms": 415.49850022420287, + "stdev_ms": 10.937976123655908, + "p50_ms": 375.8959583938122, + "p95_ms": 377.0570829510689, + "p99_ms": 415.49850022420287, + "timings": [ + 375.8959583938122, + 375.05024997517467, + 374.8935000039637, + 380.1189581863582, + 378.1010829843581, + 410.52262485027313, + 373.89466585591435, + 373.946875333786, + 415.49850022420287, + 373.45212511718273, + 377.406416926533, + 373.82599990814924, + 374.5895423926413, + 391.6604579426348, + 377.7974173426628, + 385.10729232802987, + 373.87854093685746, + 385.15533274039626, + 375.5887080915272, + 371.15683406591415, + 382.492917124182, + 373.88062523677945, + 376.673833001405, + 377.0570829510689, + 374.91224985569715 + ], + "q1_avg_ms": 382.43039573232335, + "q2_avg_ms": 381.33743056096137, + "q3_avg_ms": 381.36476394720376, + "q4_avg_ms": 375.9660357609391, + "trend_slope_ms_per_req": -0.2424524652843292, + "cv_pct": 2.8776399900271246, + "first_avg_ms": 382.43039573232335, + "last_avg_ms": 375.9660357609391 + }, + "seq_remote_https": { + "mean_ms": 612.1495017036796, + "median_ms": 601.925958879292, + "min_ms": 583.0909172073007, + "max_ms": 696.0085830651224, + "stdev_ms": 29.19412351512955, + "p50_ms": 601.925958879292, + "p95_ms": 594.6044172160327, + "p99_ms": 696.0085830651224, + "timings": [ + 603.2500420697033, + 618.884667288512, + 598.2629577629268, + 597.6477079093456, + 656.276916153729, + 592.3334578983486, + 632.5445421971381, + 587.508250027895, + 645.4014577902853, + 603.5224171355367, + 608.7909997440875, + 603.3863751217723, + 601.925958879292, + 649.2458330467343, + 587.3315827921033, + 607.0994162000716, + 588.1905420683324, + 599.134000018239, + 696.0085830651224, + 594.4484169594944, + 601.3451251201332, + 585.5836668051779, + 667.919292114675, + 594.6044172160327, + 583.0909172073007 + ], + "q1_avg_ms": 611.1092915137609, + "q2_avg_ms": 613.5256736694524, + "q3_avg_ms": 605.4878888341287, + "q4_avg_ms": 617.5714883554194, + "trend_slope_ms_per_req": -0.09377995147728004, + "cv_pct": 4.769116602052128, + "first_avg_ms": 611.1092915137609, + "last_avg_ms": 617.5714883554194 + }, + "seq_proxy_http": { + "mean_ms": 1240.0643916986883, + "median_ms": 765.8068751916289, + "min_ms": 610.2143749594688, + "max_ms": 5991.093832999468, + "stdev_ms": 1465.9272357244781, + "p50_ms": 765.8068751916289, + "p95_ms": 659.4701670110226, + "p99_ms": 5991.093832999468, + "timings": [ + 2623.1370838359, + 5850.762374699116, + 633.8357920758426, + 617.8620001301169, + 5991.093832999468, + 877.5814999826252, + 610.2143749594688, + 617.946042213589, + 926.3353338465095, + 903.6799166351557, + 801.665416918695, + 632.4535408057272, + 896.4083748869598, + 884.3156667426229, + 612.0118326507509, + 652.3267910815775, + 765.8068751916289, + 638.2649159058928, + 616.9637497514486, + 659.0578341856599, + 767.6654593087733, + 1304.5009169727564, + 683.3074996247888, + 659.4701670110226, + 774.942500051111 + ], + "q1_avg_ms": 2765.712097287178, + "q2_avg_ms": 748.7157708965242, + "q3_avg_ms": 741.5224094099054, + "q4_avg_ms": 780.8440181293657, + "trend_slope_ms_per_req": -90.2359248074488, + "cv_pct": 118.21379966538626, + "first_avg_ms": 2765.712097287178, + "last_avg_ms": 780.8440181293657 + }, + "seq_proxy_https": { + "mean_ms": 2550.2425799146295, + "median_ms": 1371.4374997653067, + "min_ms": 1227.7850829996169, + "max_ms": 10202.50212494284, + "stdev_ms": 2505.7410621589042, + "p50_ms": 1371.4374997653067, + "p95_ms": 1274.2490423843265, + "p99_ms": 10202.50212494284, + "timings": [ + 1284.3333752825856, + 1579.5296248979867, + 1269.868833012879, + 1264.7038749419153, + 1274.2294161580503, + 1255.6849168613553, + 1227.7850829996169, + 1417.9071672260761, + 1494.5885417982936, + 10202.50212494284, + 1521.3278750889003, + 1246.7147093266249, + 6540.199666749686, + 1368.3451251126826, + 6231.276582926512, + 6730.226790998131, + 1528.1623327173293, + 6668.873999733478, + 1663.6435412801802, + 1371.4374997653067, + 1488.8023328967392, + 1298.6952918581665, + 1272.7477080188692, + 1274.2490423843265, + 1280.2290408872068 + ], + "q1_avg_ms": 1321.3916735257953, + "q2_avg_ms": 2851.804250230392, + "q3_avg_ms": 4844.514083039637, + "q4_avg_ms": 1378.5434938701135, + "trend_slope_ms_per_req": 21.217851078209396, + "cv_pct": 98.25500844091408, + "first_avg_ms": 1321.3916735257953, + "last_avg_ms": 1378.5434938701135 + }, + "conc_local_http": { + "total_time_s": 1.0157771250233054, + "req_per_sec": 24.611698161076834, + "avg_ms": 40.63108500093222, + "mean_completion_ms": 623.663721550256, + "median_completion_ms": 621.0865001194179, + "min_completion_ms": 206.81333309039474, + "max_completion_ms": 1015.644125174731, + "p95_completion_ms": 1014.6595831029117, + "completion_times": [ + 206.81333309039474, + 212.69987523555756, + 220.37987504154444, + 233.73145796358585, + 246.20041623711586, + 397.05416606739163, + 422.0190830528736, + 428.9832911454141, + 434.0740409679711, + 466.14025020971894, + 620.5192501656711, + 620.8686660975218, + 621.0865001194179, + 621.1574580520391, + 637.8520000725985, + 809.5738752745092, + 818.8529582694173, + 837.9576252773404, + 837.9857083782554, + 850.9937082417309, + 995.9820001386106, + 1007.2150412015617, + 1013.1487501785159, + 1014.6595831029117, + 1015.644125174731 + ], + "trend_slope_ms_per_req": 38.259568474828626, + "cv_pct": 45.7149946768937, + "first_avg_ms": 252.81318727259836, + "last_avg_ms": 962.2327023451882 + }, + "conc_remote_http": { + "total_time_s": 2.0494123329408467, + "req_per_sec": 12.198618890970433, + "avg_ms": 81.97649331763387, + "mean_completion_ms": 1242.0638162828982, + "median_completion_ms": 1232.519708108157, + "min_completion_ms": 478.47879119217396, + "max_completion_ms": 2048.6070411279798, + "p95_completion_ms": 2001.97449978441, + "completion_times": [ + 478.47879119217396, + 480.62995774671435, + 481.22974997386336, + 481.42829118296504, + 493.8067081384361, + 855.1221657544374, + 858.0002500675619, + 858.2492908462882, + 867.8534161299467, + 884.5066661015153, + 1228.6344161257148, + 1231.5363329835236, + 1232.519708108157, + 1253.5496661439538, + 1259.3323751352727, + 1604.903208091855, + 1605.1859580911696, + 1605.4079998284578, + 1627.3798327893019, + 1662.9373328760266, + 1979.6191658824682, + 1980.9778751805425, + 1989.724707789719, + 2001.97449978441, + 2048.6070411279798 + ], + "trend_slope_ms_per_req": 73.26494670293938, + "cv_pct": 44.07170879000787, + "first_avg_ms": 545.1159439980984, + "last_avg_ms": 1898.745779347207 + }, + "conc_remote_https": { + "total_time_s": 3.543331833090633, + "req_per_sec": 7.055506279860337, + "avg_ms": 141.73327332362533, + "mean_completion_ms": 2126.570554804057, + "median_completion_ms": 2140.277750324458, + "min_completion_ms": 762.042332906276, + "max_completion_ms": 3543.061458040029, + "p95_completion_ms": 3531.765416264534, + "completion_times": [ + 762.042332906276, + 791.9388329610229, + 792.3171250149608, + 792.4045412801206, + 803.0451661907136, + 1408.77650026232, + 1484.9971663206816, + 1485.0766249001026, + 1497.0557503402233, + 1518.8227081671357, + 2023.5767080448568, + 2140.180583111942, + 2140.277750324458, + 2140.400041360408, + 2143.2806253433228, + 2626.358333043754, + 2821.1793331429362, + 2822.265415918082, + 2822.4452501162887, + 2835.134624969214, + 3210.72879107669, + 3496.2633750401437, + 3530.869415961206, + 3531.765416264534, + 3543.061458040029 + ], + "trend_slope_ms_per_req": 129.2660855075631, + "cv_pct": 45.305361352507816, + "first_avg_ms": 891.754083102569, + "last_avg_ms": 3281.4669044954435 + }, + "conc_proxy_http": { + "total_time_s": 7.825866166967899, + "req_per_sec": 3.1945345686490514, + "avg_ms": 313.03464667871594, + "mean_completion_ms": 2591.4228267781436, + "median_completion_ms": 2315.0492082349956, + "min_completion_ms": 603.4224173054099, + "max_completion_ms": 7824.781833216548, + "p95_completion_ms": 4378.424708265811, + "completion_times": [ + 603.4224173054099, + 630.5331671610475, + 635.5810002423823, + 802.929375320673, + 1376.1951671913266, + 1452.4950003251433, + 1458.364250138402, + 1627.37483298406, + 1702.9037922620773, + 1988.087042234838, + 2072.8607079945505, + 2178.937958087772, + 2315.0492082349956, + 2407.9808332026005, + 2868.3845419436693, + 3091.773417312652, + 3109.0322080999613, + 3130.2000833675265, + 3548.624583054334, + 3722.394958138466, + 3755.376750137657, + 3771.4945832267404, + 4332.368250004947, + 4378.424708265811, + 7824.781833216548 + ], + "trend_slope_ms_per_req": 197.924756354724, + "cv_pct": 61.49606748322161, + "first_avg_ms": 916.8593545909971, + "last_avg_ms": 4476.209380863501 + }, + "conc_proxy_https": { + "total_time_s": 17.57506762491539, + "req_per_sec": 1.4224696333207056, + "avg_ms": 703.0027049966156, + "mean_completion_ms": 8077.9043699242175, + "median_completion_ms": 8346.526166424155, + "min_completion_ms": 1704.5877082273364, + "max_completion_ms": 17574.569333344698, + "p95_completion_ms": 16405.033791437745, + "completion_times": [ + 1704.5877082273364, + 1760.8432504348457, + 2077.870791312307, + 2972.895957995206, + 3229.762333445251, + 3320.3327911905944, + 4389.670625329018, + 4627.27175001055, + 6079.474916215986, + 6869.677500333637, + 6873.066000174731, + 7538.573125377297, + 8346.526166424155, + 9022.682125214487, + 9661.797833163291, + 9665.366291068494, + 10626.62220839411, + 10643.153000157326, + 10919.285875279456, + 10950.075041037053, + 11930.500291287899, + 12103.980000130832, + 12653.990541119128, + 16405.033791437745, + 17574.569333344698 + ], + "trend_slope_ms_per_req": 588.7501959219718, + "cv_pct": 54.686236848083404, + "first_avg_ms": 2511.048805434257, + "last_avg_ms": 13219.633553376687 + } + }, + "pycurl": { + "seq_local_http": { + "mean_ms": 0.2880200557410717, + "median_ms": 0.2683750353753567, + "min_ms": 0.23633381351828575, + "max_ms": 0.5250419490039349, + "stdev_ms": 0.05796656962583238, + "p50_ms": 0.2683750353753567, + "p95_ms": 0.28170784935355186, + "p99_ms": 0.5250419490039349, + "timings": [ + 0.30083395540714264, + 0.33304188400506973, + 0.25475025177001953, + 0.25508319959044456, + 0.26445789262652397, + 0.26475032791495323, + 0.25475025177001953, + 0.25025010108947754, + 0.24529127404093742, + 0.23633381351828575, + 0.28837472200393677, + 0.5250419490039349, + 0.2683750353753567, + 0.26449980214238167, + 0.2509169280529022, + 0.3246660344302654, + 0.244916882365942, + 0.24308310821652412, + 0.32112468034029007, + 0.3072922118008137, + 0.2954592928290367, + 0.29799994081258774, + 0.286999624222517, + 0.28170784935355186, + 0.3405003808438778 + ], + "q1_avg_ms": 0.2788195852190256, + "q2_avg_ms": 0.3000070185710986, + "q3_avg_ms": 0.266076298430562, + "q4_avg_ms": 0.3044405686003821, + "trend_slope_ms_per_req": 0.0011202495974990039, + "cv_pct": 20.125879594282136, + "first_avg_ms": 0.2788195852190256, + "last_avg_ms": 0.3044405686003821 + }, + "seq_remote_http": { + "mean_ms": 380.54755818098783, + "median_ms": 375.2424167469144, + "min_ms": 369.9572500772774, + "max_ms": 403.8451253436506, + "stdev_ms": 10.979382322842143, + "p50_ms": 375.2424167469144, + "p95_ms": 403.8451253436506, + "p99_ms": 403.8451253436506, + "timings": [ + 396.96575002744794, + 375.71941688656807, + 376.8006251193583, + 375.19295793026686, + 373.92666656523943, + 375.14254078269005, + 378.7165000103414, + 374.5987918227911, + 403.699291869998, + 375.14775013551116, + 373.5132906585932, + 373.8865409977734, + 401.4611658640206, + 373.8992912694812, + 373.09237476438284, + 379.99429227784276, + 369.9572500772774, + 373.93629224970937, + 371.3171659037471, + 375.2424167469144, + 375.48974994570017, + 376.77220860496163, + 385.5167906731367, + 403.8451253436506, + 399.8547079972923 + ], + "q1_avg_ms": 378.9579928852618, + "q2_avg_ms": 379.92702758250135, + "q3_avg_ms": 378.723444417119, + "q4_avg_ms": 384.00545217362895, + "trend_slope_ms_per_req": 0.2228175956182755, + "cv_pct": 2.8851537966301617, + "first_avg_ms": 378.9579928852618, + "last_avg_ms": 384.00545217362895 + }, + "seq_remote_https": { + "mean_ms": 583.3836384117603, + "median_ms": 575.0361662358046, + "min_ms": 564.4924999214709, + "max_ms": 615.2714998461306, + "stdev_ms": 17.86315907449909, + "p50_ms": 575.0361662358046, + "p95_ms": 575.0361662358046, + "p99_ms": 615.2714998461306, + "timings": [ + 610.3364578448236, + 571.4919166639447, + 569.3374169059098, + 584.6563749946654, + 569.648708216846, + 601.5578340739012, + 568.9098751172423, + 567.7964589558542, + 564.9615828879178, + 596.8181248754263, + 570.1627917587757, + 612.6052499748766, + 575.1003343611956, + 572.2347497940063, + 581.2487090006471, + 612.9242503084242, + 564.4924999214709, + 615.2714998461306, + 569.9759996496141, + 572.3182079382241, + 576.6047919169068, + 611.2745832651854, + 570.297583937645, + 575.0361662358046, + 599.5287918485701 + ], + "q1_avg_ms": 584.5047847833484, + "q2_avg_ms": 580.2090139283488, + "q3_avg_ms": 586.8786738719791, + "q4_avg_ms": 582.1480178274214, + "trend_slope_ms_per_req": 0.21649762701529723, + "cv_pct": 3.061991783508166, + "first_avg_ms": 584.5047847833484, + "last_avg_ms": 582.1480178274214 + }, + "seq_remote_http2": { + "mean_ms": 580.408303309232, + "median_ms": 573.1371659785509, + "min_ms": 566.9875000603497, + "max_ms": 621.8092497438192, + "stdev_ms": 17.561358506789308, + "p50_ms": 573.1371659785509, + "p95_ms": 568.6986250802875, + "p99_ms": 621.8092497438192, + "timings": [ + 621.8092497438192, + 570.1999999582767, + 570.6767924129963, + 569.3883751519024, + 568.7210001051426, + 573.1371659785509, + 573.1798750348389, + 587.415708694607, + 570.7633337005973, + 570.6502916291356, + 574.1010410711169, + 573.039208073169, + 575.9791252203286, + 574.7553752735257, + 581.7242078483105, + 618.354625068605, + 579.8855000175536, + 571.5117501094937, + 567.2774161212146, + 570.2675003558397, + 578.4142077900469, + 616.9910002499819, + 566.9875000603497, + 568.6986250802875, + 616.2787079811096 + ], + "q1_avg_ms": 578.9887638917813, + "q2_avg_ms": 574.8582430339108, + "q3_avg_ms": 583.7017639229695, + "q4_avg_ms": 583.55927966269, + "trend_slope_ms_per_req": 0.344412845845979, + "cv_pct": 3.02569043321093, + "first_avg_ms": 578.9887638917813, + "last_avg_ms": 583.55927966269 + }, + "seq_proxy_http": { + "mean_ms": 1535.4359816759825, + "median_ms": 765.1534168981016, + "min_ms": 605.4785843007267, + "max_ms": 5801.800208166242, + "stdev_ms": 1881.4423962679093, + "p50_ms": 765.1534168981016, + "p95_ms": 885.2386251091957, + "p99_ms": 5801.800208166242, + "timings": [ + 1108.948958106339, + 871.2106668390334, + 624.9956251122057, + 765.1534168981016, + 605.4785843007267, + 625.4166248254478, + 5737.1033327654, + 770.022832788527, + 621.9656248576939, + 902.0647080615163, + 5736.001666169614, + 617.0050827786326, + 5724.141040816903, + 620.8550003357232, + 609.0219579637051, + 823.6126671545208, + 607.8788749873638, + 626.9608750008047, + 735.9892092645168, + 5801.800208166242, + 616.9680831953883, + 782.8748752363026, + 867.0775420032442, + 885.2386251091957, + 698.1134591624141 + ], + "q1_avg_ms": 766.867312680309, + "q2_avg_ms": 2397.3605412368975, + "q3_avg_ms": 1502.0784027098368, + "q4_avg_ms": 1484.0088574481863, + "trend_slope_ms_per_req": -5.486624791430166, + "cv_pct": 122.5347340248109, + "first_avg_ms": 766.867312680309, + "last_avg_ms": 1484.0088574481863 + }, + "seq_proxy_https": { + "error": "error: (16, '')" + }, + "seq_proxy_http2": { + "mean_ms": 3263.551684971899, + "median_ms": 1440.7617920078337, + "min_ms": 1122.6772079244256, + "max_ms": 6672.611416783184, + "stdev_ms": 2464.5479923338844, + "p50_ms": 1440.7617920078337, + "p95_ms": 2082.0863330736756, + "p99_ms": 6672.611416783184, + "timings": [ + 1321.456584148109, + 1122.6772079244256, + 6364.369582850486, + 1216.124167200178, + 1197.0895417034626, + 1295.781624969095, + 6637.7994157373905, + 6672.611416783184, + 6461.835207883269, + 3501.6964161768556, + 6123.4892080537975, + 6412.524334155023, + 6206.941207870841, + 1337.8892499022186, + 1240.5212922021747, + 1222.2664998844266, + 6476.74833284691, + 1188.488541636616, + 1323.6009166575968, + 1440.7617920078337, + 1272.8248750790954, + 6446.727709379047, + 1284.488458186388, + 2082.0863330736756, + 1737.9922079853714 + ], + "q1_avg_ms": 2086.2497847992927, + "q2_avg_ms": 5968.325999798253, + "q3_avg_ms": 2945.475854057198, + "q4_avg_ms": 2226.926041767001, + "trend_slope_ms_per_req": -51.90440092558185, + "cv_pct": 75.51735747537597, + "first_avg_ms": 2086.2497847992927, + "last_avg_ms": 2226.926041767001 + }, + "conc_local_http": { + "total_time_s": 0.013084417209029198, + "req_per_sec": 1910.6697379496723, + "avg_ms": 0.5233766883611679, + "mean_completion_ms": 9.678313620388508, + "median_completion_ms": 9.75870806723833, + "min_completion_ms": 5.847332999110222, + "max_completion_ms": 12.762542348355055, + "p95_completion_ms": 12.630833312869072, + "completion_times": [ + 5.847332999110222, + 6.1217499896883965, + 6.592791993170977, + 6.947042420506477, + 7.4067083187401295, + 7.696458138525486, + 7.993667386472225, + 8.2246670499444, + 8.547750301659107, + 8.913875091820955, + 9.297167416661978, + 9.445375297218561, + 9.75870806723833, + 9.971917141228914, + 10.342042427510023, + 10.65037539228797, + 11.122625321149826, + 11.365750338882208, + 11.588457971811295, + 11.89466705545783, + 12.089875061064959, + 12.276292312890291, + 12.469167355448008, + 12.630833312869072, + 12.762542348355055 + ], + "trend_slope_ms_per_req": 0.29504744658389914, + "cv_pct": 22.515268361823683, + "first_avg_ms": 6.768680643290281, + "last_avg_ms": 12.24454791684236 + }, + "conc_remote_http": { + "total_time_s": 3.0425543752498925, + "req_per_sec": 8.216780019895845, + "avg_ms": 121.7021750099957, + "mean_completion_ms": 1417.55238218233, + "median_completion_ms": 1269.39691696316, + "min_completion_ms": 481.17300029844046, + "max_completion_ms": 3041.79545911029, + "p95_completion_ms": 3030.783375259489, + "completion_times": [ + 481.17300029844046, + 484.15575036779046, + 494.9099593795836, + 496.18604220449924, + 497.94675037264824, + 853.9111251011491, + 858.668084256351, + 893.3056253008544, + 895.2763341367245, + 895.7586670294404, + 1226.8716669641435, + 1230.8359593153, + 1269.39691696316, + 1269.482500385493, + 1296.2890840135515, + 1612.328584305942, + 1612.3998751863837, + 1646.4685420505702, + 1647.1682921983302, + 1668.7861671671271, + 2027.9570841230452, + 2986.039042007178, + 3020.9156670607626, + 3030.783375259489, + 3041.79545911029 + ], + "trend_slope_ms_per_req": 106.18908964742262, + "cv_pct": 58.95459759318125, + "first_avg_ms": 551.3804379540185, + "last_avg_ms": 2489.0635838466033 + }, + "conc_remote_https": { + "total_time_s": 2.9093551663681865, + "req_per_sec": 8.592969428070228, + "avg_ms": 116.37420665472746, + "mean_completion_ms": 1723.1765486486256, + "median_completion_ms": 1719.26608402282, + "min_completion_ms": 570.5572501756251, + "max_completion_ms": 2908.943541813642, + "p95_completion_ms": 2886.560416780412, + "completion_times": [ + 570.5572501756251, + 571.5868747793138, + 571.6156670823693, + 572.7415420114994, + 573.1095001101494, + 1139.8689169436693, + 1141.6332921944559, + 1144.2639171145856, + 1144.5742091163993, + 1156.5547091886401, + 1708.4065419621766, + 1708.5704999044538, + 1719.26608402282, + 1725.167625118047, + 1747.8585420176387, + 2286.8176670745015, + 2288.01479190588, + 2291.214333847165, + 2292.892166879028, + 2310.978792142123, + 2865.351791959256, + 2876.3842498883605, + 2876.480792183429, + 2886.560416780412, + 2908.943541813642 + ], + "trend_slope_ms_per_req": 111.18834246666385, + "cv_pct": 48.337527733184615, + "first_avg_ms": 666.5799585171044, + "last_avg_ms": 2716.79882166375 + }, + "conc_remote_http2": { + "total_time_s": 3.879673750139773, + "req_per_sec": 6.443840799525817, + "avg_ms": 155.18695000559092, + "mean_completion_ms": 2288.308186493814, + "median_completion_ms": 2265.338416211307, + "min_completion_ms": 571.5217082761228, + "max_completion_ms": 3879.410750232637, + "p95_completion_ms": 3446.368332952261, + "completion_times": [ + 571.5217082761228, + 576.428750064224, + 1137.6590831205249, + 1159.003249835223, + 1572.956415824592, + 1584.7585001029074, + 1587.4188332818449, + 1701.976666226983, + 1736.9628329761326, + 2135.6914578936994, + 2140.7986250706017, + 2144.857124891132, + 2265.338416211307, + 2301.597374957055, + 2716.0697081126273, + 2733.5578752681613, + 2739.7807082161307, + 2840.222083032131, + 2881.338416133076, + 3305.400375276804, + 3307.394875213504, + 3317.5658332183957, + 3423.626665957272, + 3446.368332952261, + 3879.410750232637 + ], + "trend_slope_ms_per_req": 123.10587811212127, + "cv_pct": 40.116262498090215, + "first_avg_ms": 1100.3879512039323, + "last_avg_ms": 3365.8721784262784 + }, + "conc_proxy_http": { + "total_time_s": 11.653687625192106, + "req_per_sec": 2.1452437034571608, + "avg_ms": 466.14750500768423, + "mean_completion_ms": 4136.942556519061, + "median_completion_ms": 3168.070917017758, + "min_completion_ms": 608.4578330628574, + "max_completion_ms": 11652.40416675806, + "p95_completion_ms": 11036.64437495172, + "completion_times": [ + 608.4578330628574, + 748.7189997918904, + 765.5088747851551, + 868.9369997009635, + 1356.5062079578638, + 1363.8713327236474, + 1756.2726670876145, + 1966.27016691491, + 1969.3028749898076, + 2556.2081248499453, + 2575.595374684781, + 2828.1164579093456, + 3168.070917017758, + 3337.372582871467, + 3703.5469580441713, + 3944.1361669451, + 4322.103082668036, + 5227.417499758303, + 5808.322957716882, + 6261.643792036921, + 7052.350624930114, + 8971.100667025894, + 9574.684207793325, + 11036.64437495172, + 11652.40416675806 + ], + "trend_slope_ms_per_req": 418.0885435959611, + "cv_pct": 79.40581732420195, + "first_avg_ms": 952.000041337063, + "last_avg_ms": 8622.450113030416 + }, + "conc_proxy_https": { + "total_time_s": 14.08548037474975, + "req_per_sec": 1.7748773442484855, + "avg_ms": 563.41921498999, + "mean_completion_ms": 5823.7887299619615, + "median_completion_ms": 6155.681374948472, + "min_completion_ms": 1194.1050831228495, + "max_completion_ms": 14082.68995815888, + "p95_completion_ms": 12739.582082722336, + "completion_times": [ + 1194.1050831228495, + 1317.5367498770356, + 1351.895249914378, + 2325.6242498755455, + 2518.798958044499, + 2550.3752077929676, + 3546.8856249935925, + 3709.734042175114, + 3989.805541932583, + 4757.377041969448, + 5030.552042182535, + 5975.52275005728, + 6155.681374948472, + 6181.051833089441, + 6218.954333104193, + 6236.739874817431, + 7160.739500075579, + 7428.932167124003, + 7429.760333150625, + 7622.992624994367, + 8527.968707960099, + 8651.950791943818, + 8889.462125021964, + 12739.582082722336, + 14082.68995815888 + ], + "trend_slope_ms_per_req": 429.0747704467951, + "cv_pct": 56.72391918188233, + "first_avg_ms": 1876.3892497712125, + "last_avg_ms": 9706.343803421727 + }, + "conc_proxy_http2": { + "total_time_s": 10.376577708870173, + "req_per_sec": 2.4092721802323456, + "avg_ms": 415.0631083548069, + "mean_completion_ms": 5344.439408518374, + "median_completion_ms": 5153.064749669284, + "min_completion_ms": 1179.5270419679582, + "max_completion_ms": 10375.620916951448, + "p95_completion_ms": 9338.71224988252, + "completion_times": [ + 1179.5270419679582, + 1195.1772086322308, + 1223.026541993022, + 2438.4946250356734, + 2447.613624855876, + 2704.1394589468837, + 2782.1962917223573, + 3668.5329587198794, + 3789.712083991617, + 3950.0215416774154, + 4984.246458858252, + 5040.991708636284, + 5153.064749669284, + 6184.060291852802, + 6335.5468339286745, + 6353.23949996382, + 6405.824542045593, + 7537.335249595344, + 7610.849583987147, + 7654.41833389923, + 7668.517249636352, + 8739.371124655008, + 8850.74504185468, + 9338.71224988252, + 10375.620916951448 + ], + "trend_slope_ms_per_req": 368.59382370773415, + "cv_pct": 51.09274417000369, + "first_avg_ms": 1864.6630835719407, + "last_avg_ms": 8605.46207155234 + } + }, + "curl_cffi": { + "seq_local_http": { + "mean_ms": 0.5624417588114738, + "median_ms": 0.5514589138329029, + "min_ms": 0.39900001138448715, + "max_ms": 1.1769160628318787, + "stdev_ms": 0.15901581824897093, + "p50_ms": 0.5514589138329029, + "p95_ms": 0.4516672343015671, + "p99_ms": 1.1769160628318787, + "timings": [ + 0.5941670387983322, + 0.6845002062618732, + 0.5880827084183693, + 0.6336248479783535, + 0.6862920708954334, + 1.1769160628318787, + 0.5934587679803371, + 0.5634166300296783, + 0.563083216547966, + 0.6381249986588955, + 0.749833881855011, + 0.5599171854555607, + 0.477583147585392, + 0.44229207560420036, + 0.432540662586689, + 0.41354214772582054, + 0.49966713413596153, + 0.4526660777628422, + 0.4120422527194023, + 0.39900001138448715, + 0.5000829696655273, + 0.5035838112235069, + 0.49349991604685783, + 0.4516672343015671, + 0.5514589138329029 + ], + "q1_avg_ms": 0.7272638225307068, + "q2_avg_ms": 0.6113057800879081, + "q3_avg_ms": 0.45304854090015095, + "q4_avg_ms": 0.4730478727391788, + "trend_slope_ms_per_req": -0.0118821683841256, + "cv_pct": 28.27240612165709, + "first_avg_ms": 0.7272638225307068, + "last_avg_ms": 0.4730478727391788 + }, + "seq_remote_http": { + "mean_ms": 380.1794267632067, + "median_ms": 378.81650030612946, + "min_ms": 371.0857923142612, + "max_ms": 405.3025422617793, + "stdev_ms": 8.298586822789632, + "p50_ms": 378.81650030612946, + "p95_ms": 371.0857923142612, + "p99_ms": 405.3025422617793, + "timings": [ + 375.50458312034607, + 380.69816725328565, + 371.3766671717167, + 405.3025422617793, + 374.69249963760376, + 379.02950029820204, + 374.95904229581356, + 384.1810836456716, + 373.4695836901665, + 387.60833302512765, + 383.74224957078695, + 376.9395831041038, + 375.77350018545985, + 402.95241633430123, + 380.63337514176965, + 379.10808296874166, + 377.7794996276498, + 379.74562495946884, + 375.153916887939, + 373.07879189029336, + 382.75908306241035, + 382.46425008401275, + 377.63100024312735, + 371.0857923142612, + 378.81650030612946 + ], + "q1_avg_ms": 381.1006599571556, + "q2_avg_ms": 380.149979221945, + "q3_avg_ms": 382.6654165362318, + "q4_avg_ms": 377.2841906840248, + "trend_slope_ms_per_req": -0.15045442797530156, + "cv_pct": 2.182807968711672, + "first_avg_ms": 381.1006599571556, + "last_avg_ms": 377.2841906840248 + }, + "seq_remote_https": { + "mean_ms": 571.3390984013677, + "median_ms": 563.6767921969295, + "min_ms": 556.7320422269404, + "max_ms": 620.1510829851031, + "stdev_ms": 17.667430470845954, + "p50_ms": 563.6767921969295, + "p95_ms": 562.1430408209562, + "p99_ms": 620.1510829851031, + "timings": [ + 592.3169157467782, + 607.2264998219907, + 571.2571670301259, + 563.6767921969295, + 613.8969589956105, + 561.2424169667065, + 578.305333852768, + 569.1782496869564, + 561.3300413824618, + 562.0789588429034, + 564.0962081961334, + 563.4558750316501, + 561.7363341152668, + 558.6694171652198, + 556.7320422269404, + 566.1347918212414, + 572.9974168352783, + 560.976667329669, + 620.1510829851031, + 566.9485828839242, + 562.0126659050584, + 564.9406253360212, + 559.2929171398282, + 562.1430408209562, + 562.6804577186704 + ], + "q1_avg_ms": 584.9361251263568, + "q2_avg_ms": 566.4074444988122, + "q3_avg_ms": 562.8744449156026, + "q4_avg_ms": 571.1670532556517, + "trend_slope_ms_per_req": -0.8350507195035999, + "cv_pct": 3.0922845154970515, + "first_avg_ms": 584.9361251263568, + "last_avg_ms": 571.1670532556517 + }, + "seq_remote_http2": { + "mean_ms": 582.555721681565, + "median_ms": 570.3217918053269, + "min_ms": 562.6535001210868, + "max_ms": 684.3861658126116, + "stdev_ms": 27.255888371497974, + "p50_ms": 570.3217918053269, + "p95_ms": 605.9830840677023, + "p99_ms": 684.3861658126116, + "timings": [ + 684.3861658126116, + 608.0547906458378, + 570.3217918053269, + 568.9463750459254, + 611.1752497963607, + 568.2052080519497, + 564.2247921787202, + 573.0979172512889, + 566.4841248653829, + 576.7792081460357, + 566.6257501579821, + 608.2053752616048, + 575.5722089670599, + 578.861374873668, + 586.1636251211166, + 567.7833752706647, + 567.2371247783303, + 577.2121250629425, + 568.6510419473052, + 562.6535001210868, + 615.4867908917367, + 563.6113747023046, + 564.2476249486208, + 605.9830840677023, + 563.923042267561 + ], + "q1_avg_ms": 601.8482635263354, + "q2_avg_ms": 575.9028613101691, + "q3_avg_ms": 575.471639012297, + "q4_avg_ms": 577.7937798494739, + "trend_slope_ms_per_req": -1.1912213577530706, + "cv_pct": 4.678674907324404, + "first_avg_ms": 601.8482635263354, + "last_avg_ms": 577.7937798494739 + }, + "seq_proxy_http": { + "mean_ms": 1165.6250498630106, + "median_ms": 744.1465831361711, + "min_ms": 601.0421668179333, + "max_ms": 5888.546749949455, + "stdev_ms": 1401.6250295889404, + "p50_ms": 744.1465831361711, + "p95_ms": 614.3972910940647, + "p99_ms": 5888.546749949455, + "timings": [ + 616.1560001783073, + 620.5280418507755, + 760.0191659294069, + 1495.6742501817644, + 794.5214998908341, + 621.5407908894122, + 725.2401658333838, + 740.5819999985397, + 796.0197501815856, + 645.3184997662902, + 682.6708330772817, + 924.7033749707043, + 5673.706749919802, + 799.7364168986678, + 609.6012918278575, + 801.8259587697685, + 601.0421668179333, + 610.0050415843725, + 937.6442497596145, + 765.4841253533959, + 951.8513330258429, + 744.1465831361711, + 5888.546749949455, + 614.3972910940647, + 719.6639156900346 + ], + "q1_avg_ms": 818.07329148675, + "q2_avg_ms": 752.4224373046309, + "q3_avg_ms": 1515.9862709697336, + "q4_avg_ms": 1517.3906068583685, + "trend_slope_ms_per_req": 36.92497791185115, + "cv_pct": 120.24664618812588, + "first_avg_ms": 818.07329148675, + "last_avg_ms": 1517.3906068583685 + }, + "seq_proxy_https": { + "mean_ms": 1866.4429666660726, + "median_ms": 1329.8462498933077, + "min_ms": 1154.3512078933418, + "max_ms": 6762.83249957487, + "stdev_ms": 1495.4344600088891, + "p50_ms": 1329.8462498933077, + "p95_ms": 1154.3512078933418, + "p99_ms": 6762.83249957487, + "timings": [ + 1195.3115831129253, + 1639.6879171952605, + 1237.990166991949, + 1203.2567500136793, + 1189.4224169664085, + 1234.1289576143026, + 6722.263416741043, + 2011.8272500112653, + 1326.1238751001656, + 1715.4888329096138, + 2085.1569999940693, + 1196.9604580663145, + 1354.3100417591631, + 1232.2926670312881, + 1329.8462498933077, + 1225.3061668016016, + 1646.3916250504553, + 1834.6211249008775, + 1372.5951667875051, + 1927.3037500679493, + 1199.292792007327, + 1206.714958883822, + 6762.83249957487, + 1154.3512078933418, + 1657.5972912833095 + ], + "q1_avg_ms": 1283.2996319824208, + "q2_avg_ms": 2509.636805470412, + "q3_avg_ms": 1437.127979239449, + "q4_avg_ms": 2182.9553809283034, + "trend_slope_ms_per_req": 19.823578487580214, + "cv_pct": 80.12216214032534, + "first_avg_ms": 1283.2996319824208, + "last_avg_ms": 2182.9553809283034 + }, + "seq_proxy_http2": { + "error": "SSLError: Failed to perform, curl: (35) TLS connect error: error:00000000:invalid library (0):OPENSSL_internal" + }, + "conc_local_http": { + "total_time_s": 0.017612124793231487, + "req_per_sec": 1419.4766556280447, + "avg_ms": 0.7044849917292595, + "mean_completion_ms": 13.563212919980288, + "median_completion_ms": 13.60995788127184, + "min_completion_ms": 7.8477500937879086, + "max_completion_ms": 17.492915969341993, + "p95_completion_ms": 17.433166038244963, + "completion_times": [ + 7.8477500937879086, + 9.218832943588495, + 10.011999867856503, + 10.148707777261734, + 10.354749858379364, + 10.936957783997059, + 11.08558289706707, + 11.507082730531693, + 12.097958009690046, + 12.207624968141317, + 12.830332852900028, + 13.431999832391739, + 13.60995788127184, + 14.112166129052639, + 14.572457876056433, + 15.058707911521196, + 15.54741570726037, + 15.649874694645405, + 16.184499952942133, + 16.43120776861906, + 16.892832703888416, + 17.172665800899267, + 17.242874950170517, + 17.433166038244963, + 17.492915969341993 + ], + "trend_slope_ms_per_req": 0.39247030643029857, + "cv_pct": 21.438846291150234, + "first_avg_ms": 9.75316638747851, + "last_avg_ms": 16.978594740586622 + }, + "conc_remote_http": { + "total_time_s": 2.050203041639179, + "req_per_sec": 12.193914208620036, + "avg_ms": 82.00812166556716, + "mean_completion_ms": 1246.3426682911813, + "median_completion_ms": 1235.9148329123855, + "min_completion_ms": 479.86604180186987, + "max_completion_ms": 2049.610166810453, + "p95_completion_ms": 2012.426916975528, + "completion_times": [ + 479.86604180186987, + 481.2311250716448, + 483.4908749908209, + 484.4029168598354, + 486.16237472742796, + 853.843207936734, + 857.0875418372452, + 858.9340830221772, + 863.902083132416, + 892.3684167675674, + 1233.04787511006, + 1235.5849579907954, + 1235.9148329123855, + 1238.437124993652, + 1290.528167039156, + 1612.525667063892, + 1613.413707818836, + 1613.764917012304, + 1641.0584580153227, + 1673.44258306548, + 1987.6301251351833, + 1989.9090831167996, + 1989.983458071947, + 2012.426916975528, + 2049.610166810453 + ], + "trend_slope_ms_per_req": 73.69062525757516, + "cv_pct": 44.166953636104715, + "first_avg_ms": 544.8327568980554, + "last_avg_ms": 1906.2943987415306 + }, + "conc_remote_https": { + "total_time_s": 2.9040484577417374, + "req_per_sec": 8.608671777963595, + "avg_ms": 116.1619383096695, + "mean_completion_ms": 1730.5925779789686, + "median_completion_ms": 1719.4635830819607, + "min_completion_ms": 571.5185827575624, + "max_completion_ms": 2903.605666011572, + "p95_completion_ms": 2891.220999881625, + "completion_times": [ + 571.5185827575624, + 572.7196247316897, + 572.9410001076758, + 578.3980409614742, + 619.8765407316387, + 1127.440500073135, + 1134.3674999661744, + 1135.6560410931706, + 1179.6164577826858, + 1187.042708043009, + 1718.2629159651697, + 1719.208249822259, + 1719.4635830819607, + 1744.2582910880446, + 1744.4159160368145, + 2290.528249926865, + 2290.868000127375, + 2301.7396247014403, + 2326.20804104954, + 2330.38437506184, + 2858.017874881625, + 2859.4006658531725, + 2887.6549997366965, + 2891.220999881625, + 2903.605666011572 + ], + "trend_slope_ms_per_req": 111.0036473423959, + "cv_pct": 47.96075959403321, + "first_avg_ms": 673.8157148938626, + "last_avg_ms": 2722.356088925153 + }, + "conc_remote_http2": { + "total_time_s": 2.847100625280291, + "req_per_sec": 8.780862811105878, + "avg_ms": 113.88402501121163, + "mean_completion_ms": 1703.793304655701, + "median_completion_ms": 1710.8856248669326, + "min_completion_ms": 562.2637080959976, + "max_completion_ms": 2846.3324159383774, + "p95_completion_ms": 2838.7306248769164, + "completion_times": [ + 562.2637080959976, + 568.3424999006093, + 568.6114160344005, + 569.4256657734513, + 569.7839157655835, + 1126.8286248669028, + 1131.2648328021169, + 1135.7222497463226, + 1136.3937500864267, + 1136.7704160511494, + 1700.913957785815, + 1709.6965829841793, + 1710.8856248669326, + 1711.0978327691555, + 1711.3303747028112, + 2267.3174580559134, + 2268.1510411202908, + 2273.058250080794, + 2273.8898750394583, + 2275.8955829776824, + 2828.871415928006, + 2835.77875001356, + 2837.47575012967, + 2838.7306248769164, + 2846.3324159383774 + ], + "trend_slope_ms_per_req": 109.28339869607814, + "cv_pct": 48.10034632223804, + "first_avg_ms": 660.8759717394909, + "last_avg_ms": 2676.7106307005242 + }, + "conc_proxy_http": { + "total_time_s": 6.561570833902806, + "req_per_sec": 3.8100632657698617, + "avg_ms": 262.46283335611224, + "mean_completion_ms": 2788.9822751656175, + "median_completion_ms": 2844.2582921124995, + "min_completion_ms": 643.2352080009878, + "max_completion_ms": 6561.265416909009, + "p95_completion_ms": 5786.245875060558, + "completion_times": [ + 643.2352080009878, + 699.2527502588928, + 749.2761253379285, + 779.2860832996666, + 1442.0457081869245, + 1463.3205831050873, + 1465.7873753458261, + 1593.6621250584722, + 2048.7527921795845, + 2141.8533753603697, + 2264.5381670445204, + 2402.820042334497, + 2844.2582921124995, + 2860.8196252025664, + 2874.6625422500074, + 3011.444792151451, + 3612.0213330723345, + 3612.928833346814, + 3703.898624982685, + 3943.124750163406, + 4238.238832913339, + 4341.830958146602, + 4639.986667316407, + 5786.245875060558, + 6561.265416909009 + ], + "trend_slope_ms_per_req": 209.02812932832882, + "cv_pct": 56.71814254862295, + "first_avg_ms": 962.7360763649145, + "last_avg_ms": 4744.941589356001 + }, + "conc_proxy_https": { + "total_time_s": 10.171413416042924, + "req_per_sec": 2.4578688307535113, + "avg_ms": 406.85653664171696, + "mean_completion_ms": 5804.943978432566, + "median_completion_ms": 6338.456416968256, + "min_completion_ms": 1183.2412090152502, + "max_completion_ms": 10170.695041771978, + "p95_completion_ms": 10113.213791977614, + "completion_times": [ + 1183.2412090152502, + 1187.4786247499287, + 1213.105583563447, + 1434.1049999929965, + 2392.0932495966554, + 2661.535083781928, + 2732.1890415623784, + 3673.6652916297317, + 3980.868374928832, + 4301.366874948144, + 4922.313416842371, + 6120.528624858707, + 6338.456416968256, + 6456.787083763629, + 7301.339124795049, + 7540.051249787211, + 7679.361291695386, + 8123.896166682243, + 8523.241333663464, + 8748.688499908894, + 9236.901458818465, + 9267.159541603178, + 9821.318083908409, + 10113.213791977614, + 10170.695041771978 + ], + "trend_slope_ms_per_req": 420.4480700703481, + "cv_pct": 53.706632203724105, + "first_avg_ms": 1678.593125116701, + "last_avg_ms": 9411.602535950286 + }, + "conc_proxy_http2": { + "total_time_s": 15.620816332753748, + "req_per_sec": 1.6004285222648684, + "avg_ms": 624.8326533101499, + "mean_completion_ms": 6544.1935268417, + "median_completion_ms": 6566.163084004074, + "min_completion_ms": 1191.7932499200106, + "max_completion_ms": 15620.49866700545, + "p95_completion_ms": 14219.555874820799, + "completion_times": [ + 1191.7932499200106, + 1199.3380840867758, + 1527.489583939314, + 1665.1389999315143, + 2383.0537917092443, + 2762.737416662276, + 2986.717708874494, + 4186.366624664515, + 4667.628834024072, + 5411.118916701525, + 5966.009374707937, + 6493.437249679118, + 6566.163084004074, + 7181.451125070453, + 7662.826041691005, + 7724.672958720475, + 8123.108999803662, + 8523.124958854169, + 9055.551124736667, + 9056.86316685751, + 9352.335083764046, + 9784.980083815753, + 10292.877166997641, + 14219.555874820799, + 15620.49866700545 + ], + "trend_slope_ms_per_req": 507.5795838777692, + "cv_pct": 58.93259247115205, + "first_avg_ms": 1788.2585210415225, + "last_avg_ms": 11054.665881142553 + } + } + } +} \ No newline at end of file diff --git a/benchmarks/results/darwin/0.2.9/benchmark.md b/benchmarks/results/darwin/0.2.9/benchmark.md new file mode 100644 index 0000000..cd3f0d3 --- /dev/null +++ b/benchmarks/results/darwin/0.2.9/benchmark.md @@ -0,0 +1,133 @@ +# httpmorph Benchmark Results + +**Version:** 0.2.9 | **Generated:** 2025-12-15 + +## System Information + +| Property | Value | +|----------|-------| +| **OS** | Darwin (macOS-15.6-arm64-arm-64bit) | +| **Processor** | arm | +| **CPU Cores** | 10 | +| **Memory** | 16.0 GB | +| **Python** | 3.11.5 (CPython) | + +## Test Configuration + +- **Sequential Requests:** 25 (warmup: 5) +- **Concurrent Requests:** 25 (workers: 5) + +## Library Versions + +| Library | Version | Status | +|---------|---------|--------| +| **httpmorph** | `0.2.9` | Installed | +| **requests** | `2.31.0` | Installed | +| **httpx** | `0.28.1` | Installed | +| **aiohttp** | `3.13.2` | Installed | +| **urllib3** | `1.26.16` | Installed | +| **urllib** | `built-in (Python 3.11.5)` | Installed | +| **pycurl** | `PycURL/7.45.2 libcurl/8.7.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.12 nghttp2/1.64.0` | Installed | +| **curl_cffi** | `0.13.0` | Installed | + +## Sequential Tests (Lower is Better) + +Mean response time in milliseconds + +| Library | Local HTTP | Proxy HTTP | Proxy HTTP2 | Proxy HTTPs | Remote HTTP | Remote HTTP2 | Remote HTTPs | +|---------|--------:|--------:|--------:|--------:|--------:|--------:|--------:| +| **curl_cffi** | 0.56ms | 1165.63ms | ERROR | 1866.44ms | 380.18ms | 582.56ms | 571.34ms | +| **httpmorph** | 0.14ms | 1170.21ms | 1782.81ms | 2048.53ms | 189.68ms | 187.65ms | 188.44ms | +| **httpx** | 0.61ms | 490.34ms | 320.46ms | 343.61ms | 381.17ms | 571.08ms | 571.55ms | +| **pycurl** | 0.29ms | 1535.44ms | 3263.55ms | ERROR | 380.55ms | 580.41ms | 583.38ms | +| **requests** | 1.16ms | 374.35ms | N/A | 467.73ms | 194.14ms | N/A | 189.35ms | +| **urllib** | 14.93ms | 1240.06ms | N/A | 2550.24ms | 380.10ms | N/A | 612.15ms | +| **urllib3** | 0.56ms | 339.93ms | N/A | 483.14ms | 202.21ms | N/A | 202.19ms | + +**Winners (Sequential):** +- Local HTTP: **httpmorph** (0.14ms) +- Proxy HTTP: **urllib3** (339.93ms) +- Proxy HTTP2: **httpx** (320.46ms) +- Proxy HTTPs: **httpx** (343.61ms) +- Remote HTTP: **httpmorph** (189.68ms) +- Remote HTTP2: **httpmorph** (187.65ms) +- Remote HTTPs: **httpmorph** (188.44ms) + +## Concurrent Tests (Higher is Better) + +Throughput in requests per second + +| Library | Local HTTP | Proxy HTTP | Proxy HTTP2 | Proxy HTTPs | Remote HTTP | Remote HTTP2 | Remote HTTPs | +|---------|--------:|--------:|--------:|--------:|--------:|--------:|--------:| +| **curl_cffi** | 1419.48 | 3.81 | 1.60 | 2.46 | 12.19 | 8.78 | 8.61 | +| **httpmorph** | 3033.50 | 2.01 | 0.71 | 1.22 | 21.74 | 18.40 | 17.46 | +| **httpx** | 775.96 | 2.98 | 3.83 | 3.86 | 12.28 | 8.54 | 8.71 | +| **pycurl** | 1910.67 | 2.15 | 2.41 | 1.77 | 8.22 | 6.44 | 8.59 | +| **requests** | 779.51 | 9.10 | N/A | 3.74 | 19.91 | N/A | 16.67 | +| **urllib** | 24.61 | 3.19 | N/A | 1.42 | 12.20 | N/A | 7.06 | +| **urllib3** | 1264.03 | 4.32 | N/A | 6.97 | 20.85 | N/A | 17.05 | + +**Winners (Concurrent):** +- Local HTTP: **httpmorph** (3033.50 req/s) +- Proxy HTTP: **requests** (9.10 req/s) +- Proxy HTTP2: **httpx** (3.83 req/s) +- Proxy HTTPs: **urllib3** (6.97 req/s) +- Remote HTTP: **httpmorph** (21.74 req/s) +- Remote HTTP2: **httpmorph** (18.40 req/s) +- Remote HTTPs: **httpmorph** (17.46 req/s) + +## Async Tests (Higher is Better) + +Throughput in requests per second + +| Library | Local HTTP | Proxy HTTP | Proxy HTTP2 | Proxy HTTPs | Remote HTTP | Remote HTTP2 | Remote HTTPs | +|---------|--------:|--------:|--------:|--------:|--------:|--------:|--------:| +| **aiohttp** | 117.14 | 4.23 | N/A | 3.14 | 59.31 | N/A | 15.47 | +| **httpmorph** | 193.53 | 3.18 | 1.50 | 1.82 | 47.96 | 15.45 | 9.68 | +| **httpx** | 131.47 | 2.45 | 2.42 | 2.66 | 51.84 | 15.43 | 9.38 | + +**Winners (Async):** +- Local HTTP: **httpmorph** (193.53 req/s) +- Proxy HTTP: **aiohttp** (4.23 req/s) +- Proxy HTTP2: **httpx** (2.42 req/s) +- Proxy HTTPs: **aiohttp** (3.14 req/s) +- Remote HTTP: **aiohttp** (59.31 req/s) +- Remote HTTP2: **httpmorph** (15.45 req/s) +- Remote HTTPs: **aiohttp** (15.47 req/s) + +## Overall Performance Summary + +### Sequential Tests: httpmorph vs requests Speedup + +| Test | httpmorph | requests | Speedup | +|------|----------:|---------:|--------:| +| Local HTTP | 0.14ms | 1.16ms | **8.06x** faster | +| Proxy HTTP | 1170.21ms | 374.35ms | 0.32x slower | +| Proxy HTTPs | 2048.53ms | 467.73ms | 0.23x slower | +| Remote HTTP | 189.68ms | 194.14ms | **1.02x** faster | +| Remote HTTPs | 188.44ms | 189.35ms | **1.00x** faster | + +### Concurrent Tests: httpmorph vs requests Speedup + +| Test | httpmorph | requests | Speedup | +|------|----------:|---------:|--------:| +| Local HTTP | 3033.50 req/s | 779.51 req/s | **3.89x** faster | +| Proxy HTTP | 2.01 req/s | 9.10 req/s | 0.22x slower | +| Proxy HTTPs | 1.22 req/s | 3.74 req/s | 0.33x slower | +| Remote HTTP | 21.74 req/s | 19.91 req/s | **1.09x** faster | +| Remote HTTPs | 17.46 req/s | 16.67 req/s | **1.05x** faster | + +### Async Tests: httpmorph vs httpx Speedup + +| Test | httpmorph | httpx | Speedup | +|------|----------:|------:|--------:| +| Local HTTP | 193.53 req/s | 131.47 req/s | **1.47x** faster | +| Proxy HTTP | 3.18 req/s | 2.45 req/s | **1.30x** faster | +| Proxy HTTP2 | 1.50 req/s | 2.42 req/s | 0.62x slower | +| Proxy HTTPs | 1.82 req/s | 2.66 req/s | 0.68x slower | +| Remote HTTP | 47.96 req/s | 51.84 req/s | 0.93x slower | +| Remote HTTP2 | 15.45 req/s | 15.43 req/s | **1.00x** faster | +| Remote HTTPs | 9.68 req/s | 9.38 req/s | **1.03x** faster | + +--- +*Generated by httpmorph benchmark suite* diff --git a/benchmarks/results/darwin/0.2.9/graphics/01_sequential_all_latest.png b/benchmarks/results/darwin/0.2.9/graphics/01_sequential_all_latest.png new file mode 100644 index 0000000..1b75e4c Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/01_sequential_all_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/02_concurrent_all_latest.png b/benchmarks/results/darwin/0.2.9/graphics/02_concurrent_all_latest.png new file mode 100644 index 0000000..3b2cbe9 Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/02_concurrent_all_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/03_async_all_latest.png b/benchmarks/results/darwin/0.2.9/graphics/03_async_all_latest.png new file mode 100644 index 0000000..3e2d7c8 Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/03_async_all_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/04_http2_latest.png b/benchmarks/results/darwin/0.2.9/graphics/04_http2_latest.png new file mode 100644 index 0000000..befd94d Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/04_http2_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/05_stability_latest.png b/benchmarks/results/darwin/0.2.9/graphics/05_stability_latest.png new file mode 100644 index 0000000..a6aad56 Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/05_stability_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/06_trends_latest.png b/benchmarks/results/darwin/0.2.9/graphics/06_trends_latest.png new file mode 100644 index 0000000..9b4bc71 Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/06_trends_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/07_proxy_latest.png b/benchmarks/results/darwin/0.2.9/graphics/07_proxy_latest.png new file mode 100644 index 0000000..3073a73 Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/07_proxy_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/08_heatmap_latest.png b/benchmarks/results/darwin/0.2.9/graphics/08_heatmap_latest.png new file mode 100644 index 0000000..3b931e5 Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/08_heatmap_latest.png differ diff --git a/benchmarks/results/darwin/0.2.9/graphics/09_ranking_latest.png b/benchmarks/results/darwin/0.2.9/graphics/09_ranking_latest.png new file mode 100644 index 0000000..3702d28 Binary files /dev/null and b/benchmarks/results/darwin/0.2.9/graphics/09_ranking_latest.png differ diff --git a/include/httpmorph.h b/include/httpmorph.h index 70e703d..388934d 100644 --- a/include/httpmorph.h +++ b/include/httpmorph.h @@ -391,6 +391,63 @@ httpmorph_response_t* httpmorph_session_request( */ size_t httpmorph_session_cookie_count(httpmorph_session_t *session); +/* Connection Pool API */ + +/** + * Pre-warm connections to a host + * Establishes connections proactively for faster subsequent requests + * + * @param client HTTP client to use for connection setup + * @param host Target hostname + * @param port Target port (0 uses default: 443 for TLS, 80 for HTTP) + * @param use_tls Whether to establish TLS connections + * @param count Number of connections to pre-warm + * @return Number of connections successfully pre-warmed + */ +int httpmorph_pool_prewarm( + httpmorph_client_t *client, + const char *host, + int port, + bool use_tls, + int count +); + +/** + * Configure connection pool settings + * + * @param pool Connection pool to configure + * @param idle_timeout_seconds Idle timeout in seconds (0 = default 30s) + * @param max_connections_per_host Max connections per host (0 = default 6) + * @param max_total_connections Max total connections (0 = default 100) + */ +void httpmorph_pool_configure( + httpmorph_pool_t *pool, + int idle_timeout_seconds, + int max_connections_per_host, + int max_total_connections +); + +/** + * Get connection pool statistics + * + * @param pool Connection pool + * @param total_connections Output: total connections in pool + * @param active_connections Output: active connections + */ +void httpmorph_pool_stats( + httpmorph_pool_t *pool, + int *total_connections, + int *active_connections +); + +/** + * Clean up idle connections in the pool + * Removes connections that have been idle longer than the timeout + * + * @param pool Connection pool to clean up + */ +void httpmorph_pool_cleanup_idle(httpmorph_pool_t *pool); + /* Async I/O API */ /** diff --git a/pyproject.toml b/pyproject.toml index 7a6b1e3..ee61610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "httpmorph" -version = "0.2.8" +version = "0.2.9" description = "A Python HTTP client focused on mimicking browser fingerprints." readme = "README.md" requires-python = ">=3.8" diff --git a/src/bindings/_async.pyx b/src/bindings/_async.pyx index 9506f7c..98c5457 100644 --- a/src/bindings/_async.pyx +++ b/src/bindings/_async.pyx @@ -15,8 +15,13 @@ import asyncio import select import socket import sys +import concurrent.futures +from urllib.parse import urlparse from typing import Optional, Dict, Any +# Thread pool for blocking operations (DNS) +_dns_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="dns_") + # External C declarations cdef extern from "../core/async_request.h": @@ -93,6 +98,10 @@ cdef extern from "../core/async_request_manager.h": async_request_manager_t *mgr, uint64_t request_id ) nogil + int async_manager_remove_request( + async_request_manager_t *mgr, + uint64_t request_id + ) nogil int async_manager_poll( async_request_manager_t *mgr, uint32_t timeout_ms @@ -268,9 +277,6 @@ cdef class AsyncRequestManager: httpmorph_request_set_verify_ssl(req, verify) # Set proxy if provided - # WARNING: Proxy support is NOT implemented in the async I/O engine yet. - # The proxy will be set on the request object but IGNORED during execution. - # See ASYNC_PROXY_BUG_REPORT.md for details and implementation plan. if proxy: if isinstance(proxy, dict): # Handle proxies dict like requests library: {'http': 'http://...', 'https': 'http://...'} @@ -294,7 +300,6 @@ cdef class AsyncRequestManager: c_password = password_bytes httpmorph_request_set_proxy(req, proxy_bytes, c_username, c_password) - # TODO: Raise warning or error until C-level proxy support is implemented # Add headers if headers: @@ -336,97 +341,147 @@ cdef class AsyncRequestManager: finally: httpmorph_request_destroy(req) + def _step_request_sync(self, uint64_t request_id): + """Synchronous step for use in thread pool (handles blocking DNS)""" + cdef async_request_t *req = NULL + cdef int status + cdef int fd + + req = async_manager_get_request(self._manager, request_id) + if req is NULL: + return (ASYNC_STATUS_ERROR, -1, "Request not found") + + try: + with nogil: + status = async_request_step(req) + fd = async_request_get_fd(req) + return (status, fd, None) + finally: + async_request_unref(req) + async def _poll_request(self, uint64_t request_id, future): - """Poll a request until it completes""" - # Declare all cdef variables at the top + """Poll a request until it completes using true async I/O. + + Uses asyncio's add_reader/add_writer to wait for socket readiness + instead of busy-polling. DNS and initial connection steps run in + a thread pool since they can block. + """ cdef async_request_t *req = NULL + cdef async_request_manager_t *mgr = self._manager cdef int status cdef int fd cdef async_request_state_t state - cdef bint is_timeout - while not future.done(): - # Get request (adds a reference) - req = async_manager_get_request(self._manager, request_id) + loop = asyncio.get_running_loop() + while not future.done(): + # Get current request state + req = async_manager_get_request(mgr, request_id) if req is NULL: future.set_exception(RuntimeError("Request not found")) return try: - # Check for timeout - is_timeout = async_request_is_timeout(req) + state = async_request_get_state(req) + fd = async_request_get_fd(req) + finally: + async_request_unref(req) - if is_timeout: - async_request_unref(req) - future.set_exception(TimeoutError("Request timed out")) + # For DNS_LOOKUP and initial CONNECTING states, use thread pool + # since these can block (getaddrinfo is blocking) + if state in (ASYNC_STATE_INIT, ASYNC_STATE_DNS_LOOKUP, ASYNC_STATE_CONNECTING) and fd < 0: + result = await loop.run_in_executor( + _dns_executor, + self._step_request_sync, + request_id + ) + status, fd, error = result + if error: + future.set_exception(RuntimeError(error)) return + else: + # For states with a valid fd, use non-blocking step + req = async_manager_get_request(mgr, request_id) + if req is not NULL: + with nogil: + status = async_request_step(req) + fd = async_request_get_fd(req) + async_request_unref(req) + else: + status = ASYNC_STATUS_ERROR + + # Handle status + if status == ASYNC_STATUS_COMPLETE: + req = async_manager_get_request(mgr, request_id) + if req is not NULL: + try: + response = self._extract_response(req) + future.set_result(response) + finally: + async_request_unref(req) + # Remove from manager AFTER extracting response to avoid race conditions + async_manager_remove_request(mgr, request_id) + else: + future.set_exception(RuntimeError("Request completed but not found")) + return - # Step the state machine (always, even without FD for early states) - status = async_request_step(req) + elif status == ASYNC_STATUS_ERROR: + req = async_manager_get_request(mgr, request_id) + if req is not NULL: + try: + state = async_request_get_state(req) + state_name = async_request_state_name(state).decode('utf-8') if state else "UNKNOWN" + error_msg_ptr = async_request_get_error_message(req) + error_msg = error_msg_ptr.decode('utf-8') if error_msg_ptr is not NULL else "Unknown error" + finally: + async_request_unref(req) + else: + state_name = "UNKNOWN" + error_msg = "Unknown error" + # Remove from manager AFTER extracting error info to avoid race conditions + async_manager_remove_request(mgr, request_id) + future.set_exception(RuntimeError(f"Request failed in state {state_name}: {error_msg}")) + return - if status == ASYNC_STATUS_COMPLETE: - # Request completed successfully - response = self._extract_response(req) - async_request_unref(req) - future.set_result(response) - return + elif status == ASYNC_STATUS_NEED_READ and fd >= 0: + # Wait for socket to become readable using asyncio + await self._wait_for_fd(loop, fd, read=True) - elif status == ASYNC_STATUS_ERROR: - # Request failed - state = async_request_get_state(req) - state_name = async_request_state_name(state).decode('utf-8') if state else "UNKNOWN" + elif status == ASYNC_STATUS_NEED_WRITE and fd >= 0: + # Wait for socket to become writable using asyncio + await self._wait_for_fd(loop, fd, read=False) - # Get error message from request - error_msg_ptr = async_request_get_error_message(req) - error_msg = error_msg_ptr.decode('utf-8') if error_msg_ptr is not NULL else "Unknown error" + else: + # ASYNC_STATUS_IN_PROGRESS or no fd yet - yield and continue + await asyncio.sleep(0) - async_request_unref(req) - future.set_exception(RuntimeError(f"Request failed in state {state_name}: {error_msg}")) - return + async def _wait_for_fd(self, loop, int fd, bint read): + """Wait for a file descriptor to become ready using asyncio event loop.""" + waiter = loop.create_future() - elif status == ASYNC_STATUS_NEED_READ or status == ASYNC_STATUS_NEED_WRITE: - # Get file descriptor for event loop integration - fd = async_request_get_fd(req) - - if fd >= 0 and self._loop: - try: - # Try to use efficient event loop integration (Unix: epoll/kqueue) - if status == ASYNC_STATUS_NEED_READ: - # Wait for socket to be readable - read_event = asyncio.Event() - self._loop.add_reader(fd, read_event.set) - try: - await asyncio.wait_for(read_event.wait(), timeout=0.1) - except asyncio.TimeoutError: - pass # Continue polling - finally: - self._loop.remove_reader(fd) - else: # ASYNC_STATUS_NEED_WRITE - # Wait for socket to be writable - write_event = asyncio.Event() - self._loop.add_writer(fd, write_event.set) - try: - await asyncio.wait_for(write_event.wait(), timeout=0.1) - except asyncio.TimeoutError: - pass # Continue polling - finally: - self._loop.remove_writer(fd) - except NotImplementedError: - # Windows ProactorEventLoop doesn't support add_reader/add_writer - # Fall back to short sleep - select() doesn't work reliably with raw FDs on Windows - await asyncio.sleep(0.001) - else: - # FD not ready yet, short sleep - await asyncio.sleep(0.001) + def callback(): + if not waiter.done(): + waiter.set_result(None) - else: - # In progress, continue with short delay - await asyncio.sleep(0.001) + try: + if read: + loop.add_reader(fd, callback) + else: + loop.add_writer(fd, callback) - finally: - # Always unref the request we got at the start of the loop - async_request_unref(req) + # Wait with timeout to prevent infinite hangs + try: + await asyncio.wait_for(waiter, timeout=30.0) + except asyncio.TimeoutError: + pass # Continue anyway, the C code will handle timeout + finally: + try: + if read: + loop.remove_reader(fd) + else: + loop.remove_writer(fd) + except (ValueError, OSError): + pass # fd might be closed already cdef dict _extract_response(self, async_request_t *req): """Extract response from completed request""" @@ -444,10 +499,14 @@ cdef class AsyncRequestManager: } # Build response dict + body_bytes = b'' + if resp.body and resp.body_len > 0: + body_bytes = bytes(resp.body[:resp.body_len]) + result = { 'status_code': resp.status_code, 'headers': {}, - 'body': bytes(resp.body[:resp.body_len]) if resp.body else b'', + 'body': body_bytes, 'http_version': resp.http_version, 'connect_time_us': resp.connect_time_us, 'tls_time_us': resp.tls_time_us, diff --git a/src/bindings/_httpmorph.pyx b/src/bindings/_httpmorph.pyx index 5f918f9..b93547d 100644 --- a/src/bindings/_httpmorph.pyx +++ b/src/bindings/_httpmorph.pyx @@ -131,6 +131,12 @@ cdef extern from "../include/httpmorph.h": httpmorph_response* httpmorph_session_request(httpmorph_session_t *session, const httpmorph_request_t *request) nogil size_t httpmorph_session_cookie_count(httpmorph_session_t *session) nogil + # Connection Pool API + int httpmorph_pool_prewarm(httpmorph_client_t *client, const char *host, int port, bint use_tls, int count) nogil + void httpmorph_pool_configure(httpmorph_pool_t *pool, int idle_timeout_seconds, int max_connections_per_host, int max_total_connections) nogil + void httpmorph_pool_stats(httpmorph_pool_t *pool, int *total_connections, int *active_connections) nogil + void httpmorph_pool_cleanup_idle(httpmorph_pool_t *pool) nogil + # Async I/O API int httpmorph_pool_get_connection_fd(httpmorph_pool_t *pool, const char *host, uint16_t port) nogil @@ -407,6 +413,91 @@ cdef class Client: cdef int fd = httpmorph_pool_get_connection_fd(pool, host_bytes, port) return fd + def prewarm(self, str host, int port=0, bint use_tls=True, int count=1): + """Pre-warm connections to a host for faster subsequent requests + + Establishes TCP/TLS connections proactively to eliminate connection + setup latency from subsequent requests. + + Args: + host: Target hostname to pre-warm connections to + port: Target port (0 = default: 443 for TLS, 80 for HTTP) + use_tls: Whether to establish TLS connections (default: True) + count: Number of connections to pre-warm (default: 1) + + Returns: + int: Number of connections successfully pre-warmed + + Example: + >>> client = Client() + >>> client.prewarm('api.example.com', count=3) + 3 + >>> # Subsequent requests will reuse pre-warmed connections + >>> client.get('https://api.example.com/endpoint') + """ + cdef bytes host_bytes = host.encode('utf-8') + cdef const char* c_host = host_bytes + cdef int result + with nogil: + result = httpmorph_pool_prewarm(self._client, c_host, port, use_tls, count) + return result + + def configure_pool(self, int idle_timeout_seconds=0, int max_connections_per_host=0, int max_total_connections=0): + """Configure connection pool settings + + Args: + idle_timeout_seconds: Idle timeout before closing connections (default: 30s, 0 = keep default) + max_connections_per_host: Max connections per host (default: 6, 0 = keep default) + max_total_connections: Max total connections (default: 100, 0 = keep default) + + Example: + >>> client = Client() + >>> # Keep connections alive for 60 seconds + >>> client.configure_pool(idle_timeout_seconds=60) + >>> # Allow more concurrent connections + >>> client.configure_pool(max_connections_per_host=10, max_total_connections=200) + """ + cdef httpmorph_pool_t* pool = httpmorph_client_get_pool(self._client) + if pool is not NULL: + with nogil: + httpmorph_pool_configure(pool, idle_timeout_seconds, max_connections_per_host, max_total_connections) + + def pool_stats(self): + """Get connection pool statistics + + Returns: + dict: Dictionary with 'total_connections' and 'active_connections' + + Example: + >>> client = Client() + >>> client.get('https://example.com') + >>> stats = client.pool_stats() + >>> print(f"Total: {stats['total_connections']}, Active: {stats['active_connections']}") + """ + cdef httpmorph_pool_t* pool = httpmorph_client_get_pool(self._client) + cdef int total = 0 + cdef int active = 0 + if pool is not NULL: + with nogil: + httpmorph_pool_stats(pool, &total, &active) + return {'total_connections': total, 'active_connections': active} + + def cleanup_idle_connections(self): + """Clean up idle connections in the pool + + Removes connections that have been idle longer than the idle timeout. + Call this periodically for long-running applications to free resources. + + Example: + >>> client = Client() + >>> # ... use client for a while ... + >>> client.cleanup_idle_connections() + """ + cdef httpmorph_pool_t* pool = httpmorph_client_get_pool(self._client) + if pool is not NULL: + with nogil: + httpmorph_pool_cleanup_idle(pool) + cdef class Session: """HTTP session with persistent fingerprint""" diff --git a/src/core/async_request.c b/src/core/async_request.c index b108010..6e8fd2c 100644 --- a/src/core/async_request.c +++ b/src/core/async_request.c @@ -10,8 +10,12 @@ #include "async_request.h" #include "io_engine.h" +#include "connection_pool.h" #include "internal/proxy.h" #include "internal/util.h" +#include "internal/network.h" +#include "internal/tls.h" +#include "../tls/browser_profiles.h" #include #include #include @@ -21,9 +25,50 @@ #include #include +/* HTTP/2 support */ +#ifdef HAVE_NGHTTP2 +#include +#include "internal/request.h" +#include "internal/response.h" + +/** + * HTTP/2 stream data structure for async requests + * (defined early for use in destroy function) + */ +typedef struct { + void *req; /* Back-reference to async request (void* to avoid circular dep) */ + httpmorph_response_t *response; + uint8_t *data_buf; /* Response body buffer */ + size_t data_capacity; + size_t data_len; + bool headers_complete; + bool stream_closed; + SSL *ssl; + + /* Request body fields */ + const uint8_t *req_body; + size_t req_body_len; + size_t req_body_sent; + + /* Buffered I/O for non-blocking operation */ + uint8_t *send_buf; /* Buffer for nghttp2 output */ + size_t send_capacity; + size_t send_len; /* Data in send_buf waiting to be sent */ + size_t send_pos; /* Position of data already sent */ + + /* Receive buffer */ + uint8_t *recv_buf; /* Buffer for SSL_read data */ + size_t recv_capacity; + size_t recv_len; /* Data in recv_buf */ + + /* Track SSL errors for async handling */ + int last_ssl_want; /* SSL_ERROR_WANT_READ or SSL_ERROR_WANT_WRITE */ +} async_http2_stream_data_t; +#endif + /* Debug output control */ #ifdef HTTPMORPH_DEBUG - #define DEBUG_PRINT(...) printf(__VA_ARGS__) + #define DEBUG_PRINT(...) do { printf(__VA_ARGS__); fflush(stdout); } while(0) #else #define DEBUG_PRINT(...) ((void)0) #endif @@ -142,6 +187,9 @@ const char* async_request_state_name(async_request_state_t state) { case ASYNC_STATE_SENDING_REQUEST: return "SENDING_REQUEST"; case ASYNC_STATE_RECEIVING_HEADERS: return "RECEIVING_HEADERS"; case ASYNC_STATE_RECEIVING_BODY: return "RECEIVING_BODY"; + case ASYNC_STATE_HTTP2_INIT: return "HTTP2_INIT"; + case ASYNC_STATE_HTTP2_SEND: return "HTTP2_SEND"; + case ASYNC_STATE_HTTP2_RECV: return "HTTP2_RECV"; case ASYNC_STATE_COMPLETE: return "COMPLETE"; case ASYNC_STATE_ERROR: return "ERROR"; default: return "UNKNOWN"; @@ -324,6 +372,28 @@ async_request_t* async_request_create( return NULL; } + /* Configure per-connection browser profile settings for Chrome 143 fingerprint. + * SSL_CTX sets cipher suites and base extensions, but some require per-SSL config: + * - ECH grease (encrypted_client_hello extension) + * - OCSP stapling (status_request extension) + * - ALPS (application_settings extension) */ + const browser_profile_t *profile = &PROFILE_CHROME_143; + bool has_ech = false, has_alps = false, has_ocsp = false; + for (int i = 0; i < profile->extension_count; i++) { + uint16_t ext = profile->extensions[i]; + if (ext == 65037) has_ech = true; /* encrypted_client_hello */ + if (ext == 17613 || ext == 17513) has_alps = true; /* application_settings */ + if (ext == 5) has_ocsp = true; /* status_request */ + } + + /* Enable ECH grease for encrypted_client_hello extension */ + SSL_set_enable_ech_grease(req->ssl, has_ech ? 1 : 0); + + /* Enable OCSP stapling for status_request extension */ + if (has_ocsp) { + SSL_enable_ocsp_stapling(req->ssl); + } + /* Set SSL verification mode based on request setting */ if (request->verify_ssl) { SSL_set_verify(req->ssl, SSL_VERIFY_PEER, NULL); @@ -336,11 +406,156 @@ async_request_t* async_request_create( SSL_set_tlsext_host_name(req->ssl, request->host); } - /* Set SSL to non-blocking mode (will be done when socket is created) */ - DEBUG_PRINT("[async_request] Created SSL object for HTTPS (id=%lu)\n", + /* Set ALPN and ALPS for HTTP/2 if enabled */ + if (profile->alpn_protocol_count > 0) { + unsigned char alpn_list[256]; + unsigned char *alpn_p = alpn_list; + for (int i = 0; i < profile->alpn_protocol_count; i++) { + size_t len = strlen(profile->alpn_protocols[i]); + *alpn_p++ = (unsigned char)len; + memcpy(alpn_p, profile->alpn_protocols[i], len); + alpn_p += len; + } + if (alpn_p > alpn_list) { + SSL_set_alpn_protos(req->ssl, alpn_list, alpn_p - alpn_list); + /* Enable ALPS (application_settings) for h2 */ + if (has_alps) { + SSL_add_application_settings(req->ssl, + (const uint8_t *)"h2", 2, + (const uint8_t *)"", 0); + } + } + } + + DEBUG_PRINT("[async_request] Created SSL object with Chrome 143 profile (id=%lu)\n", (unsigned long)req->id); } + /* Allocate response object using proper constructor (initializes headers array) */ + req->response = httpmorph_response_create(NULL); /* No buffer pool for async requests */ + if (!req->response) { + /* Cleanup and return NULL */ + if (req->ssl) { + SSL_free(req->ssl); + } + free(req->send_buf); + free(req->recv_buf); + free(req); + return NULL; + } + req->response->http_version = HTTPMORPH_VERSION_1_1; /* Default, may be changed to HTTP/2 */ + req->response->error = HTTPMORPH_OK; + + return req; +} + +/** + * Create a new async request with connection pool support + */ +async_request_t* async_request_create_pooled( + const httpmorph_request_t *request, + io_engine_t *io_engine, + SSL_CTX *ssl_ctx, + httpmorph_pool_t *pool, + uint32_t timeout_ms, + async_request_callback_t callback, + void *user_data) +{ + extern int httpmorph_parse_url(const char *url, char **scheme, char **host, uint16_t *port, char **path); + + /* Parse URL to get host/port for pool lookup if not already set */ + if (!request->host && request->url) { + char *scheme = NULL, *host = NULL, *path = NULL; + uint16_t port = 0; + + if (httpmorph_parse_url(request->url, &scheme, &host, &port, &path) == 0) { + /* Store parsed values in request structure */ + ((httpmorph_request_t*)request)->host = host; /* Transfer ownership */ + ((httpmorph_request_t*)request)->port = port; + ((httpmorph_request_t*)request)->use_tls = (scheme && strcmp(scheme, "https") == 0); + + /* Free scheme and path as we don't need them */ + free(scheme); + free(path); + } + } + + /* Try to get a pooled connection (supports both HTTP/1.1 and HTTP/2) + * NOTE: Don't use pooled connections when using a proxy - proxy requests + * need to go through the proxy, not reuse direct connections to target host */ + pooled_connection_t *pooled_conn = NULL; + if (pool && request->host && request->port > 0 && + (!request->proxy_url || request->proxy_url[0] == '\0')) { + pooled_conn = pool_get_connection(pool, request->host, request->port); + if (pooled_conn && pool_connection_validate(pooled_conn)) { + DEBUG_PRINT("[async_request] Got pooled connection: host=%s, is_http2=%d, session=%p\n", + request->host, pooled_conn->is_http2, pooled_conn->http2_session); + } else if (pooled_conn) { + /* Connection invalid, destroy it */ + pool_connection_destroy(pooled_conn); + pooled_conn = NULL; + } + } + + /* Create the async request normally */ + async_request_t *req = async_request_create(request, io_engine, ssl_ctx, + timeout_ms, callback, user_data); + if (!req) { + if (pooled_conn) { + pool_connection_destroy(pooled_conn); + } + return NULL; + } + + /* Store pool reference for returning connection later */ + req->pool = pool; + + /* If we got a pooled connection, use it */ + if (pooled_conn) { + req->pooled_conn = pooled_conn; + req->from_pool = true; + + /* Free the SSL object we just created - we'll use the pooled one */ + if (req->ssl) { + SSL_free(req->ssl); + } + + /* Use the existing socket and SSL connection */ + req->sockfd = pooled_conn->sockfd; + req->ssl = pooled_conn->ssl; + req->dns_resolved = true; + req->is_https = (pooled_conn->ssl != NULL); + +#ifdef HAVE_NGHTTP2 + /* Restore HTTP/2 session from pooled connection if available */ + if (pooled_conn->is_http2 && pooled_conn->http2_session) { + req->use_http2 = true; + req->http2_session = pooled_conn->http2_session; + req->http2_stream_data = pooled_conn->http2_stream_data; + req->http2_session_initialized = pooled_conn->preface_sent; + /* Set response HTTP version to HTTP/2 since we're reusing an HTTP/2 connection */ + req->response->http_version = HTTPMORPH_VERSION_2_0; + /* Clear from pool - request now owns the session */ + pooled_conn->http2_session = NULL; + pooled_conn->http2_stream_data = NULL; + /* For HTTP/2, skip to HTTP2_INIT which will submit a new request */ + req->state = ASYNC_STATE_HTTP2_INIT; + DEBUG_PRINT("[async_request] Restored HTTP/2 session from pool: session=%p\n", + req->http2_session); + } else { + /* HTTP/1.1 - skip directly to sending request */ + req->state = ASYNC_STATE_SENDING_REQUEST; + DEBUG_PRINT("[async_request] Reusing pooled HTTP/1.1 connection fd=%d\n", + req->sockfd); + } +#else + /* No HTTP/2 support - skip directly to sending request */ + req->state = ASYNC_STATE_SENDING_REQUEST; + DEBUG_PRINT("[async_request] Reusing pooled connection fd=%d (id=%lu)\n", + req->sockfd, (unsigned long)req->id); +#endif + } + return req; } @@ -374,20 +589,83 @@ void async_request_destroy(async_request_t *req) { } #endif - /* Close socket (but never close stdin/stdout/stderr) */ - if (req->sockfd > 2) { + /* Handle connection pooling with HTTP/2 session preservation */ + if (req->from_pool && req->pooled_conn) { + /* Return connection to pool if request succeeded, otherwise destroy it */ + if (req->state == ASYNC_STATE_COMPLETE && req->pool) { +#ifdef HAVE_NGHTTP2 + /* Store HTTP/2 session back in pooled connection for reuse */ + if (req->use_http2 && req->http2_session) { + req->pooled_conn->http2_session = req->http2_session; + req->pooled_conn->http2_stream_data = req->http2_stream_data; + req->pooled_conn->is_http2 = true; + req->pooled_conn->preface_sent = true; + /* Don't free the session - pool owns it now */ + req->http2_session = NULL; + req->http2_stream_data = NULL; + DEBUG_PRINT("[async_request] Stored HTTP/2 session in pool fd=%d\n", req->sockfd); + } +#endif + /* Update last used time and return to pool */ + req->pooled_conn->last_used = get_time_us() / 1000000; /* Convert to seconds */ + pool_put_connection(req->pool, req->pooled_conn); + DEBUG_PRINT("[async_request] Returned connection to pool fd=%d\n", req->sockfd); + } else { + /* Request failed - destroy the connection */ + pool_connection_destroy(req->pooled_conn); + DEBUG_PRINT("[async_request] Destroyed failed pooled connection fd=%d\n", req->sockfd); + } + /* Don't close socket or free SSL - pooled_conn owns them */ + req->sockfd = -1; + req->ssl = NULL; + req->pooled_conn = NULL; + } else if (req->pool && req->state == ASYNC_STATE_COMPLETE && + req->sockfd > 2 && req->request && req->request->host) { + /* New connection that completed successfully - add to pool */ + DEBUG_PRINT("[async_request] Adding new connection to pool: host=%s, is_http2=%d\n", + req->request->host, req->use_http2); + pooled_connection_t *new_conn = pool_connection_create( + req->request->host, req->request->port, req->sockfd, req->ssl, req->use_http2); + if (new_conn) { +#ifdef HAVE_NGHTTP2 + /* Store HTTP/2 session in new pool entry for reuse */ + if (req->use_http2 && req->http2_session) { + new_conn->http2_session = req->http2_session; + new_conn->http2_stream_data = req->http2_stream_data; + new_conn->preface_sent = true; + /* Don't free the session - pool owns it now */ + req->http2_session = NULL; + req->http2_stream_data = NULL; + DEBUG_PRINT("[async_request] Stored HTTP/2 session in pool: session=%p\n", + new_conn->http2_session); + } +#endif + new_conn->last_used = get_time_us() / 1000000; + pool_put_connection(req->pool, new_conn); + /* Don't close socket or free SSL - pool owns them now */ + req->sockfd = -1; + req->ssl = NULL; + } else { + /* Failed to create pool connection, fall through to normal cleanup */ + goto normal_cleanup; + } + } else { +normal_cleanup: + /* Close socket (but never close stdin/stdout/stderr) */ + if (req->sockfd > 2) { #ifdef _WIN32 - closesocket(req->sockfd); + closesocket(req->sockfd); #else - close(req->sockfd); + close(req->sockfd); #endif - req->sockfd = -1; - } + req->sockfd = -1; + } - /* Clean up SSL */ - if (req->ssl) { - SSL_free(req->ssl); - req->ssl = NULL; + /* Clean up SSL */ + if (req->ssl) { + SSL_free(req->ssl); + req->ssl = NULL; + } } /* Free buffers */ @@ -401,6 +679,26 @@ void async_request_destroy(async_request_t *req) { free(req->target_host); free(req->proxy_recv_buf); +#ifdef HAVE_NGHTTP2 + /* Clean up HTTP/2 resources */ + if (req->http2_session) { + nghttp2_session_del((nghttp2_session *)req->http2_session); + req->http2_session = NULL; + } + if (req->http2_callbacks) { + nghttp2_session_callbacks_del((nghttp2_session_callbacks *)req->http2_callbacks); + req->http2_callbacks = NULL; + } + if (req->http2_stream_data) { + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)req->http2_stream_data; + free(stream_data->data_buf); + free(stream_data->send_buf); + free(stream_data->recv_buf); + free(stream_data); + req->http2_stream_data = NULL; + } +#endif + /* Free response if allocated */ if (req->response) { httpmorph_response_destroy(req->response); @@ -497,9 +795,6 @@ static int step_dns_lookup(async_request_t *req) { return ASYNC_STATUS_IN_PROGRESS; } - /* Perform blocking DNS lookup (for now) */ - /* Note: In production, this should use async DNS (getaddrinfo_a or thread pool) */ - /* If using proxy, resolve proxy hostname instead of target hostname */ const char *hostname; uint16_t port; @@ -521,31 +816,49 @@ static int step_dns_lookup(async_request_t *req) { return ASYNC_STATUS_ERROR; } - /* Setup hints for getaddrinfo */ - struct addrinfo hints; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_UNSPEC; /* Allow IPv4 or IPv6 */ - hints.ai_socktype = SOCK_STREAM; /* TCP socket */ - hints.ai_flags = AI_ADDRCONFIG; /* Only return addresses we can use */ + struct addrinfo *result = NULL; + bool from_cache = false; - /* Convert port to string */ - char port_str[16]; - snprintf(port_str, sizeof(port_str), "%u", port); + /* Try DNS cache first (shared with sync path) */ + result = dns_cache_lookup(hostname, port); + if (result) { + from_cache = true; + DEBUG_PRINT("[async_request] DNS cache hit for %s:%u (id=%lu)\n", + hostname, port, (unsigned long)req->id); + } else { + /* Cache miss - perform blocking DNS lookup */ + /* Note: In production, this should use async DNS (getaddrinfo_a or thread pool) */ + DEBUG_PRINT("[async_request] DNS cache miss for %s:%u, performing lookup (id=%lu)\n", + hostname, port, (unsigned long)req->id); - /* Perform DNS lookup */ - struct addrinfo *result = NULL; - int ret = getaddrinfo(hostname, port_str, &hints, &result); + /* Setup hints for getaddrinfo */ + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; /* Allow IPv4 or IPv6 */ + hints.ai_socktype = SOCK_STREAM; /* TCP socket */ + hints.ai_flags = AI_ADDRCONFIG; /* Only return addresses we can use */ - if (ret != 0) { - char error_buf[256]; - snprintf(error_buf, sizeof(error_buf), "DNS lookup failed: %s", gai_strerror(ret)); - async_request_set_error(req, ret, error_buf); - return ASYNC_STATUS_ERROR; - } + /* Convert port to string */ + char port_str[16]; + snprintf(port_str, sizeof(port_str), "%u", port); - if (!result) { - async_request_set_error(req, -1, "DNS lookup returned no results"); - return ASYNC_STATUS_ERROR; + /* Perform DNS lookup */ + int ret = getaddrinfo(hostname, port_str, &hints, &result); + + if (ret != 0) { + char error_buf[256]; + snprintf(error_buf, sizeof(error_buf), "DNS lookup failed: %s", gai_strerror(ret)); + async_request_set_error(req, ret, error_buf); + return ASYNC_STATUS_ERROR; + } + + if (!result) { + async_request_set_error(req, -1, "DNS lookup returned no results"); + return ASYNC_STATUS_ERROR; + } + + /* Add to DNS cache for future use */ + dns_cache_add(hostname, port, result); } /* Store the first result */ @@ -553,11 +866,15 @@ static int step_dns_lookup(async_request_t *req) { req->addr_len = result->ai_addrlen; req->dns_resolved = true; - DEBUG_PRINT("[async_request] DNS resolved for %s:%u (id=%lu)\n", - hostname, port, (unsigned long)req->id); + DEBUG_PRINT("[async_request] DNS resolved for %s:%u (from_cache=%d) (id=%lu)\n", + hostname, port, from_cache, (unsigned long)req->id); /* Free the result */ - freeaddrinfo(result); + if (from_cache) { + dns_cache_free_result(result); + } else { + freeaddrinfo(result); + } /* Move to connecting state */ req->state = ASYNC_STATE_CONNECTING; @@ -917,6 +1234,9 @@ static int step_connecting(async_request_t *req) { /** * State: TLS handshake + * + * Performs non-blocking TLS handshake. Each call attempts one handshake step, + * returning NEED_READ or NEED_WRITE to allow the event loop to wait for I/O. */ static int step_tls_handshake(async_request_t *req) { /* SSL object should exist for HTTPS */ @@ -925,19 +1245,34 @@ static int step_tls_handshake(async_request_t *req) { return ASYNC_STATUS_ERROR; } + int current_fd = SSL_get_fd(req->ssl); + /* Bind SSL to socket if not already done */ - if (SSL_get_fd(req->ssl) != req->sockfd) { + if (current_fd != req->sockfd) { if (SSL_set_fd(req->ssl, req->sockfd) != 1) { async_request_set_error(req, -1, "Failed to bind SSL to socket"); return ASYNC_STATUS_ERROR; } /* Set connect state (client mode) */ SSL_set_connect_state(req->ssl); + + /* Try to resume a cached TLS session for faster handshake */ + const char *hostname = req->request->host; + uint16_t port = req->request->port; + if (hostname && port > 0) { + SSL_SESSION *cached_session = global_session_cache_get(hostname, port); + if (cached_session) { + SSL_set_session(req->ssl, cached_session); + DEBUG_PRINT("[async_request] Using cached TLS session for %s:%u (id=%lu)\n", + hostname, port, (unsigned long)req->id); + } + } + DEBUG_PRINT("[async_request] SSL bound to socket fd=%d (id=%lu)\n", req->sockfd, (unsigned long)req->id); } - /* Perform non-blocking handshake */ + /* Perform non-blocking handshake step */ int ret = SSL_do_handshake(req->ssl); if (ret == 1) { @@ -945,9 +1280,40 @@ static int step_tls_handshake(async_request_t *req) { DEBUG_PRINT("[async_request] TLS handshake complete (id=%lu)\n", (unsigned long)req->id); + /* Cache the session for future resumption */ + const char *hostname = req->request->host; + uint16_t port = req->request->port; + if (hostname && port > 0) { + SSL_SESSION *session = SSL_get1_session(req->ssl); + if (session) { + global_session_cache_put(hostname, port, session); + SSL_SESSION_free(session); /* Release our reference */ + DEBUG_PRINT("[async_request] Cached TLS session for %s:%u (id=%lu)\n", + hostname, port, (unsigned long)req->id); + } + } + +#ifdef HAVE_NGHTTP2 + /* Check ALPN negotiated protocol */ + const unsigned char *alpn_data = NULL; + unsigned int alpn_len = 0; + SSL_get0_alpn_selected(req->ssl, &alpn_data, &alpn_len); + + if (alpn_data && alpn_len == 2 && memcmp(alpn_data, "h2", 2) == 0) { + /* HTTP/2 negotiated - transition to HTTP/2 state machine */ + req->use_http2 = true; + req->response->http_version = HTTPMORPH_VERSION_2_0; + DEBUG_PRINT("[async_request] HTTP/2 negotiated via ALPN (id=%lu)\n", + (unsigned long)req->id); + req->state = ASYNC_STATE_HTTP2_INIT; + /* Return NEED_WRITE since HTTP/2 init will send data */ + return ASYNC_STATUS_NEED_WRITE; + } +#endif + req->state = ASYNC_STATE_SENDING_REQUEST; - /* Continue immediately to sending */ - return ASYNC_STATUS_IN_PROGRESS; + /* Continue to sending - yield first to allow event loop to process */ + return ASYNC_STATUS_NEED_WRITE; } /* Check error */ @@ -1045,6 +1411,13 @@ static int build_http_request(async_request_t *req) { return -1; } + /* Add Connection: keep-alive for connection reuse */ + written += snprintf(buf + written, SEND_BUFFER_SIZE - written, + "Connection: keep-alive\r\n"); + if (written >= (int)SEND_BUFFER_SIZE) { + return -1; + } + /* Add Proxy-Authorization header for HTTP proxy (not HTTPS/CONNECT) */ if (use_absolute_uri && (req->proxy_username || req->proxy_password)) { const char *username = req->proxy_username ? req->proxy_username : ""; @@ -1712,16 +2085,13 @@ static int step_receiving_body(async_request_t *req) { DEBUG_PRINT("[async_request] No body to receive (id=%lu)\n", (unsigned long)req->id); - /* Create response object for empty body */ - if (!req->response) { - req->response = calloc(1, sizeof(httpmorph_response_t)); - if (req->response) { - req->response->body = NULL; - req->response->body_len = 0; - req->response->status_code = 200; /* TODO: Parse from headers */ - req->response->http_version = HTTPMORPH_VERSION_1_1; - req->response->error = HTTPMORPH_OK; - } + /* Initialize response object for empty body */ + if (req->response) { + req->response->body = NULL; + req->response->body_len = 0; + req->response->status_code = 200; /* TODO: Parse from headers */ + /* http_version already set during creation or ALPN negotiation */ + req->response->error = HTTPMORPH_OK; } req->state = ASYNC_STATE_COMPLETE; @@ -1733,24 +2103,21 @@ static int step_receiving_body(async_request_t *req) { DEBUG_PRINT("[async_request] Body already complete (%zu bytes) (id=%lu)\n", req->body_received, (unsigned long)req->id); - /* Create response object */ - if (!req->response) { - req->response = calloc(1, sizeof(httpmorph_response_t)); - if (req->response) { - /* Extract body from recv_buf (starts after headers) */ - size_t body_start = req->headers_end_pos; - if (req->content_length > 0) { - req->response->body = malloc(req->content_length); - if (req->response->body) { - memcpy(req->response->body, req->recv_buf + body_start, req->content_length); - req->response->body_len = req->content_length; - req->response->_body_actual_size = req->content_length; /* Track allocated size */ - } + /* Populate response object with body data */ + if (req->response) { + /* Extract body from recv_buf (starts after headers) */ + size_t body_start = req->headers_end_pos; + if (req->content_length > 0) { + req->response->body = malloc(req->content_length); + if (req->response->body) { + memcpy(req->response->body, req->recv_buf + body_start, req->content_length); + req->response->body_len = req->content_length; + req->response->_body_actual_size = req->content_length; /* Track allocated size */ } - req->response->status_code = 200; // TODO: Parse from headers - req->response->http_version = HTTPMORPH_VERSION_1_1; - req->response->error = HTTPMORPH_OK; } + req->response->status_code = 200; // TODO: Parse from headers + /* http_version already set during creation or ALPN negotiation */ + req->response->error = HTTPMORPH_OK; } req->state = ASYNC_STATE_COMPLETE; @@ -1941,24 +2308,21 @@ static int step_receiving_body(async_request_t *req) { DEBUG_PRINT("[async_request] Body received (%zu bytes) (id=%lu)\n", req->body_received, (unsigned long)req->id); - /* Create response object */ - if (!req->response) { - req->response = calloc(1, sizeof(httpmorph_response_t)); - if (req->response) { - /* Extract body from recv_buf (starts after headers) */ - size_t body_start = req->headers_end_pos; - if (req->content_length > 0) { - req->response->body = malloc(req->content_length); - if (req->response->body) { - memcpy(req->response->body, req->recv_buf + body_start, req->content_length); - req->response->body_len = req->content_length; - req->response->_body_actual_size = req->content_length; /* Track allocated size */ - } + /* Populate response object with body data */ + if (req->response) { + /* Extract body from recv_buf (starts after headers) */ + size_t body_start = req->headers_end_pos; + if (req->content_length > 0) { + req->response->body = malloc(req->content_length); + if (req->response->body) { + memcpy(req->response->body, req->recv_buf + body_start, req->content_length); + req->response->body_len = req->content_length; + req->response->_body_actual_size = req->content_length; /* Track allocated size */ } - req->response->status_code = 200; // TODO: Parse from headers - req->response->http_version = HTTPMORPH_VERSION_1_1; - req->response->error = HTTPMORPH_OK; } + req->response->status_code = 200; // TODO: Parse from headers + /* http_version already set during creation or ALPN negotiation */ + req->response->error = HTTPMORPH_OK; } req->state = ASYNC_STATE_COMPLETE; @@ -2109,6 +2473,577 @@ static int step_proxy_connect(async_request_t *req) { return ASYNC_STATUS_IN_PROGRESS; } +#ifdef HAVE_NGHTTP2 + +/** + * HTTP/2 send callback for async - buffers data instead of direct SSL_write + * This allows proper non-blocking I/O handling + */ +static ssize_t async_http2_send_callback(nghttp2_session *session, const uint8_t *data, + size_t length, int flags, void *user_data) { + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)user_data; + if (!stream_data) { + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + + /* Expand send buffer if needed */ + size_t needed = stream_data->send_len + length; + if (needed > stream_data->send_capacity) { + size_t new_capacity = needed * 2; + if (new_capacity < 32768) new_capacity = 32768; + uint8_t *new_buf = realloc(stream_data->send_buf, new_capacity); + if (!new_buf) { + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + stream_data->send_buf = new_buf; + stream_data->send_capacity = new_capacity; + } + + /* Copy data to send buffer */ + memcpy(stream_data->send_buf + stream_data->send_len, data, length); + stream_data->send_len += length; + + return (ssize_t)length; +} + +/** + * HTTP/2 recv callback for async - returns WOULDBLOCK since we use mem_recv + * We don't use this callback directly; instead we do SSL_read externally + * and feed data to nghttp2 via nghttp2_session_mem_recv + */ +static ssize_t async_http2_recv_callback(nghttp2_session *session, uint8_t *buf, + size_t length, int flags, void *user_data) { + /* Always return WOULDBLOCK - we handle recv externally */ + return NGHTTP2_ERR_WOULDBLOCK; +} + +/** + * HTTP/2 data provider read callback for request body + */ +static ssize_t async_http2_data_source_read_callback(nghttp2_session *session, int32_t stream_id, + uint8_t *buf, size_t length, uint32_t *data_flags, + nghttp2_data_source *source, void *user_data) { + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)user_data; + + size_t remaining = stream_data->req_body_len - stream_data->req_body_sent; + size_t to_send = remaining < length ? remaining : length; + + if (to_send > 0) { + memcpy(buf, stream_data->req_body + stream_data->req_body_sent, to_send); + stream_data->req_body_sent += to_send; + } + + if (stream_data->req_body_sent >= stream_data->req_body_len) { + *data_flags |= NGHTTP2_DATA_FLAG_EOF; + } + + return to_send; +} + +/** + * HTTP/2 header callback for async + */ +static int async_http2_on_header_callback(nghttp2_session *session, + const nghttp2_frame *frame, + const uint8_t *name, size_t namelen, + const uint8_t *value, size_t valuelen, + uint8_t flags, void *user_data) { + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)nghttp2_session_get_stream_user_data(session, frame->hd.stream_id); + if (!stream_data) { + stream_data = (async_http2_stream_data_t *)user_data; + } + if (!stream_data) { + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + + if (frame->hd.type != NGHTTP2_HEADERS || frame->headers.cat != NGHTTP2_HCAT_RESPONSE) { + return 0; + } + + /* Handle :status pseudo-header */ + if (namelen == 7 && memcmp(name, ":status", 7) == 0) { + char status_str[4] = {0}; + size_t copy_len = valuelen > 3 ? 3 : valuelen; + memcpy(status_str, value, copy_len); + stream_data->response->status_code = atoi(status_str); + return 0; + } + + /* Add regular header */ + httpmorph_response_add_header_internal(stream_data->response, (const char *)name, + namelen, (const char *)value, valuelen); + return 0; +} + +/** + * HTTP/2 data chunk recv callback for async + */ +static int async_http2_on_data_chunk_recv_callback(nghttp2_session *session, uint8_t flags, + int32_t stream_id, const uint8_t *data, + size_t len, void *user_data) { + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)nghttp2_session_get_stream_user_data(session, stream_id); + if (!stream_data) { + stream_data = (async_http2_stream_data_t *)user_data; + } + if (!stream_data) { + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + + /* Expand buffer if needed */ + if (stream_data->data_len + len > stream_data->data_capacity) { + size_t sum = stream_data->data_len + len; + if (sum > SIZE_MAX / 2) { + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + size_t new_capacity = sum * 2; + uint8_t *new_buf = realloc(stream_data->data_buf, new_capacity); + if (!new_buf) return NGHTTP2_ERR_CALLBACK_FAILURE; + stream_data->data_buf = new_buf; + stream_data->data_capacity = new_capacity; + } + + memcpy(stream_data->data_buf + stream_data->data_len, data, len); + stream_data->data_len += len; + return 0; +} + +/** + * HTTP/2 frame recv callback for async + */ +static int async_http2_on_frame_recv_callback(nghttp2_session *session, + const nghttp2_frame *frame, void *user_data) { + async_http2_stream_data_t *stream_data = NULL; + if (frame->hd.stream_id > 0) { + stream_data = (async_http2_stream_data_t *)nghttp2_session_get_stream_user_data(session, frame->hd.stream_id); + } + if (!stream_data) { + stream_data = (async_http2_stream_data_t *)user_data; + } + if (!stream_data) { + return 0; + } + + if (frame->hd.type == NGHTTP2_HEADERS && + frame->headers.cat == NGHTTP2_HCAT_RESPONSE) { + stream_data->headers_complete = true; + } + + /* Check if stream is closed */ + if ((frame->hd.type == NGHTTP2_HEADERS || frame->hd.type == NGHTTP2_DATA) && + (frame->hd.flags & NGHTTP2_FLAG_END_STREAM) && + frame->hd.stream_id > 0) { + stream_data->stream_closed = true; + /* Update async request state */ + if (stream_data->req) { + ((async_request_t *)stream_data->req)->http2_stream_closed = true; + } + } + return 0; +} + +/** + * State: Initialize HTTP/2 session + * Handles both new sessions and reused sessions from the connection pool + */ +static int step_http2_init(async_request_t *req) { + DEBUG_PRINT("[async_request] HTTP/2 init (id=%lu, reused=%d)\n", + (unsigned long)req->id, req->http2_session_initialized); + + nghttp2_session *session = (nghttp2_session *)req->http2_session; + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)req->http2_stream_data; + bool is_reused_session = (session != NULL && req->http2_session_initialized); + + /* For reused sessions, we need to prepare new stream data for this request */ + if (is_reused_session) { + /* Reset/reallocate stream data for the new request */ + if (stream_data) { + /* Reset existing stream data for reuse */ + stream_data->req = req; + stream_data->response = req->response; + stream_data->ssl = req->ssl; + stream_data->data_len = 0; + stream_data->send_len = 0; + stream_data->send_pos = 0; + stream_data->recv_len = 0; + stream_data->stream_closed = false; + stream_data->headers_complete = false; + stream_data->req_body = (const uint8_t *)req->request->body; + stream_data->req_body_len = req->request->body_len; + stream_data->req_body_sent = 0; + stream_data->last_ssl_want = 0; + + /* Update nghttp2 session's user data to point to the reset stream_data */ + nghttp2_session_set_user_data(session, stream_data); + } else { + /* Need to create new stream data for reused session */ + goto create_stream_data; + } + } else { +create_stream_data: + /* Create new stream data structure */ + stream_data = calloc(1, sizeof(async_http2_stream_data_t)); + if (!stream_data) { + async_request_set_error(req, HTTPMORPH_ERROR_MEMORY, "Failed to allocate HTTP/2 stream data"); + return ASYNC_STATUS_ERROR; + } + + stream_data->req = req; + stream_data->response = req->response; + stream_data->ssl = req->ssl; + + /* Allocate response body buffer */ + stream_data->data_capacity = 16384; + stream_data->data_buf = malloc(stream_data->data_capacity); + if (!stream_data->data_buf) { + free(stream_data); + async_request_set_error(req, HTTPMORPH_ERROR_MEMORY, "Failed to allocate HTTP/2 buffer"); + return ASYNC_STATUS_ERROR; + } + + /* Allocate send buffer for nghttp2 output */ + stream_data->send_capacity = 32768; + stream_data->send_buf = malloc(stream_data->send_capacity); + if (!stream_data->send_buf) { + free(stream_data->data_buf); + free(stream_data); + async_request_set_error(req, HTTPMORPH_ERROR_MEMORY, "Failed to allocate HTTP/2 send buffer"); + return ASYNC_STATUS_ERROR; + } + stream_data->send_len = 0; + stream_data->send_pos = 0; + + /* Allocate receive buffer for SSL_read data */ + stream_data->recv_capacity = 16384; + stream_data->recv_buf = malloc(stream_data->recv_capacity); + if (!stream_data->recv_buf) { + free(stream_data->send_buf); + free(stream_data->data_buf); + free(stream_data); + async_request_set_error(req, HTTPMORPH_ERROR_MEMORY, "Failed to allocate HTTP/2 recv buffer"); + return ASYNC_STATUS_ERROR; + } + stream_data->recv_len = 0; + + /* Set up request body if present */ + stream_data->req_body = (const uint8_t *)req->request->body; + stream_data->req_body_len = req->request->body_len; + stream_data->req_body_sent = 0; + + req->http2_stream_data = stream_data; + } + + /* For new sessions, create the nghttp2 session and send preface */ + if (!is_reused_session) { + /* Create nghttp2 callbacks */ + nghttp2_session_callbacks *callbacks = NULL; + int cb_rv = nghttp2_session_callbacks_new(&callbacks); + + if (cb_rv != 0 || !callbacks) { + free(stream_data->recv_buf); + free(stream_data->send_buf); + free(stream_data->data_buf); + free(stream_data); + req->http2_stream_data = NULL; + async_request_set_error(req, -1, "Failed to create nghttp2 callbacks"); + return ASYNC_STATUS_ERROR; + } + + nghttp2_session_callbacks_set_send_callback(callbacks, async_http2_send_callback); + nghttp2_session_callbacks_set_recv_callback(callbacks, async_http2_recv_callback); + nghttp2_session_callbacks_set_on_header_callback(callbacks, async_http2_on_header_callback); + nghttp2_session_callbacks_set_on_data_chunk_recv_callback(callbacks, async_http2_on_data_chunk_recv_callback); + nghttp2_session_callbacks_set_on_frame_recv_callback(callbacks, async_http2_on_frame_recv_callback); + req->http2_callbacks = callbacks; + + /* Create HTTP/2 session */ + int rv = nghttp2_session_client_new(&session, callbacks, stream_data); + if (rv != 0) { + nghttp2_session_callbacks_del(callbacks); + free(stream_data->recv_buf); + free(stream_data->send_buf); + free(stream_data->data_buf); + free(stream_data); + req->http2_stream_data = NULL; + req->http2_callbacks = NULL; + async_request_set_error(req, -1, "Failed to create HTTP/2 session"); + return ASYNC_STATUS_ERROR; + } + req->http2_session = session; + + /* Send HTTP/2 preface and settings (Chrome-like) - only for new sessions */ + nghttp2_settings_entry iv[4]; + iv[0].settings_id = NGHTTP2_SETTINGS_HEADER_TABLE_SIZE; + iv[0].value = 65536; + iv[1].settings_id = NGHTTP2_SETTINGS_ENABLE_PUSH; + iv[1].value = 0; + iv[2].settings_id = NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE; + iv[2].value = 6291456; + iv[3].settings_id = NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE; + iv[3].value = 262144; + + nghttp2_submit_settings(session, NGHTTP2_FLAG_NONE, iv, 4); + nghttp2_submit_window_update(session, NGHTTP2_FLAG_NONE, 0, 15663105); + + DEBUG_PRINT("[async_request] Created new HTTP/2 session (id=%lu)\n", (unsigned long)req->id); + } else { + DEBUG_PRINT("[async_request] Reusing existing HTTP/2 session (id=%lu)\n", (unsigned long)req->id); + } + + /* Prepare request headers */ + nghttp2_nv hdrs[64]; + int nhdrs = 0; + + const char *method_str = httpmorph_method_to_string(req->request->method); + const char *host = req->request->host; + + /* Extract path from URL */ + const char *path = strchr(req->request->url, '/'); + if (path && path[0] == '/' && path[1] == '/') { + path = strchr(path + 2, '/'); + } + if (!path || path[0] != '/') { + path = "/"; + } + + /* Add pseudo-headers in Chrome order: m,a,s,p */ + hdrs[nhdrs++] = (nghttp2_nv){(uint8_t *)":method", (uint8_t *)method_str, 7, strlen(method_str), NGHTTP2_NV_FLAG_NONE}; + hdrs[nhdrs++] = (nghttp2_nv){(uint8_t *)":authority", (uint8_t *)host, 10, strlen(host), NGHTTP2_NV_FLAG_NONE}; + hdrs[nhdrs++] = (nghttp2_nv){(uint8_t *)":scheme", (uint8_t *)"https", 7, 5, NGHTTP2_NV_FLAG_NONE}; + hdrs[nhdrs++] = (nghttp2_nv){(uint8_t *)":path", (uint8_t *)path, 5, strlen(path), NGHTTP2_NV_FLAG_NONE}; + + /* Add custom headers */ + for (size_t i = 0; i < req->request->header_count && nhdrs < 60; i++) { + if (strcasecmp(req->request->headers[i].key, "host") == 0) continue; + hdrs[nhdrs++] = (nghttp2_nv){ + (uint8_t *)req->request->headers[i].key, + (uint8_t *)req->request->headers[i].value, + strlen(req->request->headers[i].key), + strlen(req->request->headers[i].value), + NGHTTP2_NV_FLAG_NONE + }; + } + + /* Set up data provider if request has a body */ + nghttp2_data_provider data_prd; + nghttp2_data_provider *data_prd_ptr = NULL; + if (stream_data->req_body_len > 0) { + data_prd.source.ptr = NULL; + data_prd.read_callback = async_http2_data_source_read_callback; + data_prd_ptr = &data_prd; + } + + /* Chrome default priority */ + nghttp2_priority_spec pri_spec; + nghttp2_priority_spec_init(&pri_spec, 0, 256, 1); + + /* Submit request to the session */ + int32_t stream_id = nghttp2_submit_request(session, &pri_spec, hdrs, nhdrs, data_prd_ptr, stream_data); + if (stream_id < 0) { + async_request_set_error(req, -1, "Failed to submit HTTP/2 request"); + return ASYNC_STATUS_ERROR; + } + req->http2_stream_id = stream_id; + req->http2_session_initialized = true; + req->http2_stream_closed = false; + + DEBUG_PRINT("[async_request] HTTP/2 request submitted, stream_id=%d, reused=%d (id=%lu)\n", + stream_id, is_reused_session, (unsigned long)req->id); + + /* Transition to send state - return NEED_WRITE to yield control to event loop */ + req->state = ASYNC_STATE_HTTP2_SEND; + return ASYNC_STATUS_NEED_WRITE; +} + +/** + * State: Send HTTP/2 frames + * Uses buffered I/O: nghttp2 writes to buffer, we do non-blocking SSL_write + */ +static int step_http2_send(async_request_t *req) { + nghttp2_session *session = (nghttp2_session *)req->http2_session; + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)req->http2_stream_data; + + if (!session || !stream_data) { + async_request_set_error(req, -1, "HTTP/2 session or stream_data is NULL"); + return ASYNC_STATUS_ERROR; + } + + /* Check if stream already completed (from callback) */ + if (stream_data && (stream_data->stream_closed || req->http2_stream_closed)) { + req->state = ASYNC_STATE_HTTP2_RECV; + return ASYNC_STATUS_NEED_READ; /* Let recv state handle completion */ + } + + /* First, let nghttp2 generate any pending data into our buffer */ + int rv = nghttp2_session_send(session); + if (rv < 0 && rv != NGHTTP2_ERR_WOULDBLOCK) { + char err_msg[128]; + snprintf(err_msg, sizeof(err_msg), "HTTP/2 send error: %s", nghttp2_strerror(rv)); + async_request_set_error(req, rv, err_msg); + return ASYNC_STATUS_ERROR; + } + + /* Now do actual SSL_write on buffered data */ + while (stream_data->send_pos < stream_data->send_len) { + size_t remaining = stream_data->send_len - stream_data->send_pos; + ERR_clear_error(); + int written = SSL_write(req->ssl, stream_data->send_buf + stream_data->send_pos, remaining); + + if (written <= 0) { + int ssl_err = SSL_get_error(req->ssl, written); + if (ssl_err == SSL_ERROR_WANT_WRITE) { + stream_data->last_ssl_want = SSL_ERROR_WANT_WRITE; + return ASYNC_STATUS_NEED_WRITE; + } else if (ssl_err == SSL_ERROR_WANT_READ) { + /* TLS renegotiation - need to read first */ + stream_data->last_ssl_want = SSL_ERROR_WANT_READ; + return ASYNC_STATUS_NEED_READ; + } else { + async_request_set_error(req, ssl_err, "HTTP/2 SSL write failed"); + return ASYNC_STATUS_ERROR; + } + } + + stream_data->send_pos += written; + } + + /* All data sent - reset buffer */ + stream_data->send_len = 0; + stream_data->send_pos = 0; + + /* Check if nghttp2 wants to write more */ + if (nghttp2_session_want_write(session)) { + /* Generate more data - but need to yield to event loop first */ + /* Return NEED_WRITE so polling will wait for socket and re-enter */ + return ASYNC_STATUS_NEED_WRITE; + } + + /* Transition to receive state */ + req->state = ASYNC_STATE_HTTP2_RECV; + + /* Check if we need to read */ + if (nghttp2_session_want_read(session)) { + return ASYNC_STATUS_NEED_READ; + } + + /* Check if already complete */ + if (stream_data && stream_data->stream_closed) { + /* Transition to recv state to finalize */ + return ASYNC_STATUS_NEED_READ; + } + + return ASYNC_STATUS_NEED_READ; +} + +/** + * State: Receive HTTP/2 frames + * Uses buffered I/O: we do non-blocking SSL_read, then feed data to nghttp2_session_mem_recv + */ +static int step_http2_recv(async_request_t *req) { + nghttp2_session *session = (nghttp2_session *)req->http2_session; + async_http2_stream_data_t *stream_data = (async_http2_stream_data_t *)req->http2_stream_data; + + if (!session || !stream_data) { + async_request_set_error(req, -1, "HTTP/2 recv: NULL session or stream_data"); + return ASYNC_STATUS_ERROR; + } + + /* Check if stream already completed */ + if (stream_data && (stream_data->stream_closed || req->http2_stream_closed)) { + goto complete; + } + + /* Try non-blocking SSL_read */ + ERR_clear_error(); + int n = SSL_read(req->ssl, stream_data->recv_buf, stream_data->recv_capacity); + + if (n <= 0) { + int ssl_err = SSL_get_error(req->ssl, n); + if (ssl_err == SSL_ERROR_WANT_READ) { + /* Check if we need to send (e.g., WINDOW_UPDATE) */ + if (nghttp2_session_want_write(session)) { + req->state = ASYNC_STATE_HTTP2_SEND; + return ASYNC_STATUS_NEED_WRITE; + } + return ASYNC_STATUS_NEED_READ; + } else if (ssl_err == SSL_ERROR_WANT_WRITE) { + /* TLS renegotiation */ + return ASYNC_STATUS_NEED_WRITE; + } else if (ssl_err == SSL_ERROR_ZERO_RETURN || n == 0) { + /* Connection closed - check if we got response */ + if (stream_data && stream_data->headers_complete) { + goto complete; + } + async_request_set_error(req, -1, "HTTP/2 connection closed unexpectedly"); + return ASYNC_STATUS_ERROR; + } else { + async_request_set_error(req, ssl_err, "HTTP/2 SSL read failed"); + return ASYNC_STATUS_ERROR; + } + } + + /* Feed received data to nghttp2 */ + ssize_t rv = nghttp2_session_mem_recv(session, stream_data->recv_buf, n); + + if (rv < 0) { + char err_msg[128]; + snprintf(err_msg, sizeof(err_msg), "HTTP/2 recv error: %s", nghttp2_strerror((int)rv)); + async_request_set_error(req, (int)rv, err_msg); + return ASYNC_STATUS_ERROR; + } + + /* Check if stream is complete (from callback during recv) */ + if (stream_data && (stream_data->stream_closed || req->http2_stream_closed)) { + goto complete; + } + + /* Check if we need to send (e.g., WINDOW_UPDATE, PING response, SETTINGS ACK) */ + if (nghttp2_session_want_write(session)) { + req->state = ASYNC_STATE_HTTP2_SEND; + return ASYNC_STATUS_NEED_WRITE; /* Go send first */ + } + + /* Check if there might be more SSL data buffered */ + if (SSL_pending(req->ssl) > 0) { + return ASYNC_STATUS_NEED_READ; /* Read more - socket is ready */ + } + + /* Continue receiving if session wants to */ + if (nghttp2_session_want_read(session)) { + return ASYNC_STATUS_NEED_READ; + } + + /* Session doesn't want to read or write */ + /* If we have headers, consider it complete (some servers don't send END_STREAM) */ + if (stream_data && stream_data->headers_complete) { + goto complete; + } + + /* Still waiting for headers - continue receiving */ + return ASYNC_STATUS_NEED_READ; + +complete: + /* Set response body */ + if (stream_data && stream_data->data_len > 0) { + req->response->body = malloc(stream_data->data_len + 1); + if (req->response->body) { + memcpy(req->response->body, stream_data->data_buf, stream_data->data_len); + req->response->body[stream_data->data_len] = '\0'; + req->response->body_len = stream_data->data_len; + } + } + + DEBUG_PRINT("[async_request] HTTP/2 complete, status=%d, body_len=%zu (id=%lu)\n", + req->response->status_code, req->response->body_len, (unsigned long)req->id); + + req->state = ASYNC_STATE_COMPLETE; + if (req->on_complete) { + req->on_complete(req, ASYNC_STATUS_COMPLETE); + } + return ASYNC_STATUS_COMPLETE; +} + +#endif /* HAVE_NGHTTP2 */ + /** * Step the async request state machine */ @@ -2154,6 +3089,17 @@ int async_request_step(async_request_t *req) { case ASYNC_STATE_RECEIVING_BODY: return step_receiving_body(req); +#ifdef HAVE_NGHTTP2 + case ASYNC_STATE_HTTP2_INIT: + return step_http2_init(req); + + case ASYNC_STATE_HTTP2_SEND: + return step_http2_send(req); + + case ASYNC_STATE_HTTP2_RECV: + return step_http2_recv(req); +#endif + case ASYNC_STATE_COMPLETE: /* Already complete */ if (req->on_complete) { diff --git a/src/core/async_request.h b/src/core/async_request.h index 124bb2c..d721e2f 100644 --- a/src/core/async_request.h +++ b/src/core/async_request.h @@ -19,6 +19,12 @@ extern "C" { */ typedef struct ssl_st SSL; +/* Forward declaration for pooled connection */ +typedef struct pooled_connection pooled_connection_t; + +/* Forward declaration for connection pool */ +typedef struct httpmorph_pool httpmorph_pool_t; + /** @@ -30,9 +36,13 @@ typedef enum { ASYNC_STATE_CONNECTING, /* TCP connection in progress */ ASYNC_STATE_PROXY_CONNECT, /* Proxy CONNECT tunnel establishment */ ASYNC_STATE_TLS_HANDSHAKE, /* TLS handshake in progress */ - ASYNC_STATE_SENDING_REQUEST, /* Sending HTTP request */ - ASYNC_STATE_RECEIVING_HEADERS, /* Receiving response headers */ - ASYNC_STATE_RECEIVING_BODY, /* Receiving response body */ + ASYNC_STATE_SENDING_REQUEST, /* Sending HTTP request (HTTP/1.x) */ + ASYNC_STATE_RECEIVING_HEADERS, /* Receiving response headers (HTTP/1.x) */ + ASYNC_STATE_RECEIVING_BODY, /* Receiving response body (HTTP/1.x) */ + /* HTTP/2 states */ + ASYNC_STATE_HTTP2_INIT, /* Initialize HTTP/2 session */ + ASYNC_STATE_HTTP2_SEND, /* Send HTTP/2 frames */ + ASYNC_STATE_HTTP2_RECV, /* Receive HTTP/2 frames */ ASYNC_STATE_COMPLETE, /* Request completed successfully */ ASYNC_STATE_ERROR /* Request failed */ } async_request_state_t; @@ -134,6 +144,22 @@ struct async_request { /* Reference counting */ int refcount; + /* Connection pooling support */ + pooled_connection_t *pooled_conn; /* Reused connection from pool */ + httpmorph_pool_t *pool; /* Connection pool to return to */ + bool from_pool; /* True if connection was from pool */ + + /* HTTP/2 support */ +#ifdef HAVE_NGHTTP2 + bool use_http2; /* True if HTTP/2 was negotiated via ALPN */ + void *http2_session; /* nghttp2_session* */ + void *http2_callbacks; /* nghttp2_session_callbacks* */ + void *http2_stream_data; /* http2_stream_data_t* for current stream */ + int32_t http2_stream_id; /* Current stream ID */ + bool http2_session_initialized; /* True if HTTP/2 preface was sent */ + bool http2_stream_closed; /* True if stream finished */ +#endif + /* Windows IOCP support */ #ifdef _WIN32 void *overlapped_connect; /* OVERLAPPED* for ConnectEx */ @@ -162,6 +188,19 @@ async_request_t* async_request_create( void *user_data ); +/** + * Create a new async request with connection pool support + */ +async_request_t* async_request_create_pooled( + const httpmorph_request_t *request, + io_engine_t *io_engine, + SSL_CTX *ssl_ctx, + httpmorph_pool_t *pool, + uint32_t timeout_ms, + async_request_callback_t callback, + void *user_data +); + /** * Destroy an async request */ diff --git a/src/core/async_request_manager.c b/src/core/async_request_manager.c index c641829..6f5aa33 100644 --- a/src/core/async_request_manager.c +++ b/src/core/async_request_manager.c @@ -3,7 +3,9 @@ */ #include "async_request_manager.h" +#include "connection_pool.h" #include "internal/tls.h" +#include "../tls/browser_profiles.h" #include #include #include @@ -50,7 +52,7 @@ async_request_manager_t* async_manager_create(void) { return NULL; } - /* Configure SSL context */ + /* Configure SSL context - use minimal configuration for async to avoid conflicts */ SSL_CTX_set_verify(mgr->ssl_ctx, SSL_VERIFY_PEER, NULL); #ifdef _WIN32 /* On Windows, load certificates from Windows Certificate Store */ @@ -60,10 +62,29 @@ async_request_manager_t* async_manager_create(void) { SSL_CTX_set_default_verify_paths(mgr->ssl_ctx); #endif + /* Enable SSL session caching for TLS session resumption */ + SSL_CTX_set_session_cache_mode(mgr->ssl_ctx, SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL); + SSL_CTX_set_timeout(mgr->ssl_ctx, 300); /* 5 minute session timeout */ + + /* Configure SSL_CTX with Chrome 143 browser profile for proper TLS fingerprinting. + * This sets cipher suites, extensions, GREASE, ALPN, etc. to match Chrome's JA4 fingerprint. + * Note: httpmorph_configure_ssl_ctx already configures ALPN for HTTP/2 from the profile. */ + httpmorph_configure_ssl_ctx(mgr->ssl_ctx, &PROFILE_CHROME_143); + + /* Create connection pool for reuse */ + mgr->pool = pool_create(); + if (!mgr->pool) { + SSL_CTX_free(mgr->ssl_ctx); + io_engine_destroy(mgr->io_engine); + free(mgr); + return NULL; + } + /* Allocate request array */ mgr->request_capacity = INITIAL_CAPACITY; mgr->requests = calloc(mgr->request_capacity, sizeof(async_request_t*)); if (!mgr->requests) { + pool_destroy(mgr->pool); SSL_CTX_free(mgr->ssl_ctx); io_engine_destroy(mgr->io_engine); free(mgr); @@ -149,6 +170,11 @@ void async_manager_destroy(async_request_manager_t *mgr) { free(mgr->requests); pthread_mutex_unlock(&mgr->mutex); + /* Destroy connection pool */ + if (mgr->pool) { + pool_destroy(mgr->pool); + } + /* Destroy SSL context */ if (mgr->ssl_ctx) { SSL_CTX_free(mgr->ssl_ctx); @@ -206,11 +232,12 @@ uint64_t async_manager_submit_request( pthread_mutex_lock(&mgr->mutex); - /* Create async request */ - async_request_t *req = async_request_create( + /* Create async request with connection pooling support */ + async_request_t *req = async_request_create_pooled( request, mgr->io_engine, mgr->ssl_ctx, + mgr->pool, timeout_ms, callback, user_data @@ -236,7 +263,7 @@ uint64_t async_manager_submit_request( /* Add to array */ mgr->requests[mgr->request_count++] = req; - async_request_ref(req); /* Manager holds a reference */ + /* Note: The initial refcount=1 from creation IS the manager's reference */ pthread_mutex_unlock(&mgr->mutex); @@ -313,8 +340,47 @@ int async_manager_cancel_request( return -1; } +/** + * Remove a completed request from the manager + * Should be called after extracting the response to prevent memory leaks + */ +int async_manager_remove_request( + async_request_manager_t *mgr, + uint64_t request_id) +{ + if (!mgr) { + return -1; + } + + pthread_mutex_lock(&mgr->mutex); + for (size_t i = 0; i < mgr->request_count; i++) { + if (mgr->requests[i] && mgr->requests[i]->id == request_id) { + async_request_t *req = mgr->requests[i]; + + /* Remove from array by shifting remaining elements */ + for (size_t j = i; j < mgr->request_count - 1; j++) { + mgr->requests[j] = mgr->requests[j + 1]; + } + mgr->request_count--; + mgr->requests[mgr->request_count] = NULL; + + /* Release manager's reference */ + async_request_unref(req); + + pthread_mutex_unlock(&mgr->mutex); + DEBUG_PRINT("[async_manager] Removed request id=%lu\n", (unsigned long)request_id); + return 0; + } + } + pthread_mutex_unlock(&mgr->mutex); + return -1; /* Request not found */ +} + /** * Poll for events + * Note: Does NOT automatically clean up completed requests to avoid race conditions + * with concurrent Python coroutines. Python must call async_manager_remove_request() + * after extracting the response. */ int async_manager_poll(async_request_manager_t *mgr, uint32_t timeout_ms) { if (!mgr) { @@ -355,8 +421,9 @@ int async_manager_poll(async_request_manager_t *mgr, uint32_t timeout_ms) { } } - /* Clean up completed requests */ - cleanup_completed_requests(mgr); + /* NOTE: We intentionally do NOT clean up completed requests here. + * Python coroutines must explicitly call async_manager_remove_request() + * after extracting the response to avoid race conditions. */ pthread_mutex_unlock(&mgr->mutex); diff --git a/src/core/async_request_manager.h b/src/core/async_request_manager.h index f51563c..936eb81 100644 --- a/src/core/async_request_manager.h +++ b/src/core/async_request_manager.h @@ -21,6 +21,9 @@ extern "C" { /* Forward declaration for SSL_CTX */ typedef struct ssl_ctx_st SSL_CTX; +/* Forward declaration for connection pool */ +typedef struct httpmorph_pool httpmorph_pool_t; + /** * Request manager structure */ @@ -31,6 +34,9 @@ typedef struct async_request_manager { /* SSL/TLS context */ SSL_CTX *ssl_ctx; + /* Connection pool for reuse */ + httpmorph_pool_t *pool; + /* Request tracking */ async_request_t **requests; size_t request_count; @@ -87,6 +93,15 @@ int async_manager_cancel_request( uint64_t request_id ); +/** + * Remove a completed request from the manager + * Should be called after extracting the response + */ +int async_manager_remove_request( + async_request_manager_t *mgr, + uint64_t request_id +); + /** * Poll for events (non-blocking) * Returns number of events processed diff --git a/src/core/buffer_pool.c b/src/core/buffer_pool.c index 691c897..b9b79dc 100644 --- a/src/core/buffer_pool.c +++ b/src/core/buffer_pool.c @@ -43,10 +43,12 @@ struct httpmorph_buffer_pool { * Returns the smallest tier that can fit the requested size */ static int get_tier_index(size_t size) { - if (size <= BUFFER_SIZE_4KB) return 0; - if (size <= BUFFER_SIZE_16KB) return 1; - if (size <= BUFFER_SIZE_64KB) return 2; - if (size <= BUFFER_SIZE_256KB) return 3; + if (size <= BUFFER_SIZE_1KB) return 0; + if (size <= BUFFER_SIZE_4KB) return 1; + if (size <= BUFFER_SIZE_16KB) return 2; + if (size <= BUFFER_SIZE_64KB) return 3; + if (size <= BUFFER_SIZE_256KB) return 4; + if (size <= BUFFER_SIZE_1MB) return 5; return -1; /* Too large for pooling */ } @@ -55,10 +57,12 @@ static int get_tier_index(size_t size) { */ static size_t get_tier_size(int tier_index) { switch (tier_index) { - case 0: return BUFFER_SIZE_4KB; - case 1: return BUFFER_SIZE_16KB; - case 2: return BUFFER_SIZE_64KB; - case 3: return BUFFER_SIZE_256KB; + case 0: return BUFFER_SIZE_1KB; + case 1: return BUFFER_SIZE_4KB; + case 2: return BUFFER_SIZE_16KB; + case 3: return BUFFER_SIZE_64KB; + case 4: return BUFFER_SIZE_256KB; + case 5: return BUFFER_SIZE_1MB; default: return 0; } } diff --git a/src/core/buffer_pool.h b/src/core/buffer_pool.h index 87217b2..ff3563a 100644 --- a/src/core/buffer_pool.h +++ b/src/core/buffer_pool.h @@ -11,17 +11,27 @@ #include #include -/* Buffer size tiers (powers of 2 for efficient allocation) */ +/* Buffer size tiers (powers of 2 for efficient allocation) + * Optimized for common HTTP response sizes: + * - 1KB: Small JSON responses, API errors + * - 4KB: Typical API responses + * - 16KB: Medium responses + * - 64KB: Larger responses, small files + * - 256KB: Large responses + * - 1MB: Very large responses, files + */ +#define BUFFER_SIZE_1KB 1024 #define BUFFER_SIZE_4KB 4096 #define BUFFER_SIZE_16KB 16384 #define BUFFER_SIZE_64KB 65536 #define BUFFER_SIZE_256KB 262144 +#define BUFFER_SIZE_1MB 1048576 -/* Number of buffers to keep per size tier */ -#define BUFFERS_PER_TIER 8 +/* Number of buffers to keep per size tier (increased for better concurrency) */ +#define BUFFERS_PER_TIER 16 /* Total number of size tiers */ -#define NUM_TIERS 4 +#define NUM_TIERS 6 /** * Buffer pool structure diff --git a/src/core/client.c b/src/core/client.c index c505668..383c1bf 100644 --- a/src/core/client.c +++ b/src/core/client.c @@ -6,6 +6,7 @@ #include "internal/tls.h" #include "internal/network.h" #include "buffer_pool.h" +#include "connection_pool.h" #ifndef _WIN32 #include @@ -208,10 +209,12 @@ httpmorph_client_t* httpmorph_client_create(void) { SSL_CTX_set_default_verify_paths(client->ssl_ctx); #endif - /* Disable SSL session caching to avoid BoringSSL state issues */ - SSL_CTX_set_session_cache_mode(client->ssl_ctx, SSL_SESS_CACHE_OFF); + /* Enable SSL session caching for TLS session resumption + * This significantly speeds up subsequent TLS connections to the same host + * by reusing the negotiated session parameters (0-RTT or 1-RTT resumption) */ + SSL_CTX_set_session_cache_mode(client->ssl_ctx, SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL); - /* Set session timeout (5 minutes = 300 seconds) - still set even if caching is off */ + /* Set session timeout (5 minutes = 300 seconds) */ SSL_CTX_set_timeout(client->ssl_ctx, 300); /* Enable session ticket support for TLS 1.3 and better TLS 1.2 resumption */ @@ -228,12 +231,13 @@ httpmorph_client_t* httpmorph_client_create(void) { client->max_redirects = 10; client->io_engine = default_io_engine; - /* Default to Chrome browser profile - but DON'T configure SSL_CTX here. - * SSL_CTX configuration happens in session.c when the actual profile is known. - * This is because SSL_CTX_add_cert_compression_alg() and similar functions - * ADD to the context rather than replacing, so we can't reconfigure later. */ + /* Default to Chrome 143 browser profile for proper TLS fingerprinting */ client->browser_profile = &PROFILE_CHROME_143; - client->ssl_ctx_configured = false; /* Mark as not yet configured */ + + /* Configure SSL_CTX with browser profile for Chrome-like TLS fingerprint. + * This sets cipher suites, extensions, GREASE, etc. to match Chrome. */ + httpmorph_configure_ssl_ctx(client->ssl_ctx, client->browser_profile); + client->ssl_ctx_configured = true; /* Create buffer pool for response bodies */ client->buffer_pool = buffer_pool_create(); @@ -243,6 +247,23 @@ httpmorph_client_t* httpmorph_client_create(void) { return NULL; } + /* Create connection pool for keep-alive */ + client->pool = pool_create(); + if (!client->pool) { + buffer_pool_destroy(client->buffer_pool); + SSL_CTX_free(client->ssl_ctx); + free(client); + return NULL; + } + + /* Initialize TLS session cache mutex */ + client->session_cache_count = 0; +#ifdef _WIN32 + InitializeCriticalSection(&client->session_cache_mutex); +#else + pthread_mutex_init(&client->session_cache_mutex, NULL); +#endif + return client; } @@ -280,6 +301,23 @@ void httpmorph_client_destroy(httpmorph_client_t *client) { return; } + /* Destroy connection pool first (closes all pooled connections) */ + if (client->pool) { + pool_destroy(client->pool); + } + + /* Free TLS session cache entries */ + for (int i = 0; i < client->session_cache_count; i++) { + if (client->session_cache[i].session) { + SSL_SESSION_free(client->session_cache[i].session); + } + } +#ifdef _WIN32 + DeleteCriticalSection(&client->session_cache_mutex); +#else + pthread_mutex_destroy(&client->session_cache_mutex); +#endif + if (client->ssl_ctx) { SSL_CTX_free(client->ssl_ctx); } diff --git a/src/core/connection_pool.c b/src/core/connection_pool.c index 157db3b..a42bf54 100644 --- a/src/core/connection_pool.c +++ b/src/core/connection_pool.c @@ -249,6 +249,81 @@ pooled_connection_t* pool_get_connection(httpmorph_pool_t *pool, return result; } +pooled_connection_t* pool_get_proxy_connection(httpmorph_pool_t *pool, + const char *host, + int port, + const char *proxy_url) { + if (!pool || !host || !proxy_url) { + return NULL; + } + + /* Lock pool for thread safety */ +#ifdef _WIN32 + CRITICAL_SECTION *cs = (CRITICAL_SECTION*)pool->mutex; + if (cs) EnterCriticalSection(cs); +#else + pthread_mutex_t *mutex = (pthread_mutex_t*)pool->mutex; + if (mutex) pthread_mutex_lock(mutex); +#endif + + /* Build proxy-aware host key */ + char host_key[POOL_MAX_HOST_KEY_LEN]; + pool_build_proxy_host_key(host, port, proxy_url, host_key); + + /* Search for matching connection */ + pooled_connection_t **curr = &pool->connections; + pooled_connection_t *result = NULL; + + while (*curr) { + pooled_connection_t *conn = *curr; + + if (strcmp(conn->host_key, host_key) == 0) { + /* Found matching proxy tunnel - validate it */ + if (pool_connection_validate(conn)) { + /* HTTP/2 connections can be shared (multiplexing) */ + if (conn->is_http2 && conn->ref_count > 0) { + /* Connection already in use - increment ref_count and share it */ + conn->ref_count++; + conn->last_used = time(NULL); + result = conn; + break; + } + + /* For HTTP/1.1 or first use of HTTP/2: remove from pool */ + *curr = conn->next; + pool->total_connections--; + pool->active_connections++; + + /* Update last used time and increment reference count */ + conn->last_used = time(NULL); + conn->ref_count = 1; /* First reference */ + conn->next = NULL; + + result = conn; + break; + } else { + /* Connection is dead - remove and destroy it */ + *curr = conn->next; + pool->total_connections--; + pool_connection_destroy(conn); + /* Continue searching */ + } + } else { + /* Move to next */ + curr = &conn->next; + } + } + + /* Unlock pool */ +#ifdef _WIN32 + if (cs) LeaveCriticalSection(cs); +#else + if (mutex) pthread_mutex_unlock(mutex); +#endif + + return result; +} + bool pool_put_connection(httpmorph_pool_t *pool, pooled_connection_t *conn) { if (!pool || !conn) { return false; @@ -394,6 +469,69 @@ pooled_connection_t* pool_connection_create(const char *host, return conn; } +pooled_connection_t* pool_proxy_connection_create(const char *host, + int port, + int sockfd, + SSL *ssl, + bool is_http2, + const char *proxy_url) { + if (!host || sockfd < 0 || !proxy_url) { + return NULL; + } + + /* Ensure socket is in blocking mode for HTTP/1.1 compatibility + * (HTTP/2 connections are already non-blocking) */ + if (!is_http2) { +#ifdef _WIN32 + u_long mode = 0; /* 0 = blocking */ + ioctlsocket(sockfd, FIONBIO, &mode); +#else + int flags = fcntl(sockfd, F_GETFL, 0); + if (flags != -1) { + fcntl(sockfd, F_SETFL, flags & ~O_NONBLOCK); + } +#endif + } + + pooled_connection_t *conn = (pooled_connection_t*)calloc(1, sizeof(pooled_connection_t)); + if (!conn) { + return NULL; + } + + /* Build proxy-aware host key: "hostname:port@proxy_url" */ + pool_build_proxy_host_key(host, port, proxy_url, conn->host_key); + + /* Initialize connection */ + conn->sockfd = sockfd; + conn->ssl = ssl; + conn->is_http2 = is_http2; + conn->is_valid = true; + conn->preface_sent = false; /* Will be set to true after first HTTP/2 preface */ + conn->state = POOL_CONN_IDLE; + conn->ref_count = 0; /* No references yet */ + conn->last_used = time(NULL); + conn->next = NULL; + + /* Initialize proxy info fields */ + conn->is_proxy = true; + conn->proxy_url = strdup(proxy_url); + conn->target_host = strdup(host); + conn->target_port = port; + + /* Initialize TLS info fields */ + conn->ja3_fingerprint = NULL; + conn->tls_version = NULL; + conn->tls_cipher = NULL; + +#ifdef HAVE_NGHTTP2 + conn->http2_session = NULL; + conn->http2_stream_data = NULL; + conn->http2_session_manager = NULL; +#endif + + return conn; +} + void pool_connection_destroy(pooled_connection_t *conn) { if (!conn) { return; @@ -463,20 +601,29 @@ bool pool_connection_validate(pooled_connection_t *conn) { return false; } - /* For SSL connections, just check shutdown state */ + /* For SSL connections, check shutdown state */ if (conn->ssl) { int shutdown_state = SSL_get_shutdown(conn->ssl); if (shutdown_state != 0) { return false; } - /* Trust the connection - if it fails, we'll handle it during actual use */ - return true; } - /* For non-SSL connections, also just trust them */ - /* The cost of validation (fcntl, recv) is higher than just trying to use - * the connection and handling failures. If the connection is dead, the - * next request will fail and we'll create a new one. */ +#ifdef HAVE_NGHTTP2 + /* For HTTP/2 connections, validate nghttp2 session if present */ + if (conn->is_http2 && conn->http2_session) { + nghttp2_session *session = (nghttp2_session *)conn->http2_session; + /* Check if session received GOAWAY and has no active streams. + * Note: An idle session (no active streams) will have want_read=0 and want_write=0, + * but that doesn't mean it's invalid - it's just idle and can accept new requests. */ + if (nghttp2_session_get_remote_settings(session, NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS) == 0) { + /* Server doesn't allow any streams - session is unusable */ + return false; + } + } +#endif + + /* Trust the connection - if it fails, we'll handle it during actual use */ return true; } @@ -490,6 +637,20 @@ void pool_build_host_key(const char *host, int port, char *key_out) { snprintf(key_out, POOL_MAX_HOST_KEY_LEN, "%s:%d", host, port); } +void pool_build_proxy_host_key(const char *host, int port, const char *proxy_url, char *key_out) { + if (!host || !key_out) { + return; + } + + if (proxy_url) { + /* For proxy connections: "hostname:port@proxy_url" */ + snprintf(key_out, POOL_MAX_HOST_KEY_LEN, "%s:%d@%s", host, port, proxy_url); + } else { + /* No proxy - same as regular key */ + snprintf(key_out, POOL_MAX_HOST_KEY_LEN, "%s:%d", host, port); + } +} + int pool_count_connections_for_host(httpmorph_pool_t *pool, const char *host_key) { if (!pool || !host_key) { return 0; @@ -672,3 +833,108 @@ int httpmorph_connection_on_writable(pooled_connection_t *conn, (void)user_data; /* Unused in Phase A */ return 0; /* Success (no-op) */ } + +/* === Public API Wrappers === */ + +/** + * Pre-warm connections to a host (public API) + */ +int httpmorph_pool_prewarm( + httpmorph_client_t *client, + const char *host, + int port, + bool use_tls, + int count) +{ + if (!client || !host || count <= 0) { + return 0; + } + + httpmorph_pool_t *pool = client->pool; + if (!pool) { + return 0; + } + + return pool_prewarm_connections(pool, client, host, port, use_tls, count); +} + +/** + * Configure connection pool settings (public API) + */ +void httpmorph_pool_configure( + httpmorph_pool_t *pool, + int idle_timeout_seconds, + int max_connections_per_host, + int max_total_connections) +{ + if (!pool) { + return; + } + + /* Lock pool for thread safety */ +#ifdef _WIN32 + CRITICAL_SECTION *cs = (CRITICAL_SECTION*)pool->mutex; + if (cs) EnterCriticalSection(cs); +#else + pthread_mutex_t *mutex = (pthread_mutex_t*)pool->mutex; + if (mutex) pthread_mutex_lock(mutex); +#endif + + if (idle_timeout_seconds > 0) { + pool->idle_timeout_seconds = idle_timeout_seconds; + } + if (max_connections_per_host > 0) { + pool->max_connections_per_host = max_connections_per_host; + } + if (max_total_connections > 0) { + pool->max_total_connections = max_total_connections; + } + + /* Unlock pool */ +#ifdef _WIN32 + if (cs) LeaveCriticalSection(cs); +#else + if (mutex) pthread_mutex_unlock(mutex); +#endif +} + +/** + * Get connection pool statistics (public API) + */ +void httpmorph_pool_stats( + httpmorph_pool_t *pool, + int *total_connections, + int *active_connections) +{ + if (!pool) { + if (total_connections) *total_connections = 0; + if (active_connections) *active_connections = 0; + return; + } + + /* Lock pool for thread safety */ +#ifdef _WIN32 + CRITICAL_SECTION *cs = (CRITICAL_SECTION*)pool->mutex; + if (cs) EnterCriticalSection(cs); +#else + pthread_mutex_t *mutex = (pthread_mutex_t*)pool->mutex; + if (mutex) pthread_mutex_lock(mutex); +#endif + + if (total_connections) *total_connections = pool->total_connections; + if (active_connections) *active_connections = pool->active_connections; + + /* Unlock pool */ +#ifdef _WIN32 + if (cs) LeaveCriticalSection(cs); +#else + if (mutex) pthread_mutex_unlock(mutex); +#endif +} + +/** + * Clean up idle connections (public API wrapper) + */ +void httpmorph_pool_cleanup_idle(httpmorph_pool_t *pool) { + pool_cleanup_idle(pool); +} diff --git a/src/core/connection_pool.h b/src/core/connection_pool.h index 30577ca..60b66de 100644 --- a/src/core/connection_pool.h +++ b/src/core/connection_pool.h @@ -129,6 +129,22 @@ pooled_connection_t* pool_get_connection(httpmorph_pool_t *pool, const char *host, int port); +/** + * Get a proxy connection from the pool + * Returns an existing tunnel connection if available, NULL otherwise + * Uses proxy-aware key: "hostname:port@proxy_url" + * + * @param pool The connection pool + * @param host Target hostname (e.g., "example.com") + * @param port Target port number (e.g., 443) + * @param proxy_url Proxy URL (e.g., "http://proxy:8080") + * @return Pooled connection or NULL if not found + */ +pooled_connection_t* pool_get_proxy_connection(httpmorph_pool_t *pool, + const char *host, + int port, + const char *proxy_url); + /** * Return a connection to the pool for reuse * If pool is full or connection is invalid, it will be closed @@ -156,6 +172,26 @@ pooled_connection_t* pool_connection_create(const char *host, SSL *ssl, bool is_http2); +/** + * Create a pooled proxy connection wrapper + * Does NOT add to pool yet - call pool_put_connection() for that + * Uses proxy-aware key: "hostname:port@proxy_url" + * + * @param host Target hostname + * @param port Target port number + * @param sockfd Socket file descriptor (the tunneled connection) + * @param ssl SSL connection to destination (or NULL for HTTP) + * @param is_http2 Whether this is an HTTP/2 connection + * @param proxy_url Proxy URL used for this tunnel + * @return New pooled connection (caller must free or pool it) + */ +pooled_connection_t* pool_proxy_connection_create(const char *host, + int port, + int sockfd, + SSL *ssl, + bool is_http2, + const char *proxy_url); + /** * Close and free a pooled connection * Closes socket, frees SSL, frees memory @@ -183,6 +219,17 @@ bool pool_connection_validate(pooled_connection_t *conn); */ void pool_build_host_key(const char *host, int port, char *key_out); +/** + * Build a host key for proxy connections + * Format: "hostname:port@proxy_url" (includes proxy to differentiate tunnels) + * + * @param host Target hostname + * @param port Target port number + * @param proxy_url Proxy URL (e.g., "http://proxy:8080") or NULL for direct + * @param key_out Buffer to write key (must be POOL_MAX_HOST_KEY_LEN bytes) + */ +void pool_build_proxy_host_key(const char *host, int port, const char *proxy_url, char *key_out); + /** * Count connections for a specific host * diff --git a/src/core/core.c b/src/core/core.c index f0822de..bb34207 100644 --- a/src/core/core.c +++ b/src/core/core.c @@ -73,10 +73,37 @@ httpmorph_response_t* httpmorph_request_execute( bool proxy_use_tls = false; SSL *proxy_ssl = NULL; - /* Don't use connection pool for proxy connections - they are not pooled - * due to SSL_CTX use-after-free issues (see line 532 below). - * If we retrieve a connection from pool here, it would be a stale direct - * connection which cannot be used for proxy requests. */ + /* Try to get a pooled proxy tunnel connection first */ + if (pool) { + pooled_conn = pool_get_proxy_connection(pool, host, port, request->proxy_url); + if (pooled_conn) { + /* Reuse existing proxy tunnel from pool */ + sockfd = pooled_conn->sockfd; + ssl = pooled_conn->ssl; + use_http2 = pooled_conn->is_http2; + + /* Set HTTP version from pooled connection */ + if (use_http2) { + response->http_version = HTTPMORPH_VERSION_2_0; + } else { + response->http_version = HTTPMORPH_VERSION_1_1; + } + + /* Restore TLS info from pooled connection */ + if (pooled_conn->ja3_fingerprint) { + response->ja3_fingerprint = strdup(pooled_conn->ja3_fingerprint); + } + if (pooled_conn->tls_version) { + response->tls_version = strdup(pooled_conn->tls_version); + } + if (pooled_conn->tls_cipher) { + response->tls_cipher = strdup(pooled_conn->tls_cipher); + } + + /* Connection reused - no connect/TLS time */ + connect_time = 0; + } + } /* If no pooled connection, create new proxy connection */ if (sockfd < 0) { @@ -166,6 +193,13 @@ httpmorph_response_t* httpmorph_request_execute( ssl = pooled_conn->ssl; use_http2 = pooled_conn->is_http2; /* Use same protocol as pooled connection */ + /* Set HTTP version from pooled connection */ + if (use_http2) { + response->http_version = HTTPMORPH_VERSION_2_0; + } else { + response->http_version = HTTPMORPH_VERSION_1_1; + } + /* Restore TLS info from pooled connection BEFORE potential destruction */ if (sockfd >= 0) { /* Connection reused - no connect/TLS time */ @@ -204,10 +238,11 @@ httpmorph_response_t* httpmorph_request_execute( } response->connect_time_us = connect_time; - /* 2. TLS Handshake (if HTTPS and not reused) */ + /* 2. TLS Handshake (if HTTPS and not reused) + * Use the cached version for session resumption support */ if (use_tls && !ssl) { uint64_t tls_time = 0; - ssl = httpmorph_tls_connect(client->ssl_ctx, sockfd, host, client->browser_profile, + ssl = httpmorph_tls_connect_cached(client, sockfd, host, port, request->http2_enabled, request->verify_ssl, &tls_time); if (!ssl) { response->error = HTTPMORPH_ERROR_TLS; @@ -265,16 +300,34 @@ httpmorph_response_t* httpmorph_request_execute( uint64_t first_byte_time = httpmorph_get_time_us(); int http2_result; - /* Use pooled version for session reuse if connection came from pool */ + /* For new HTTP/2 connections, create pooled_conn wrapper to enable session reuse */ + if (!pooled_conn && !request->proxy_url) { + pooled_conn = pool_connection_create(host, port, sockfd, ssl, true /* is_http2 */); + if (pooled_conn) { + /* Store TLS info for reuse */ + if (response->ja3_fingerprint) { + pooled_conn->ja3_fingerprint = strdup(response->ja3_fingerprint); + } + if (response->tls_version) { + pooled_conn->tls_version = strdup(response->tls_version); + } + if (response->tls_cipher) { + pooled_conn->tls_cipher = strdup(response->tls_cipher); + } + } + } + + /* Use pooled version for session reuse (works for both new and reused connections) */ if (pooled_conn && pooled_conn->is_http2) { /* Use concurrent version if session manager exists (high-performance mode) */ if (pooled_conn->http2_session_manager) { http2_result = httpmorph_http2_request_concurrent(pooled_conn, request, host, path, response); } else { - /* Fall back to sequential pooled version */ + /* Use pooled version which creates and stores the session */ http2_result = httpmorph_http2_request_pooled(pooled_conn, request, host, path, response); } } else { + /* Fallback for proxy connections or when pooled_conn creation failed */ http2_result = httpmorph_http2_request(ssl, request, host, path, response); } @@ -308,10 +361,10 @@ httpmorph_response_t* httpmorph_request_execute( } response->connect_time_us = connect_time; - /* New TLS handshake if needed */ + /* New TLS handshake if needed (use cached for session resumption) */ if (use_tls) { uint64_t tls_time = 0; - ssl = httpmorph_tls_connect(client->ssl_ctx, sockfd, host, client->browser_profile, + ssl = httpmorph_tls_connect_cached(client, sockfd, host, port, request->http2_enabled, request->verify_ssl, &tls_time); if (!ssl) { response->error = HTTPMORPH_ERROR_TLS; @@ -358,10 +411,10 @@ httpmorph_response_t* httpmorph_request_execute( } response->connect_time_us = connect_time; - /* New TLS handshake */ + /* New TLS handshake (use cached for session resumption) */ if (use_tls) { uint64_t tls_time = 0; - ssl = httpmorph_tls_connect(client->ssl_ctx, sockfd, host, client->browser_profile, + ssl = httpmorph_tls_connect_cached(client, sockfd, host, port, request->http2_enabled, request->verify_ssl, &tls_time); if (!ssl) { response->error = HTTPMORPH_ERROR_TLS; @@ -469,49 +522,35 @@ httpmorph_response_t* httpmorph_request_execute( if (!conn_to_pool) { /* New connection - create wrapper */ - bool use_http2 = (response->http_version == HTTPMORPH_VERSION_2_0); - /* Don't pool HTTP/2 connections - HTTP/2 pooling has reliability issues */ - /* Don't pool proxy connections - they can cause SSL_CTX use-after-free issues */ - if (use_http2 || request->proxy_url) { - conn_to_pool = NULL; + bool is_http2 = (response->http_version == HTTPMORPH_VERSION_2_0); + if (request->proxy_url) { + /* Create proxy-aware pooled connection with key: "host:port@proxy_url" */ + conn_to_pool = pool_proxy_connection_create(host, port, sockfd, ssl, is_http2, request->proxy_url); } else { - conn_to_pool = pool_connection_create(host, port, sockfd, ssl, use_http2); - /* Store TLS info in pooled connection for future reuse with error checking */ - if (conn_to_pool && ssl) { - bool alloc_failed = false; - - if (response->ja3_fingerprint) { - conn_to_pool->ja3_fingerprint = strdup(response->ja3_fingerprint); - if (!conn_to_pool->ja3_fingerprint) alloc_failed = true; - } - if (response->tls_version && !alloc_failed) { - conn_to_pool->tls_version = strdup(response->tls_version); - if (!conn_to_pool->tls_version) alloc_failed = true; - } - if (response->tls_cipher && !alloc_failed) { - conn_to_pool->tls_cipher = strdup(response->tls_cipher); - if (!conn_to_pool->tls_cipher) alloc_failed = true; - } + /* Pool both HTTP/1.1 and HTTP/2 connections for reuse */ + conn_to_pool = pool_connection_create(host, port, sockfd, ssl, is_http2); + } + /* Store TLS info in pooled connection for future reuse with error checking */ + if (conn_to_pool && ssl) { + bool alloc_failed = false; - /* If any allocation failed, destroy connection instead of pooling */ - if (alloc_failed) { - pool_connection_destroy(conn_to_pool); - conn_to_pool = NULL; - } + if (response->ja3_fingerprint) { + conn_to_pool->ja3_fingerprint = strdup(response->ja3_fingerprint); + if (!conn_to_pool->ja3_fingerprint) alloc_failed = true; } - /* Store proxy info for proxy connections */ - if (conn_to_pool && request->proxy_url) { - conn_to_pool->is_proxy = true; - /* Free old values if already set (connection reuse) */ - if (conn_to_pool->proxy_url) { - free(conn_to_pool->proxy_url); - } - if (conn_to_pool->target_host) { - free(conn_to_pool->target_host); - } - conn_to_pool->proxy_url = strdup(request->proxy_url); - conn_to_pool->target_host = strdup(host); - conn_to_pool->target_port = port; + if (response->tls_version && !alloc_failed) { + conn_to_pool->tls_version = strdup(response->tls_version); + if (!conn_to_pool->tls_version) alloc_failed = true; + } + if (response->tls_cipher && !alloc_failed) { + conn_to_pool->tls_cipher = strdup(response->tls_cipher); + if (!conn_to_pool->tls_cipher) alloc_failed = true; + } + + /* If any allocation failed, destroy connection instead of pooling */ + if (alloc_failed) { + pool_connection_destroy(conn_to_pool); + conn_to_pool = NULL; } } } diff --git a/src/core/http2_logic.c b/src/core/http2_logic.c index d923647..e25a26a 100644 --- a/src/core/http2_logic.c +++ b/src/core/http2_logic.c @@ -47,9 +47,17 @@ static ssize_t http2_send_callback(nghttp2_session *session, const uint8_t *data size_t length, int flags, void *user_data) { http2_stream_data_t *stream_data = (http2_stream_data_t *)user_data; if (stream_data && stream_data->ssl) { - return SSL_write(stream_data->ssl, data, length); + ssize_t rv = SSL_write(stream_data->ssl, data, length); + if (rv < 0) { + int ssl_err = SSL_get_error(stream_data->ssl, rv); + if (ssl_err == SSL_ERROR_WANT_WRITE) { + return NGHTTP2_ERR_WOULDBLOCK; + } + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + return rv; } - return -1; + return NGHTTP2_ERR_CALLBACK_FAILURE; } /* Helper: Data provider callback for sending request body */ @@ -501,9 +509,9 @@ int httpmorph_http2_request_pooled(struct pooled_connection *conn, stream_data.req_body_len = request->body_len; stream_data.req_body_sent = 0; - /* If no session exists, create one */ + /* Create nghttp2 session if needed */ if (session == NULL) { - /* Initialize nghttp2 callbacks */ + /* New session - need to send preface */ nghttp2_session_callbacks_new(&callbacks); nghttp2_session_callbacks_set_send_callback(callbacks, http2_send_callback); nghttp2_session_callbacks_set_recv_callback(callbacks, http2_recv_callback); @@ -511,8 +519,8 @@ int httpmorph_http2_request_pooled(struct pooled_connection *conn, nghttp2_session_callbacks_set_on_data_chunk_recv_callback(callbacks, http2_on_data_chunk_recv_callback); nghttp2_session_callbacks_set_on_frame_recv_callback(callbacks, http2_on_frame_recv_callback); - /* Create session and send preface */ - rv = http2_init_or_reuse_session(&session, callbacks, &stream_data, conn->ssl, &session_created); + /* Create session */ + rv = nghttp2_session_client_new(&session, callbacks, &stream_data); nghttp2_session_callbacks_del(callbacks); if (rv != 0) { @@ -520,28 +528,27 @@ int httpmorph_http2_request_pooled(struct pooled_connection *conn, return -1; } - /* Store session in connection for reuse */ + /* Send HTTP/2 connection preface (SETTINGS + WINDOW_UPDATE) */ + nghttp2_settings_entry iv[4]; + iv[0].settings_id = NGHTTP2_SETTINGS_HEADER_TABLE_SIZE; + iv[0].value = 65536; + iv[1].settings_id = NGHTTP2_SETTINGS_ENABLE_PUSH; + iv[1].value = 0; + iv[2].settings_id = NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE; + iv[2].value = 6291456; + iv[3].settings_id = NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE; + iv[3].value = 262144; + + nghttp2_submit_settings(session, NGHTTP2_FLAG_NONE, iv, 4); + nghttp2_submit_window_update(session, NGHTTP2_FLAG_NONE, 0, 15663105); + nghttp2_session_send(session); + + /* Store session in connection */ conn->http2_session = session; conn->preface_sent = true; - - /* Create and start session manager for concurrent multiplexing */ - http2_session_manager_t *mgr = http2_session_manager_create( - session, - callbacks, - conn->ssl, - conn->sockfd - ); - - if (mgr) { - /* Start I/O thread for concurrent stream handling */ - if (http2_session_manager_start(mgr) == 0) { - conn->http2_session_manager = mgr; - } else { - /* Failed to start - clean up manager */ - http2_session_manager_destroy(mgr); - conn->http2_session_manager = NULL; - } - } + } else { + /* Reusing existing session - just update user_data */ + nghttp2_session_set_user_data(session, &stream_data); } /* Prepare request headers */ @@ -608,24 +615,40 @@ int httpmorph_http2_request_pooled(struct pooled_connection *conn, } /* Send request */ - nghttp2_session_send(session); + rv = nghttp2_session_send(session); + if (rv != 0) { + free(stream_data.data_buf); + return -1; + } /* Receive response - event loop for non-blocking I/O */ int sockfd = SSL_get_fd(conn->ssl); fd_set readfds, writefds; struct timeval tv; - while (!stream_data.stream_closed && - (nghttp2_session_want_read(session) || nghttp2_session_want_write(session))) { + /* Loop until stream is closed. Check want_read/want_write as a hint, but also + * check SSL_pending for buffered data and always try at least one iteration + * after submitting a request. */ + bool first_iteration = true; + while (!stream_data.stream_closed) { + /* Check if session wants to do anything */ + bool want_read = nghttp2_session_want_read(session) || SSL_pending(conn->ssl) > 0; + bool want_write = nghttp2_session_want_write(session); + + /* If session doesn't want to do anything and this isn't the first iteration, exit */ + if (!want_read && !want_write && !first_iteration) { + break; + } + first_iteration = false; /* Wait for socket to be ready */ FD_ZERO(&readfds); FD_ZERO(&writefds); - if (nghttp2_session_want_read(session)) { + if (want_read) { FD_SET(sockfd, &readfds); } - if (nghttp2_session_want_write(session)) { + if (want_write) { FD_SET(sockfd, &writefds); } @@ -638,8 +661,10 @@ int httpmorph_http2_request_pooled(struct pooled_connection *conn, rv = -1; break; } else if (select_rv == 0) { - /* Timeout */ - rv = -1; + /* Timeout - only error if we haven't received the response yet */ + if (!stream_data.stream_closed) { + rv = -1; + } break; } diff --git a/src/core/internal/internal.h b/src/core/internal/internal.h index 7b2dab4..14716b4 100644 --- a/src/core/internal/internal.h +++ b/src/core/internal/internal.h @@ -83,6 +83,15 @@ /* Forward declare buffer pool */ typedef struct httpmorph_buffer_pool httpmorph_buffer_pool_t; +/* TLS session cache entry (for session resumption across connections) */ +#define MAX_SESSION_CACHE_ENTRIES 64 +typedef struct tls_session_entry { + char host[256]; + uint16_t port; + SSL_SESSION *session; + time_t created; +} tls_session_entry_t; + /** * HTTP client structure */ @@ -92,6 +101,15 @@ struct httpmorph_client { httpmorph_pool_t *pool; httpmorph_buffer_pool_t *buffer_pool; /* Buffer pool for response bodies */ + /* TLS session cache for session resumption */ + tls_session_entry_t session_cache[MAX_SESSION_CACHE_ENTRIES]; + int session_cache_count; +#ifdef _WIN32 + CRITICAL_SECTION session_cache_mutex; +#else + pthread_mutex_t session_cache_mutex; +#endif + /* Configuration */ uint32_t timeout_ms; bool follow_redirects; diff --git a/src/core/internal/network.h b/src/core/internal/network.h index 31d273c..d8bc1e7 100644 --- a/src/core/internal/network.h +++ b/src/core/internal/network.h @@ -19,6 +19,23 @@ int httpmorph_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms, uint64_t *connect_time); +/** + * Lookup hostname in DNS cache + * Returns cached addrinfo if found and not expired, NULL otherwise + * Caller must free the result with dns_cache_free_result() + */ +struct addrinfo* dns_cache_lookup(const char *hostname, uint16_t port); + +/** + * Add entry to DNS cache + */ +void dns_cache_add(const char *hostname, uint16_t port, const struct addrinfo *result); + +/** + * Free a DNS cache lookup result + */ +void dns_cache_free_result(struct addrinfo *result); + /** * Cleanup expired DNS cache entries */ diff --git a/src/core/internal/tls.h b/src/core/internal/tls.h index 541b9d5..6a9266a 100644 --- a/src/core/internal/tls.h +++ b/src/core/internal/tls.h @@ -51,6 +51,45 @@ SSL* httpmorph_tls_connect(SSL_CTX *ctx, int sockfd, const char *hostname, const browser_profile_t *browser_profile, bool http2_enabled, bool verify_cert, uint64_t *tls_time); +/** + * Establish TLS connection with session caching support + * + * @param client HTTP client (for session cache) + * @param sockfd Socket file descriptor + * @param hostname Hostname for SNI + * @param port Target port + * @param http2_enabled Whether HTTP/2 is enabled + * @param verify_cert Whether to verify server certificate + * @param tls_time Output: TLS handshake time in microseconds + * @return SSL* on success, NULL on error + */ +SSL* httpmorph_tls_connect_cached(httpmorph_client_t *client, int sockfd, + const char *hostname, uint16_t port, + bool http2_enabled, bool verify_cert, + uint64_t *tls_time); + +/** + * Store a TLS session in the client's cache + */ +void httpmorph_session_cache_put(httpmorph_client_t *client, const char *host, + uint16_t port, SSL_SESSION *session); + +/** + * Get a TLS session from the client's cache + */ +SSL_SESSION* httpmorph_session_cache_get(httpmorph_client_t *client, const char *host, uint16_t port); + +/** + * Get a TLS session from the global cache (for async requests) + * Returns NULL if not found. Caller should NOT free the returned session. + */ +SSL_SESSION* global_session_cache_get(const char *host, uint16_t port); + +/** + * Store a TLS session in the global cache (for async requests) + */ +void global_session_cache_put(const char *host, uint16_t port, SSL_SESSION *session); + /** * Calculate JA3 fingerprint from SSL connection * diff --git a/src/core/network.c b/src/core/network.c index 66c0aa5..2647782 100644 --- a/src/core/network.c +++ b/src/core/network.c @@ -136,11 +136,18 @@ static void addrinfo_deep_free(struct addrinfo *ai) { } } +/** + * Free a DNS cache lookup result (deep copied addrinfo) + */ +void dns_cache_free_result(struct addrinfo *result) { + addrinfo_deep_free(result); +} + /** * Lookup hostname in DNS cache * Returns cached addrinfo if found and not expired, NULL otherwise */ -static struct addrinfo* dns_cache_lookup(const char *hostname, uint16_t port) { +struct addrinfo* dns_cache_lookup(const char *hostname, uint16_t port) { if (!hostname) return NULL; dns_cache_init_mutex(); @@ -175,8 +182,8 @@ static struct addrinfo* dns_cache_lookup(const char *hostname, uint16_t port) { /** * Add entry to DNS cache */ -static void dns_cache_add(const char *hostname, uint16_t port, - const struct addrinfo *result) { +void dns_cache_add(const char *hostname, uint16_t port, + const struct addrinfo *result) { if (!hostname || !result) return; dns_cache_init_mutex(); @@ -299,15 +306,203 @@ void dns_cache_clear(void) { } /* ==================================================================== - * TCP CONNECTION + * TCP CONNECTION WITH HAPPY EYEBALLS (RFC 8305) * ==================================================================== */ +/* Happy Eyeballs configuration */ +#define HAPPY_EYEBALLS_DELAY_MS 250 /* RFC 8305 recommends 250ms */ +#define MAX_PARALLEL_CONNECTIONS 2 /* IPv6 + IPv4 */ + +/** + * Configure socket with performance options + */ +static void configure_socket_options(int sockfd) { + /* Enable TCP_NODELAY (disable Nagle's algorithm for lower latency) */ + int nodelay = 1; + setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (char*)&nodelay, sizeof(nodelay)); + + /* Enable SO_REUSEADDR for faster socket reuse */ + int reuse = 1; + setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, (char*)&reuse, sizeof(reuse)); + + /* Enable SO_KEEPALIVE for connection health monitoring */ + int keepalive = 1; + setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, (char*)&keepalive, sizeof(keepalive)); + + /* Optimize send/receive buffer sizes (64KB each for better throughput) */ + int bufsize = 65536; + setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, (char*)&bufsize, sizeof(bufsize)); + setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, (char*)&bufsize, sizeof(bufsize)); + +#ifdef TCP_QUICKACK + /* Enable TCP_QUICKACK on Linux for faster ACKs */ + int quickack = 1; + setsockopt(sockfd, IPPROTO_TCP, TCP_QUICKACK, (char*)&quickack, sizeof(quickack)); +#endif + +#ifdef SO_REUSEPORT + /* Enable SO_REUSEPORT if available (Linux 3.9+, BSD) */ + int reuseport = 1; + setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, (char*)&reuseport, sizeof(reuseport)); +#endif +} + +/** + * Set socket to non-blocking mode + */ +static void set_socket_nonblocking(int sockfd) { +#ifdef _WIN32 + u_long mode = 1; + ioctlsocket(sockfd, FIONBIO, &mode); +#else + int flags = fcntl(sockfd, F_GETFL, 0); + fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); +#endif +} + +/** + * Set socket to blocking mode + */ +static void set_socket_blocking(int sockfd) { +#ifdef _WIN32 + u_long mode = 0; + ioctlsocket(sockfd, FIONBIO, &mode); +#else + int flags = fcntl(sockfd, F_GETFL, 0); + fcntl(sockfd, F_SETFL, flags & ~O_NONBLOCK); +#endif +} + /** - * Establish a TCP connection to a host + * Configure final socket options after successful connection + */ +static void configure_connected_socket(int sockfd, uint32_t timeout_ms) { + /* Set socket to blocking mode for HTTP/1.1 compatibility */ + set_socket_blocking(sockfd); + + /* Set performance options */ + int opt = 1; +#ifdef _WIN32 + setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (char*)&opt, sizeof(opt)); +#else + setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)); + + /* Enable TCP keep-alive to detect dead connections */ + setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt)); + + #ifdef TCP_KEEPIDLE + int keepidle = 60; + setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); + #endif + + #ifdef TCP_KEEPINTVL + int keepintvl = 10; + setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); + #endif + + #ifdef TCP_KEEPCNT + int keepcnt = 3; + setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); + #endif + + #ifdef __APPLE__ + #ifdef TCP_FASTOPEN + int tfo = 1; + setsockopt(sockfd, IPPROTO_TCP, TCP_FASTOPEN, &tfo, sizeof(tfo)); + #endif + #endif + + #ifdef __linux__ + #ifdef TCP_FASTOPEN_CONNECT + int tfo = 1; + setsockopt(sockfd, IPPROTO_TCP, TCP_FASTOPEN_CONNECT, &tfo, sizeof(tfo)); + #endif + #endif +#endif + + /* Set receive timeout to prevent indefinite blocking */ +#ifdef _WIN32 + DWORD timeout_dw = timeout_ms; + setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout_dw, sizeof(timeout_dw)); +#else + struct timeval recv_timeout; + recv_timeout.tv_sec = timeout_ms / 1000; + recv_timeout.tv_usec = (timeout_ms % 1000) * 1000; + setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &recv_timeout, sizeof(recv_timeout)); +#endif +} + +/** + * Check if socket connection completed (success or failure) + * Returns: 1 = connected, 0 = still pending, -1 = failed + */ +static int check_socket_connected(int sockfd) { + int error = 0; + socklen_t len = sizeof(error); + +#ifdef _WIN32 + if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, (char*)&error, (int*)&len) != 0) { + return -1; + } +#else + if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len) != 0) { + return -1; + } +#endif + + if (error == 0) { + return 1; /* Connected */ + } + return -1; /* Failed */ +} + +/** + * Start a non-blocking connection attempt + * Returns: socket fd on success (connection in progress), -1 on immediate failure + */ +static int start_connection_attempt(const struct addrinfo *addr) { + int sockfd = socket(addr->ai_family, addr->ai_socktype, addr->ai_protocol); + if (sockfd == -1) { + return -1; + } + + configure_socket_options(sockfd); + set_socket_nonblocking(sockfd); + + int ret = connect(sockfd, addr->ai_addr, addr->ai_addrlen); + if (ret == 0) { + /* Connected immediately (rare but possible on localhost) */ + return sockfd; + } + +#ifdef _WIN32 + if (WSAGetLastError() == WSAEWOULDBLOCK) { + return sockfd; /* Connection in progress */ + } +#else + if (errno == EINPROGRESS) { + return sockfd; /* Connection in progress */ + } +#endif + + /* Immediate failure */ + close(sockfd); + return -1; +} + +/** + * Establish a TCP connection using Happy Eyeballs (RFC 8305) + * + * Algorithm: + * 1. Sort addresses: IPv6 first, then IPv4 (interleaved by family) + * 2. Start first (IPv6) connection immediately + * 3. After 250ms delay, start IPv4 connection if IPv6 not yet connected + * 4. Return whichever connection succeeds first + * 5. Cancel losing connection(s) */ int httpmorph_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms, uint64_t *connect_time_us) { - struct addrinfo hints, *result, *rp; + struct addrinfo hints, *result; int sockfd = -1; uint64_t start_time = httpmorph_get_time_us(); bool need_free_result = false; @@ -315,7 +510,7 @@ int httpmorph_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms, /* Try DNS cache first */ result = dns_cache_lookup(host, port); if (result) { - need_free_result = true; /* We own this copy */ + need_free_result = true; } else { /* Cache miss - perform DNS lookup */ memset(&hints, 0, sizeof(hints)); @@ -324,214 +519,196 @@ int httpmorph_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms, hints.ai_flags = 0; hints.ai_protocol = 0; - /* Convert port to string */ char port_str[6]; snprintf(port_str, sizeof(port_str), "%u", port); - /* Resolve hostname */ int ret = getaddrinfo(host, port_str, &hints, &result); if (ret != 0) { return -1; } - /* Add to cache for future use */ dns_cache_add(host, port, result); - need_free_result = false; /* Will use freeaddrinfo() */ + need_free_result = false; } - /* Try each address until we succeed */ - int ret; - for (rp = result; rp != NULL; rp = rp->ai_next) { - sockfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); - if (sockfd == -1) { - continue; - } - - /* Enable TCP_NODELAY (disable Nagle's algorithm for lower latency) */ - int nodelay = 1; - setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (char*)&nodelay, sizeof(nodelay)); + /* Separate addresses by family (RFC 8305: prefer IPv6) */ + struct addrinfo *ipv6_addrs[16]; + struct addrinfo *ipv4_addrs[16]; + int ipv6_count = 0, ipv4_count = 0; - /* Enable SO_REUSEADDR for faster socket reuse */ - int reuse = 1; - setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, (char*)&reuse, sizeof(reuse)); + for (struct addrinfo *rp = result; rp != NULL; rp = rp->ai_next) { + if (rp->ai_family == AF_INET6 && ipv6_count < 16) { + ipv6_addrs[ipv6_count++] = rp; + } else if (rp->ai_family == AF_INET && ipv4_count < 16) { + ipv4_addrs[ipv4_count++] = rp; + } + } - /* Enable SO_KEEPALIVE for connection health monitoring */ - int keepalive = 1; - setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, (char*)&keepalive, sizeof(keepalive)); + /* Build interleaved address list (IPv6, IPv4, IPv6, IPv4, ...) per RFC 8305 */ + struct addrinfo *sorted_addrs[32]; + int sorted_count = 0; + int i6 = 0, i4 = 0; - /* Optimize send/receive buffer sizes (64KB each for better throughput) */ - int bufsize = 65536; - setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, (char*)&bufsize, sizeof(bufsize)); - setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, (char*)&bufsize, sizeof(bufsize)); + while (i6 < ipv6_count || i4 < ipv4_count) { + if (i6 < ipv6_count) { + sorted_addrs[sorted_count++] = ipv6_addrs[i6++]; + } + if (i4 < ipv4_count) { + sorted_addrs[sorted_count++] = ipv4_addrs[i4++]; + } + } -#ifdef TCP_QUICKACK - /* Enable TCP_QUICKACK on Linux for faster ACKs */ - int quickack = 1; - setsockopt(sockfd, IPPROTO_TCP, TCP_QUICKACK, (char*)&quickack, sizeof(quickack)); -#endif + if (sorted_count == 0) { + /* No addresses found */ + if (need_free_result) { + addrinfo_deep_free(result); + } else { + freeaddrinfo(result); + } + return -1; + } -#ifdef SO_REUSEPORT - /* Enable SO_REUSEPORT if available (Linux 3.9+, BSD) */ - int reuseport = 1; - setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, (char*)&reuseport, sizeof(reuseport)); -#endif + /* Happy Eyeballs: race connections with staggered starts */ + int active_sockets[MAX_PARALLEL_CONNECTIONS] = {-1, -1}; + int active_count = 0; + int next_addr_idx = 0; + uint64_t next_attempt_time = 0; + uint64_t deadline = start_time + (uint64_t)timeout_ms * 1000; + + /* Start first connection immediately */ + active_sockets[0] = start_connection_attempt(sorted_addrs[next_addr_idx++]); + if (active_sockets[0] >= 0) { + active_count = 1; + /* Schedule next attempt after 250ms delay */ + next_attempt_time = httpmorph_get_time_us() + HAPPY_EYEBALLS_DELAY_MS * 1000; + } - /* Set socket to non-blocking for timeout support */ -#ifdef _WIN32 - u_long mode = 1; - ioctlsocket(sockfd, FIONBIO, &mode); -#else - int flags = fcntl(sockfd, F_GETFL, 0); - fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); -#endif + /* Poll until we have a winner or timeout */ + while (active_count > 0) { + uint64_t now = httpmorph_get_time_us(); - /* Attempt connection */ - ret = connect(sockfd, rp->ai_addr, rp->ai_addrlen); - if (ret == 0) { - /* Connected immediately */ + /* Check timeout */ + if (now >= deadline) { break; } -#ifndef _WIN32 - /* On Unix, check for immediate connection failure */ - if (errno == ECONNREFUSED || errno == ENETUNREACH || errno == EHOSTUNREACH || - errno == ETIMEDOUT || errno == ECONNRESET) { - /* Connection failed immediately - don't wait, try next address */ - if (sockfd > 2) close(sockfd); - sockfd = -1; - continue; + /* Start next connection attempt if delay has passed and we have addresses left */ + if (next_addr_idx < sorted_count && active_count < MAX_PARALLEL_CONNECTIONS && now >= next_attempt_time) { + int new_sock = start_connection_attempt(sorted_addrs[next_addr_idx++]); + if (new_sock >= 0) { + active_sockets[active_count++] = new_sock; + next_attempt_time = now + HAPPY_EYEBALLS_DELAY_MS * 1000; + } } -#endif - -#ifdef _WIN32 - if (WSAGetLastError() == WSAEWOULDBLOCK) { -#else - if (errno == EINPROGRESS) { -#endif - /* Connection in progress - wait with select using polling approach */ - uint64_t poll_start = httpmorph_get_time_us(); - uint64_t poll_timeout_us = (uint64_t)timeout_ms * 1000; - int connected = 0; - while (httpmorph_get_time_us() - poll_start < poll_timeout_us) { - fd_set write_fds, except_fds; - struct timeval tv; + /* Build fd_set for select */ + fd_set write_fds; + FD_ZERO(&write_fds); + int max_fd = -1; - FD_ZERO(&write_fds); - FD_ZERO(&except_fds); - FD_SET(sockfd, &write_fds); - FD_SET(sockfd, &except_fds); + for (int i = 0; i < active_count; i++) { + if (active_sockets[i] >= 0) { + FD_SET(active_sockets[i], &write_fds); + if (active_sockets[i] > max_fd) { + max_fd = active_sockets[i]; + } + } + } - /* Poll every 100ms to detect errors quickly */ - tv.tv_sec = 0; - tv.tv_usec = 100000; /* 100ms */ + if (max_fd < 0) { + break; + } - ret = select(SELECT_NFDS(sockfd), NULL, &write_fds, &except_fds, &tv); + /* Calculate select timeout: + * - If we have more addresses to try, wait until next_attempt_time + * - Otherwise wait until deadline + */ + uint64_t wait_until = deadline; + if (next_addr_idx < sorted_count && active_count < MAX_PARALLEL_CONNECTIONS) { + if (next_attempt_time < wait_until) { + wait_until = next_attempt_time; + } + } - /* Check socket error after each poll */ - int error = 0; - socklen_t len = sizeof(error); -#ifdef _WIN32 - if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, (char*)&error, (int*)&len) == 0) { -#else - if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len) == 0) { -#endif - if (error == 0 && ret > 0 && (FD_ISSET(sockfd, &write_fds) || FD_ISSET(sockfd, &except_fds))) { - /* Connection succeeded */ - connected = 1; - break; - } else if (error != 0) { - /* Connection failed - don't wait, try next address */ + uint64_t wait_us = (wait_until > now) ? (wait_until - now) : 0; + /* Cap at 50ms for responsiveness */ + if (wait_us > 50000) wait_us = 50000; + + struct timeval tv; + tv.tv_sec = wait_us / 1000000; + tv.tv_usec = wait_us % 1000000; + + int sel_ret = select(SELECT_NFDS(max_fd), NULL, &write_fds, NULL, &tv); + + if (sel_ret > 0) { + /* Check which socket(s) are ready */ + for (int i = 0; i < active_count; i++) { + if (active_sockets[i] >= 0 && FD_ISSET(active_sockets[i], &write_fds)) { + int status = check_socket_connected(active_sockets[i]); + if (status == 1) { + /* Winner! This socket connected first */ + sockfd = active_sockets[i]; + active_sockets[i] = -1; + + /* Close all other active sockets */ + for (int j = 0; j < active_count; j++) { + if (active_sockets[j] >= 0) { + close(active_sockets[j]); + active_sockets[j] = -1; + } + } + active_count = 0; break; + } else if (status == -1) { + /* This socket failed, close it */ + close(active_sockets[i]); + active_sockets[i] = -1; } } } - if (connected) { - break; /* Successfully connected */ + /* Compact the active_sockets array */ + int write_idx = 0; + for (int i = 0; i < active_count; i++) { + if (active_sockets[i] >= 0) { + active_sockets[write_idx++] = active_sockets[i]; + } + } + active_count = write_idx; + + if (sockfd >= 0) { + break; /* We have a winner */ } - /* Connection failed or timed out - try next address */ } - /* Connection failed, try next address */ - if (sockfd > 2) close(sockfd); - sockfd = -1; + /* If no active connections and we have more addresses, try next */ + if (active_count == 0 && next_addr_idx < sorted_count) { + int new_sock = start_connection_attempt(sorted_addrs[next_addr_idx++]); + if (new_sock >= 0) { + active_sockets[0] = new_sock; + active_count = 1; + next_attempt_time = httpmorph_get_time_us() + HAPPY_EYEBALLS_DELAY_MS * 1000; + } + } } - /* Free result using appropriate method */ + /* Cleanup any remaining active sockets */ + for (int i = 0; i < active_count; i++) { + if (active_sockets[i] >= 0) { + close(active_sockets[i]); + } + } + + /* Free DNS result */ if (need_free_result) { addrinfo_deep_free(result); } else { freeaddrinfo(result); } - if (sockfd != -1) { - /* Set socket to blocking mode for HTTP/1.1 compatibility - * (HTTP/2 will set it back to non-blocking later if negotiated) */ -#ifdef _WIN32 - u_long mode = 0; - ioctlsocket(sockfd, FIONBIO, &mode); -#else - int flags = fcntl(sockfd, F_GETFL, 0); - fcntl(sockfd, F_SETFL, flags & ~O_NONBLOCK); -#endif - - /* Set performance options */ - int opt = 1; -#ifdef _WIN32 - setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (char*)&opt, sizeof(opt)); -#else - setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)); - - /* Enable TCP keep-alive to detect dead connections */ - setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt)); - - #ifdef TCP_KEEPIDLE - /* Start probing after 60 seconds of idle time */ - int keepidle = 60; - setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); - #endif - - #ifdef TCP_KEEPINTVL - /* Send probes every 10 seconds */ - int keepintvl = 10; - setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); - #endif - - #ifdef TCP_KEEPCNT - /* Drop connection after 3 failed probes */ - int keepcnt = 3; - setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); - #endif - - #ifdef __APPLE__ - /* Enable TCP Fast Open on macOS for reduced latency */ - #ifdef TCP_FASTOPEN - int tfo = 1; - setsockopt(sockfd, IPPROTO_TCP, TCP_FASTOPEN, &tfo, sizeof(tfo)); - #endif - #endif - - #ifdef __linux__ - /* Enable TCP Fast Open on Linux */ - #ifdef TCP_FASTOPEN_CONNECT - int tfo = 1; - setsockopt(sockfd, IPPROTO_TCP, TCP_FASTOPEN_CONNECT, &tfo, sizeof(tfo)); - #endif - #endif -#endif - - /* Set receive timeout to prevent indefinite blocking */ -#ifdef _WIN32 - DWORD timeout_dw = timeout_ms; - setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout_dw, sizeof(timeout_dw)); -#else - struct timeval recv_timeout; - recv_timeout.tv_sec = timeout_ms / 1000; - recv_timeout.tv_usec = (timeout_ms % 1000) * 1000; - setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &recv_timeout, sizeof(recv_timeout)); -#endif - + /* Configure winning socket */ + if (sockfd >= 0) { + configure_connected_socket(sockfd, timeout_ms); *connect_time_us = httpmorph_get_time_us() - start_time; } diff --git a/src/core/tls.c b/src/core/tls.c index 9239be2..860a7b8 100644 --- a/src/core/tls.c +++ b/src/core/tls.c @@ -12,6 +12,8 @@ extern void httpmorph_set_aes_hw_override(SSL_CTX *ctx, int override_value); #include #include #pragma comment(lib, "crypt32.lib") +#else +#include #endif /* OpenSSL 1.0.x compatibility */ @@ -56,6 +58,143 @@ static inline int SSL_CTX_set_max_proto_version(SSL_CTX *ctx, int version) { #include #include +/* ==================================================================== + * GLOBAL TLS SESSION CACHE (for async requests without client context) + * ==================================================================== */ + +#define GLOBAL_SESSION_CACHE_SIZE 64 +#define GLOBAL_SESSION_TTL_SECONDS 300 /* 5 minutes */ + +typedef struct global_session_entry { + char host[256]; + uint16_t port; + SSL_SESSION *session; + time_t created; + bool valid; +} global_session_entry_t; + +static global_session_entry_t g_session_cache[GLOBAL_SESSION_CACHE_SIZE]; +static int g_session_cache_initialized = 0; + +#ifdef _WIN32 +static CRITICAL_SECTION g_session_cache_mutex; +#else +static pthread_mutex_t g_session_cache_mutex = PTHREAD_MUTEX_INITIALIZER; +#endif + +static void global_session_cache_init(void) { + if (!g_session_cache_initialized) { +#ifdef _WIN32 + InitializeCriticalSection(&g_session_cache_mutex); +#endif + memset(g_session_cache, 0, sizeof(g_session_cache)); + g_session_cache_initialized = 1; + } +} + +static void global_session_cache_lock(void) { +#ifdef _WIN32 + EnterCriticalSection(&g_session_cache_mutex); +#else + pthread_mutex_lock(&g_session_cache_mutex); +#endif +} + +static void global_session_cache_unlock(void) { +#ifdef _WIN32 + LeaveCriticalSection(&g_session_cache_mutex); +#else + pthread_mutex_unlock(&g_session_cache_mutex); +#endif +} + +/* Get a TLS session from the global cache */ +SSL_SESSION* global_session_cache_get(const char *host, uint16_t port) { + if (!host) return NULL; + + global_session_cache_init(); + global_session_cache_lock(); + + SSL_SESSION *session = NULL; + time_t now = time(NULL); + + for (int i = 0; i < GLOBAL_SESSION_CACHE_SIZE; i++) { + if (g_session_cache[i].valid && + g_session_cache[i].port == port && + strcmp(g_session_cache[i].host, host) == 0) { + + /* Check if entry is expired */ + if (now - g_session_cache[i].created > GLOBAL_SESSION_TTL_SECONDS) { + /* Expired - invalidate and continue */ + SSL_SESSION_free(g_session_cache[i].session); + g_session_cache[i].valid = false; + g_session_cache[i].session = NULL; + break; + } + + session = g_session_cache[i].session; + break; + } + } + + global_session_cache_unlock(); + return session; +} + +/* Store a TLS session in the global cache */ +void global_session_cache_put(const char *host, uint16_t port, SSL_SESSION *session) { + if (!host || !session) return; + + global_session_cache_init(); + global_session_cache_lock(); + + int free_idx = -1; + int oldest_idx = 0; + time_t oldest_time = time(NULL); + + /* Look for existing entry or find free/oldest slot */ + for (int i = 0; i < GLOBAL_SESSION_CACHE_SIZE; i++) { + if (!g_session_cache[i].valid) { + if (free_idx < 0) free_idx = i; + continue; + } + + /* Update existing entry */ + if (g_session_cache[i].port == port && + strcmp(g_session_cache[i].host, host) == 0) { + SSL_SESSION_free(g_session_cache[i].session); + SSL_SESSION_up_ref(session); + g_session_cache[i].session = session; + g_session_cache[i].created = time(NULL); + global_session_cache_unlock(); + return; + } + + /* Track oldest for eviction */ + if (g_session_cache[i].created < oldest_time) { + oldest_time = g_session_cache[i].created; + oldest_idx = i; + } + } + + /* Use free slot or evict oldest */ + int target_idx = (free_idx >= 0) ? free_idx : oldest_idx; + + if (g_session_cache[target_idx].session) { + SSL_SESSION_free(g_session_cache[target_idx].session); + } + + strncpy(g_session_cache[target_idx].host, host, sizeof(g_session_cache[target_idx].host) - 1); + g_session_cache[target_idx].host[sizeof(g_session_cache[target_idx].host) - 1] = '\0'; + g_session_cache[target_idx].port = port; + SSL_SESSION_up_ref(session); + g_session_cache[target_idx].session = session; + g_session_cache[target_idx].created = time(NULL); + g_session_cache[target_idx].valid = true; + + global_session_cache_unlock(); +} + static int cert_decompress_brotli(SSL *ssl, CRYPTO_BUFFER **out, size_t uncompressed_len, const uint8_t *in, size_t in_len) { @@ -320,6 +459,112 @@ int httpmorph_configure_ssl_ctx(SSL_CTX *ctx, const browser_profile_t *profile) return 0; } +/* ================================================================== + * TLS SESSION CACHE + * ================================================================== */ + +#ifndef _WIN32 +#include +#endif + +/** + * Store a TLS session in the client's cache + * Note: We increment the reference count rather than duplicating, + * since BoringSSL doesn't have SSL_SESSION_dup. + */ +void httpmorph_session_cache_put(httpmorph_client_t *client, const char *host, + uint16_t port, SSL_SESSION *session) { + if (!client || !host || !session) return; + +#ifdef _WIN32 + EnterCriticalSection(&client->session_cache_mutex); +#else + pthread_mutex_lock(&client->session_cache_mutex); +#endif + + /* Look for existing entry to update */ + for (int i = 0; i < client->session_cache_count; i++) { + if (client->session_cache[i].port == port && + strcmp(client->session_cache[i].host, host) == 0) { + /* Replace existing session */ + SSL_SESSION_free(client->session_cache[i].session); + SSL_SESSION_up_ref(session); /* Increment ref count */ + client->session_cache[i].session = session; + client->session_cache[i].created = time(NULL); + goto unlock; + } + } + + /* Add new entry */ + if (client->session_cache_count < MAX_SESSION_CACHE_ENTRIES) { + int idx = client->session_cache_count++; + strncpy(client->session_cache[idx].host, host, sizeof(client->session_cache[idx].host) - 1); + client->session_cache[idx].host[sizeof(client->session_cache[idx].host) - 1] = '\0'; + client->session_cache[idx].port = port; + SSL_SESSION_up_ref(session); /* Increment ref count */ + client->session_cache[idx].session = session; + client->session_cache[idx].created = time(NULL); + } else { + /* Cache full - replace oldest entry */ + int oldest_idx = 0; + time_t oldest_time = client->session_cache[0].created; + for (int i = 1; i < MAX_SESSION_CACHE_ENTRIES; i++) { + if (client->session_cache[i].created < oldest_time) { + oldest_time = client->session_cache[i].created; + oldest_idx = i; + } + } + SSL_SESSION_free(client->session_cache[oldest_idx].session); + strncpy(client->session_cache[oldest_idx].host, host, sizeof(client->session_cache[oldest_idx].host) - 1); + client->session_cache[oldest_idx].host[sizeof(client->session_cache[oldest_idx].host) - 1] = '\0'; + client->session_cache[oldest_idx].port = port; + SSL_SESSION_up_ref(session); /* Increment ref count */ + client->session_cache[oldest_idx].session = session; + client->session_cache[oldest_idx].created = time(NULL); + } + +unlock: +#ifdef _WIN32 + LeaveCriticalSection(&client->session_cache_mutex); +#else + pthread_mutex_unlock(&client->session_cache_mutex); +#endif +} + +/** + * Get a TLS session from the client's cache + * Returns NULL if not found, or a reference to the cached session. + * Caller should NOT free the returned session - it's owned by the cache. + */ +SSL_SESSION* httpmorph_session_cache_get(httpmorph_client_t *client, const char *host, uint16_t port) { + if (!client || !host) return NULL; + + SSL_SESSION *session = NULL; + +#ifdef _WIN32 + EnterCriticalSection(&client->session_cache_mutex); +#else + pthread_mutex_lock(&client->session_cache_mutex); +#endif + + for (int i = 0; i < client->session_cache_count; i++) { + if (client->session_cache[i].port == port && + strcmp(client->session_cache[i].host, host) == 0) { + /* Return the cached session directly - caller should not free it */ + session = client->session_cache[i].session; + break; + } + } + +#ifdef _WIN32 + LeaveCriticalSection(&client->session_cache_mutex); +#else + pthread_mutex_unlock(&client->session_cache_mutex); +#endif + + return session; +} + /** * Establish TLS connection on existing socket */ @@ -472,6 +717,157 @@ SSL* httpmorph_tls_connect(SSL_CTX *ctx, int sockfd, const char *hostname, return ssl; } +/** + * Establish TLS connection with session caching support + * Uses cached TLS sessions to speed up repeated connections to the same host + */ +SSL* httpmorph_tls_connect_cached(httpmorph_client_t *client, int sockfd, + const char *hostname, uint16_t port, + bool http2_enabled, bool verify_cert, + uint64_t *tls_time) { + if (!client || !hostname) { + return NULL; + } + + uint64_t start_time = httpmorph_get_time_us(); + + SSL *ssl = SSL_new(client->ssl_ctx); + if (!ssl) { + return NULL; + } + + /* Try to reuse a cached session for faster resumption */ + SSL_SESSION *cached_session = httpmorph_session_cache_get(client, hostname, port); + if (cached_session) { + SSL_set_session(ssl, cached_session); + /* Don't free - cache owns the session */ + } + + /* Check which extensions the profile includes */ + const browser_profile_t *browser_profile = client->browser_profile; + bool has_ech = false; + bool has_alps = false; + bool has_ocsp = false; + if (browser_profile) { + for (int i = 0; i < browser_profile->extension_count; i++) { + uint16_t ext = browser_profile->extensions[i]; + if (ext == 65037) has_ech = true; + if (ext == 17613 || ext == 17513) has_alps = true; + if (ext == 5) has_ocsp = true; + } + } + + /* Enable/disable ECH grease */ + SSL_set_enable_ech_grease(ssl, has_ech ? 1 : 0); + + /* Enable OCSP stapling if profile includes it */ + if (has_ocsp) { + SSL_enable_ocsp_stapling(ssl); + } + + /* Set SSL verification mode */ + if (verify_cert) { + SSL_set_verify(ssl, SSL_VERIFY_PEER, NULL); + } else { + SSL_set_verify(ssl, SSL_VERIFY_NONE, NULL); + } + + /* Set ALPN protocols */ + if (browser_profile && browser_profile->alpn_protocol_count > 0) { + unsigned char alpn_list[256]; + unsigned char *alpn_p = alpn_list; + + for (int i = 0; i < browser_profile->alpn_protocol_count; i++) { + if (!http2_enabled && strcmp(browser_profile->alpn_protocols[i], "h2") == 0) { + continue; + } + + size_t len = strlen(browser_profile->alpn_protocols[i]); + *alpn_p++ = (unsigned char)len; + memcpy(alpn_p, browser_profile->alpn_protocols[i], len); + alpn_p += len; + } + + if (alpn_p > alpn_list) { + SSL_set_alpn_protos(ssl, alpn_list, alpn_p - alpn_list); + + if (has_alps && http2_enabled) { + SSL_add_application_settings(ssl, + (const uint8_t *)"h2", 2, + (const uint8_t *)"", 0); + } + } + } + + /* Set SNI hostname */ + SSL_set_tlsext_host_name(ssl, hostname); + + /* Attach to socket */ + if (SSL_set_fd(ssl, sockfd) != 1) { + SSL_free(ssl); + return NULL; + } + + /* Perform TLS handshake */ + int ret; + int ssl_err; + uint64_t handshake_timeout_us = 30000000; + uint64_t deadline = start_time + handshake_timeout_us; + + while (1) { + ret = SSL_connect(ssl); + if (ret == 1) { + break; /* Success */ + } + + ssl_err = SSL_get_error(ssl, ret); + + if (ssl_err == SSL_ERROR_WANT_READ || ssl_err == SSL_ERROR_WANT_WRITE) { + uint64_t now = httpmorph_get_time_us(); + if (now >= deadline) { + SSL_free(ssl); + return NULL; /* Timeout */ + } + + fd_set read_fds, write_fds; + struct timeval tv; + uint64_t remaining_us = deadline - now; + + FD_ZERO(&read_fds); + FD_ZERO(&write_fds); + + if (ssl_err == SSL_ERROR_WANT_READ) { + FD_SET(sockfd, &read_fds); + } else { + FD_SET(sockfd, &write_fds); + } + + tv.tv_sec = remaining_us / 1000000; + tv.tv_usec = remaining_us % 1000000; + + int select_ret = select(SELECT_NFDS(sockfd), &read_fds, &write_fds, NULL, &tv); + if (select_ret <= 0) { + SSL_free(ssl); + return NULL; + } + continue; + } + + /* Handshake failed */ + SSL_free(ssl); + return NULL; + } + + /* Cache the session for future connections */ + SSL_SESSION *new_session = SSL_get_session(ssl); + if (new_session) { + httpmorph_session_cache_put(client, hostname, port, new_session); + } + + *tls_time = httpmorph_get_time_us() - start_time; + return ssl; +} + /** * Calculate JA3 fingerprint from SSL connection */ diff --git a/src/httpmorph/_async_client.py b/src/httpmorph/_async_client.py index 066922a..0b5b1fd 100644 --- a/src/httpmorph/_async_client.py +++ b/src/httpmorph/_async_client.py @@ -1,8 +1,8 @@ """ AsyncClient - Truly asynchronous HTTP client using httpmorph's async I/O engine -This provides true async I/O capabilities without thread pool overhead. -Uses C-level I/O engine (kqueue/epoll) for non-blocking operations. +This provides true async I/O capabilities using C-level kqueue/epoll integration. +Falls back to thread pool if the async bindings are not available. """ import asyncio @@ -128,65 +128,72 @@ def raise_for_status(self): class AsyncClient: """ - HTTP client with true async I/O (no thread pool) + Async HTTP client with true async I/O using C-level kqueue/epoll. - This uses the C-level async I/O engine for maximum performance: - - ✅ Non-blocking connect() - - ✅ Non-blocking TLS handshake - - ✅ Non-blocking send/receive - - ✅ I/O engine with epoll/kqueue - - ✅ Async request manager - - ✅ Python asyncio bindings - - ⏳ DNS resolution (uses blocking for now) + Uses the C-level async I/O engine for non-blocking HTTP/HTTPS requests. + Falls back to thread pool if async bindings are unavailable. Usage: async with AsyncClient() as client: response = await client.get('https://example.com') print(response.status_code) + + # Concurrent requests run in parallel: + responses = await asyncio.gather( + client.get('https://example.com/1'), + client.get('https://example.com/2'), + client.get('https://example.com/3'), + ) """ - def __init__(self, http2: bool = False, timeout: float = 30.0): + def __init__(self, http2: bool = True, timeout: float = 30.0, max_workers: int = 10): """ Initialize AsyncClient Args: - http2: Enable HTTP/2 support (not yet implemented) + http2: Enable HTTP/2 support (default: True) timeout: Default timeout in seconds + max_workers: Maximum concurrent requests (for thread pool fallback) """ - if not HAS_ASYNC_BINDINGS: - raise RuntimeError( - "Async bindings not available. " - "Please rebuild httpmorph with: python setup.py build_ext --inplace" - ) - self.http2 = http2 self.timeout = timeout + self.max_workers = max_workers self._manager = None + self._use_true_async = HAS_ASYNC_BINDINGS + # Thread pool fallback + self._executor = None self._loop = None + self._thread_clients = None async def __aenter__(self): """Async context manager entry""" - # Create manager and set event loop - self._manager = _async_bindings.create_async_manager() - self._loop = asyncio.get_running_loop() - self._manager.set_event_loop(self._loop) + if self._use_true_async: + # Use true async I/O + self._manager = _async_bindings.AsyncRequestManager() + else: + # Fallback to thread pool + import concurrent.futures + import threading + + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) + self._loop = asyncio.get_running_loop() + self._thread_clients = threading.local() return self async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit""" await self.close() - async def get(self, url: str, **kwargs): - """ - Make async GET request + def _get_thread_client(self): + """Get or create a Client for the current thread (fallback mode).""" + from httpmorph import Client - Args: - url: URL to request - **kwargs: Additional request options (headers, timeout) + if not hasattr(self._thread_clients, 'client'): + self._thread_clients.client = Client(http2=self.http2) + return self._thread_clients.client - Returns: - AsyncResponse object - """ + async def get(self, url: str, **kwargs): + """Make async GET request""" return await self._request("GET", url, **kwargs) async def post(self, url: str, **kwargs): @@ -215,162 +222,109 @@ async def options(self, url: str, **kwargs): async def _request(self, method: str, url: str, **kwargs): """ - Internal async request implementation - - This uses the C-level async I/O engine: - 1. Create C async_request_t via manager - 2. Get socket FD from async_request_get_fd() - 3. Register FD with asyncio event loop (add_reader/add_writer) - 4. Wait for I/O events without blocking - 5. Step state machine on each event - 6. Return response when complete - """ - if self._manager is None: - raise RuntimeError( - "Client not initialized. Use 'async with AsyncClient() as client:' pattern" - ) - - # Get timeout (use default if not specified) - timeout = kwargs.get("timeout", self.timeout) - timeout_ms = int(timeout * 1000) + Internal async request implementation. - # Get headers - headers = kwargs.get("headers", {}) - - # Get verify parameter (default to True) - verify = kwargs.get("verify", True) - - # Get proxy parameters - proxy = kwargs.get("proxy") or kwargs.get("proxies") - proxy_auth = kwargs.get("proxy_auth") - - # Get body - body = kwargs.get("data") or kwargs.get("body") - - # Handle JSON parameter - json_data = kwargs.get("json") - if json_data: + Uses true async I/O when available, falls back to thread pool otherwise. + """ + timeout = kwargs.pop("timeout", self.timeout) + headers = kwargs.pop("headers", {}) + body = kwargs.pop("body", None) + data = kwargs.pop("data", None) + json_data = kwargs.pop("json", None) + proxy = kwargs.pop("proxy", None) + proxy_auth = kwargs.pop("proxy_auth", None) + verify = kwargs.pop("verify", True) + + # Handle body/data/json + request_body = None + if body is not None: + request_body = body if isinstance(body, bytes) else body.encode('utf-8') + elif json_data is not None: import json - - body = json.dumps(json_data).encode("utf-8") - headers = headers.copy() # Don't modify original - headers["Content-Type"] = "application/json" - - # Convert body to bytes if needed - if body and isinstance(body, str): - body = body.encode("utf-8") - - # Submit request to manager - response_dict = await self._manager.submit_request( - method=method, - url=url, - headers=headers, - body=body, - timeout_ms=timeout_ms, - verify=verify, - proxy=proxy, - proxy_auth=proxy_auth, - ) - - # Check for errors - if response_dict.get("error") and response_dict["error"] != 0: - error_msg = response_dict.get("error_message", "Request failed") - error_code = response_dict["error"] - - # Map error codes to exceptions (negative values in C) - if error_code == -5: # HTTPMORPH_ERROR_TIMEOUT - raise asyncio.TimeoutError(error_msg) - elif error_code == -3: # HTTPMORPH_ERROR_NETWORK - from httpmorph._client_c import ConnectionError - - raise ConnectionError(error_msg) + request_body = json.dumps(json_data).encode('utf-8') + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/json' + elif data is not None: + if isinstance(data, dict): + import urllib.parse + request_body = urllib.parse.urlencode(data).encode('utf-8') + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/x-www-form-urlencoded' else: - from httpmorph._client_c import RequestException - - raise RequestException(error_msg) - - # Create response object - return AsyncResponse(response_dict, url) + request_body = data if isinstance(data, bytes) else str(data).encode('utf-8') + + if self._use_true_async and self._manager is not None: + # True async I/O path + timeout_ms = int(timeout * 1000) + result = await self._manager.submit_request( + method, + url, + headers, + request_body, + timeout_ms, + verify=verify, + proxy=proxy, + proxy_auth=proxy_auth + ) + return AsyncResponse(result, url) + else: + # Thread pool fallback + if self._executor is None: + raise RuntimeError( + "Client not initialized. Use 'async with AsyncClient() as client:' pattern" + ) + + def sync_request(): + client = self._get_thread_client() + client_method = getattr(client, method.lower()) + return client_method(url, timeout=timeout, headers=headers, **kwargs) + + response = await self._loop.run_in_executor(self._executor, sync_request) + return response async def close(self): """Close client and cleanup resources""" if self._manager is not None: - # Wait for all active requests to complete before destroying manager - # This prevents the manager from being destroyed mid-request - max_wait = 10 # seconds - wait_start = asyncio.get_running_loop().time() - while self._manager.get_active_count() > 0: - # Trigger cleanup of completed requests - self._manager.cleanup() - - # Check if any requests remain - active = self._manager.get_active_count() - if active == 0: - break - - elapsed = asyncio.get_running_loop().time() - wait_start - if elapsed > max_wait: - print( - f"[AsyncClient] Warning: {active} requests still active after {max_wait}s timeout" - ) - break - await asyncio.sleep(0.1) # Give poll loops time to complete - - # Give any remaining poll loops a chance to complete - # This ensures all async coroutines have exited before manager destruction - await asyncio.sleep(0.2) - - # Manager cleanup is handled by Cython __dealloc__ + self._manager.cleanup() self._manager = None + if self._executor is not None: + self._executor.shutdown(wait=False) + self._executor = None self._loop = None + self._thread_clients = None # Architecture documentation __doc__ = """ -Async I/O Architecture (Phase B Complete) -========================================== +True Async I/O Architecture +============================ -Phase B Days 1-3: ✅ COMPLETE +httpmorph now provides TRUE async I/O using C-level integration: 1. I/O Engine (src/core/io_engine.c) - - epoll support for Linux (edge-triggered) - - kqueue support for macOS/BSD (one-shot) - - Platform-agnostic API - - Socket helpers (non-blocking, performance opts) - - Operation helpers (connect, recv, send) + - kqueue support for macOS/BSD + - epoll support for Linux + - Platform-agnostic API for socket readiness 2. Async Request State Machine (src/core/async_request.c) - 9-state machine: INIT → DNS → CONNECT → TLS → SEND → RECV_HEADERS → RECV_BODY → COMPLETE - Non-blocking at every stage - Proper SSL_WANT_READ/WANT_WRITE handling - - Timeout tracking - - Error handling - - Reference counting + - HTTP/2 support via nghttp2 3. Request Manager (src/core/async_request_manager.c) - Track multiple concurrent requests - Request ID generation - Event loop integration - - Thread-safe operations - -Phase B Days 4-5: ⏳ IN PROGRESS - -4. Python Asyncio Integration (this file) - - Cython bindings for async APIs - - Event loop integration (add_reader/add_writer) - - AsyncClient class (this file) - - Example applications - -Architecture Benefits: -- No thread pool overhead (currently 1-2ms per request) -- Support for 10,000+ concurrent connections -- Sub-millisecond async overhead -- Efficient resource usage (320KB per request vs 8MB per thread) -- Native event loop integration - -Performance Targets: -- Latency: 100-200μs overhead (vs 1-2ms with thread pool) -- Concurrency: 10K+ simultaneous requests (vs 100-200 with threads) -- Memory: 320KB per request (vs 8MB per thread) -- Throughput: 2-5x improvement over thread pool approach + +4. Python asyncio Integration (_async.pyx) + - Uses add_reader/add_writer for socket events + - Zero-copy response extraction + - Native Future integration + +Performance Characteristics: +- Latency: ~100-200μs overhead per request (vs 1-2ms with thread pool) +- Concurrency: 10K+ simultaneous requests possible +- Memory: ~320KB per request (vs 8MB per thread) +- True non-blocking I/O - no thread pool overhead """ diff --git a/src/httpmorph/_client_c.py b/src/httpmorph/_client_c.py index c3b7605..97960d3 100644 --- a/src/httpmorph/_client_c.py +++ b/src/httpmorph/_client_c.py @@ -629,6 +629,74 @@ def options(self, url, **kwargs): """Execute an OPTIONS request""" return self.request("OPTIONS", url, **kwargs) + def prewarm(self, host, port=0, use_tls=True, count=1): + """Pre-warm connections to a host for faster subsequent requests + + Establishes TCP/TLS connections proactively to eliminate connection + setup latency from subsequent requests. + + Args: + host: Target hostname to pre-warm connections to + port: Target port (0 = default: 443 for TLS, 80 for HTTP) + use_tls: Whether to establish TLS connections (default: True) + count: Number of connections to pre-warm (default: 1) + + Returns: + int: Number of connections successfully pre-warmed + + Example: + >>> client = Client() + >>> client.prewarm('api.example.com', count=3) + 3 + >>> # Subsequent requests will reuse pre-warmed connections + >>> client.get('https://api.example.com/endpoint') + """ + return self._client.prewarm(host, port, use_tls, count) + + def configure_pool(self, idle_timeout_seconds=0, max_connections_per_host=0, max_total_connections=0): + """Configure connection pool settings + + Args: + idle_timeout_seconds: Idle timeout before closing connections (default: 30s, 0 = keep default) + max_connections_per_host: Max connections per host (default: 6, 0 = keep default) + max_total_connections: Max total connections (default: 100, 0 = keep default) + + Example: + >>> client = Client() + >>> # Keep connections alive for 60 seconds + >>> client.configure_pool(idle_timeout_seconds=60) + >>> # Allow more concurrent connections + >>> client.configure_pool(max_connections_per_host=10, max_total_connections=200) + """ + return self._client.configure_pool(idle_timeout_seconds, max_connections_per_host, max_total_connections) + + def pool_stats(self): + """Get connection pool statistics + + Returns: + dict: Dictionary with 'total_connections' and 'active_connections' + + Example: + >>> client = Client() + >>> client.get('https://example.com') + >>> stats = client.pool_stats() + >>> print(f"Total: {stats['total_connections']}, Active: {stats['active_connections']}") + """ + return self._client.pool_stats() + + def cleanup_idle_connections(self): + """Clean up idle connections in the pool + + Removes connections that have been idle longer than the idle timeout. + Call this periodically for long-running applications to free resources. + + Example: + >>> client = Client() + >>> # ... use client for a while ... + >>> client.cleanup_idle_connections() + """ + return self._client.cleanup_idle_connections() + class CookieDict(dict): """Dict-like wrapper for cookie jar with Set-Cookie parsing""" @@ -851,6 +919,12 @@ def request(self, method, url, **kwargs): elif error_code != 0: raise RequestException(error_msg) + # Check for status_code=0 which indicates a failed request + # This can happen when the request fails silently (e.g., HTTP/2 stream error) + if result.get("status_code", 0) == 0: + error_msg = result.get("error_message") or "Request failed: no response received" + raise ConnectionError(error_msg) + response = Response(result, url=url) # Parse Set-Cookie headers from response