|
9 | 9 | PARENT_SPAN_KEY, |
10 | 10 | REQUEST_OPTIONS_KEY, |
11 | 11 | MiddlewarePipeline, |
| 12 | + RedirectHandler, |
12 | 13 | RetryHandler, |
13 | 14 | UserAgentHandler, |
14 | 15 | ) |
@@ -444,3 +445,175 @@ def request_handler(request: httpx.Request): |
444 | 445 | assert len(ua_spans) == 2 |
445 | 446 | for ua_span in ua_spans: |
446 | 447 | assert ua_span.parent.span_id == expected_parent_id |
| 448 | + |
| 449 | + |
| 450 | +def _status_sequence_transport(status_codes, methods): |
| 451 | + """Answers each request with the next status code and records its method.""" |
| 452 | + remaining = list(status_codes) |
| 453 | + |
| 454 | + def request_handler(request: httpx.Request): |
| 455 | + methods.append(request.method) |
| 456 | + return httpx.Response(remaining.pop(0) if remaining else 200) |
| 457 | + |
| 458 | + return httpx.MockTransport(request_handler) |
| 459 | + |
| 460 | + |
| 461 | +@pytest.fixture |
| 462 | +def no_retry_delay(monkeypatch): |
| 463 | + """Skips the wait between retries so the tests run quickly.""" |
| 464 | + monkeypatch.setattr(RetryHandler, "get_delay_time", lambda self, *args, **kwargs: 0) |
| 465 | + |
| 466 | + |
| 467 | +def test_retry_unprocessed_mutations_only_defaults_to_false(): |
| 468 | + assert not RetryHandlerOption().retry_unprocessed_mutations_only |
| 469 | + assert RetryHandlerOption( |
| 470 | + retry_unprocessed_mutations_only=True |
| 471 | + ).retry_unprocessed_mutations_only |
| 472 | + |
| 473 | + |
| 474 | +@pytest.mark.asyncio |
| 475 | +@pytest.mark.parametrize("method", ["POST", "PATCH"]) |
| 476 | +async def test_non_idempotent_request_retried_on_gateway_timeout_by_default(method, no_retry_delay): |
| 477 | + methods = [] |
| 478 | + handler = RetryHandler() |
| 479 | + request = httpx.Request(method, BASE_URL, json={"message": "hello"}) |
| 480 | + |
| 481 | + resp = await handler.send(request, _status_sequence_transport([GATEWAY_TIMEOUT, 200], methods)) |
| 482 | + |
| 483 | + assert resp.status_code == 200 |
| 484 | + assert len(methods) == 2 |
| 485 | + |
| 486 | + |
| 487 | +@pytest.mark.asyncio |
| 488 | +@pytest.mark.parametrize("method", ["POST", "PATCH", "post"]) |
| 489 | +async def test_non_idempotent_request_not_retried_on_gateway_timeout_when_enabled( |
| 490 | + method, no_retry_delay |
| 491 | +): |
| 492 | + methods = [] |
| 493 | + handler = RetryHandler(RetryHandlerOption(retry_unprocessed_mutations_only=True)) |
| 494 | + request = httpx.Request(method, BASE_URL, json={"message": "hello"}) |
| 495 | + |
| 496 | + resp = await handler.send(request, _status_sequence_transport([GATEWAY_TIMEOUT, 200], methods)) |
| 497 | + |
| 498 | + assert resp.status_code == GATEWAY_TIMEOUT |
| 499 | + assert len(methods) == 1 |
| 500 | + |
| 501 | + |
| 502 | +@pytest.mark.asyncio |
| 503 | +@pytest.mark.parametrize("method", ["POST", "PATCH"]) |
| 504 | +@pytest.mark.parametrize("status_code", [TOO_MANY_REQUESTS, SERVICE_UNAVAILABLE]) |
| 505 | +async def test_non_idempotent_request_retried_when_not_processed_and_enabled( |
| 506 | + method, status_code, no_retry_delay |
| 507 | +): |
| 508 | + methods = [] |
| 509 | + handler = RetryHandler(RetryHandlerOption(retry_unprocessed_mutations_only=True)) |
| 510 | + request = httpx.Request(method, BASE_URL, json={"message": "hello"}) |
| 511 | + |
| 512 | + resp = await handler.send(request, _status_sequence_transport([status_code, 200], methods)) |
| 513 | + |
| 514 | + assert resp.status_code == 200 |
| 515 | + assert len(methods) == 2 |
| 516 | + |
| 517 | + |
| 518 | +@pytest.mark.asyncio |
| 519 | +@pytest.mark.parametrize("method", ["GET", "PUT", "DELETE", "QUERY"]) |
| 520 | +async def test_idempotent_request_retried_on_gateway_timeout_when_enabled(method, no_retry_delay): |
| 521 | + methods = [] |
| 522 | + handler = RetryHandler(RetryHandlerOption(retry_unprocessed_mutations_only=True)) |
| 523 | + request = httpx.Request(method, BASE_URL) |
| 524 | + |
| 525 | + resp = await handler.send(request, _status_sequence_transport([GATEWAY_TIMEOUT, 200], methods)) |
| 526 | + |
| 527 | + assert resp.status_code == 200 |
| 528 | + assert len(methods) == 2 |
| 529 | + |
| 530 | + |
| 531 | +@pytest.mark.asyncio |
| 532 | +async def test_non_idempotent_request_stops_when_a_later_response_leaves_outcome_unknown( |
| 533 | + no_retry_delay |
| 534 | +): |
| 535 | + methods = [] |
| 536 | + handler = RetryHandler(RetryHandlerOption(retry_unprocessed_mutations_only=True)) |
| 537 | + request = httpx.Request("POST", BASE_URL, json={"message": "hello"}) |
| 538 | + |
| 539 | + resp = await handler.send( |
| 540 | + request, _status_sequence_transport([SERVICE_UNAVAILABLE, GATEWAY_TIMEOUT, 200], methods) |
| 541 | + ) |
| 542 | + |
| 543 | + assert resp.status_code == GATEWAY_TIMEOUT |
| 544 | + assert len(methods) == 2 |
| 545 | + |
| 546 | + |
| 547 | +@pytest.mark.asyncio |
| 548 | +async def test_retry_unprocessed_mutations_only_honoured_from_request_option(no_retry_delay): |
| 549 | + methods = [] |
| 550 | + handler = RetryHandler() |
| 551 | + request_option = RetryHandlerOption(retry_unprocessed_mutations_only=True) |
| 552 | + request = httpx.Request( |
| 553 | + "POST", |
| 554 | + BASE_URL, |
| 555 | + json={"message": "hello"}, |
| 556 | + extensions={REQUEST_OPTIONS_KEY: { |
| 557 | + RetryHandlerOption.get_key(): request_option |
| 558 | + }}, |
| 559 | + ) |
| 560 | + |
| 561 | + resp = await handler.send(request, _status_sequence_transport([GATEWAY_TIMEOUT, 200], methods)) |
| 562 | + |
| 563 | + assert resp.status_code == GATEWAY_TIMEOUT |
| 564 | + assert len(methods) == 1 |
| 565 | + |
| 566 | + |
| 567 | +def _redirect_then_status_transport(redirect_status, final_statuses, calls): |
| 568 | + """Redirects the first request to /redirected, then answers with each final status.""" |
| 569 | + remaining = list(final_statuses) |
| 570 | + |
| 571 | + def request_handler(request: httpx.Request): |
| 572 | + calls.append(request.method) |
| 573 | + if request.url.path != "/redirected": |
| 574 | + return httpx.Response(redirect_status, headers={"Location": f"{BASE_URL}/redirected"}) |
| 575 | + return httpx.Response(remaining.pop(0) if remaining else 200) |
| 576 | + |
| 577 | + return httpx.MockTransport(request_handler) |
| 578 | + |
| 579 | + |
| 580 | +@pytest.mark.asyncio |
| 581 | +async def test_post_redirected_to_get_is_retried_when_enabled(no_retry_delay): |
| 582 | + calls = [] |
| 583 | + pipeline = MiddlewarePipeline( |
| 584 | + _redirect_then_status_transport(303, [GATEWAY_TIMEOUT, 200], calls) |
| 585 | + ) |
| 586 | + pipeline.add_middleware(RedirectHandler()) |
| 587 | + pipeline.add_middleware(RetryHandler(RetryHandlerOption(retry_unprocessed_mutations_only=True))) |
| 588 | + request = httpx.Request("POST", f"{BASE_URL}/send", json={"message": "hello"}) |
| 589 | + |
| 590 | + resp = await pipeline.send(request) |
| 591 | + |
| 592 | + assert resp.status_code == 200 |
| 593 | + assert calls == ["POST", "GET", "GET"] |
| 594 | + |
| 595 | + |
| 596 | +@pytest.mark.asyncio |
| 597 | +async def test_post_redirected_as_post_is_not_retried_with_request_option(no_retry_delay): |
| 598 | + calls = [] |
| 599 | + pipeline = MiddlewarePipeline( |
| 600 | + _redirect_then_status_transport(307, [GATEWAY_TIMEOUT, 200], calls) |
| 601 | + ) |
| 602 | + pipeline.add_middleware(RedirectHandler()) |
| 603 | + pipeline.add_middleware(RetryHandler()) |
| 604 | + request = httpx.Request( |
| 605 | + "POST", |
| 606 | + f"{BASE_URL}/send", |
| 607 | + json={"message": "hello"}, |
| 608 | + extensions={ |
| 609 | + REQUEST_OPTIONS_KEY: { |
| 610 | + RetryHandlerOption.get_key(): |
| 611 | + RetryHandlerOption(retry_unprocessed_mutations_only=True) |
| 612 | + } |
| 613 | + }, |
| 614 | + ) |
| 615 | + |
| 616 | + resp = await pipeline.send(request) |
| 617 | + |
| 618 | + assert resp.status_code == GATEWAY_TIMEOUT |
| 619 | + assert calls == ["POST", "POST"] |
0 commit comments