Repository: planetlabs/planet-client-python
Pull request #756 (2022-11-10 19:02:51)

Documentation updates to the Getting Started Experience (GSX)
**Related Issue(s):**

Progress on #539

**Proposed Changes:**

For inclusion in changelog (if applicable):

1. Updated docs/get-started/quick-start-guide.md with steps to search for imagery
2. Updated docs/get-started/venv-tutorial.md, including linking back to docs/get-started/quick-start-guide.md

Not intended for changelog:

1. Additional fixes between these Getting Started Experience (GSX) files in the docs/ folder.

**Diff of User Interface**

NONE


**PR Checklist:**

- [x] This PR is as small and focused as possible
- [x] If this PR includes proposed changes for inclusion in the changelog, the title of this PR summarizes those changes and is ready for inclusion in the Changelog.
- [x] I have updated docstrings for function changes and docs in the 'docs' folder for user interface / behavior changes
- [x] This PR does not break any examples or I have updated them

**(Optional) @mentions for Notifications:**

@cholmes 



Patch:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index a8d7256..2e626e7 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -8,13 +8,13 @@ assignees: ''
 ---
 
 **Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
 
 **Describe the solution you'd like**
-A clear and concise description of what you want to happen.
+
 
 **Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
+
 
 **Additional context**
-Add any other context or screenshots about the feature request here.
+
diff --git a/CHANGES.txt b/CHANGES.txt
index 937cb8a..e2ba724 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,3 +1,9 @@
+2.0a6 (2022-11-10)
+
+- Add SDK identification headers to communication with servers (#754).
+- Add new example (example subfolder): Downloading multiple assets in
+  parallel (#722).
+
 2.0a5 (2022-10-20)
 
 - Implement python api data client asset download and supporting functions (#707).
diff --git a/RELEASE.md b/RELEASE.md
index 0734e04..52d6b6d 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -9,8 +9,8 @@ Releasing is a two-step process: (1) releasing on Github and test.pypi and (2) r
 The following are the release naming conventions:
 
 1. Current Dev Version is obtained from `planet/__version__.py`
-1. Release Version: Remove `dev` from Current Dev Version
-1. Next Dev Version: Bumped version of last release with `dev` added to the end.
+3. Release Version: Remove `dev` from Current Dev Version
+4. Next Dev Version: Bumped version of last release with `dev` added to the end.
     * **PROPOSAL**: Version number is determined by [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
 
 
@@ -30,7 +30,7 @@ The release on Github and PyPi performed from a release branch while the release
 1. Make the following changes for the release
   * Update `CHANGES.txt` (**PROPOSAL**: change this to `docs/CHANGELOG.md`)
     * Include added, changed, depricated or removed features and bug fixes.
-       A list of merged PRs and their titles since the last release can be obtained with `git log <RELEASE_TAG>..HEAD | awk '/Merge pull request/{print;getline;getline;print}`
+       A list of merged PRs and their titles since the last release can be obtained with `git log <LAST_RELEASE_TAG>..HEAD | awk '/Merge pull request/{print;getline;getline;print}`.
     * Sort according to importance
     * **PROPOSAL**: Adhere to [Keep a Changelog](https://keepachangelog.com/)
   * Update `planet/__version__.py` to Release Version
@@ -38,9 +38,9 @@ The release on Github and PyPi performed from a release branch while the release
 1. Create a new github release:
   * Set Tag Release Version
   * **!!!** Set Target to the release branch **!!!**
+  * Set Title to Tag Release Version
   * Copy Description from the new entry in `docs/CHANGELOG.md`
   * Select "This is a pre-release" if applicable
-  * Select "Create a discussion for this release"
 1. Verify the successful run of the Github Action "Autopublish to TestPyPi" and validate the test release on [test.pypi.org](https://test.pypi.org/project/planet/)
 1. Run the Github Action "Publish on PyPi", **!!!** Set Branch to the release branch **!!!**
 1. Verify the successful run of the Github Action "Publish on PyPi" and validate the release on [pypi.org](https://pypi.org/project/planet/)
diff --git a/docs/cli/cli-tutorial.md b/docs/cli/cli-tutorial.md
index cbce0d8..cd0b4b9 100644
--- a/docs/cli/cli-tutorial.md
+++ b/docs/cli/cli-tutorial.md
@@ -19,7 +19,7 @@ We start with the `auth` package of tools, to ensure you can get started.
 #### Initialize
 
 The main way to initialize the Planet CLI is to use `init`, which will prompt
-you for your Planet user name and password, and then store your API key.
+you for your Planet username and password, and then store your API key.
 
 ```console
 planet auth init
diff --git a/docs/get-started/get-your-planet-account.md b/docs/get-started/get-your-planet-account.md
index ca1f55f..b7ccd00 100644
--- a/docs/get-started/get-your-planet-account.md
+++ b/docs/get-started/get-your-planet-account.md
@@ -2,13 +2,13 @@
 title: Get Your Planet Account
 ---
 
-Whenever you use the Planet SDK for Python to connect to Planet's APIs, you’ll need to authenticate with the Planet server. To do so, you need a Planet account with your own user name and password.
+Whenever you use the Planet SDK for Python to connect to Planet's APIs, you’ll need to authenticate with the Planet server. To do so, you need a Planet account with your own username and password.
 
 ## Confirm your Planet account
 
 ### Sign on to Planet Explorer
 
-If you go to <a href="https://www.planet.com/explorer/#" target="_blank" >Planet Explorer</a>, and you have an account, you’ll be prompted to enter your account user name and password. That’s the same user name and password you’ll use to authenticate with the Planet servers, here.
+If you go to <a href="https://www.planet.com/explorer/#" target="_blank" >Planet Explorer</a>, and you have an account, you’ll be prompted to enter your account username and password. That’s the same username and password you’ll use to authenticate with the Planet servers, here.
 
 Once in Explorer, you can select the user icon in the upper right corner to go to your Account page:
 
@@ -36,5 +36,5 @@ After you’ve installed the Planet SDK, you can authenticate with the Planet se
 
 ## Next steps
 
-Now that you have confirmed your Planet account user name and password, you can take the other steps in the [Quick Start](quick-start-guide). 
+Now that you have confirmed your Planet account username and password, you can take the other steps in the [Quick Start](quick-start-guide). 
 
diff --git a/docs/get-started/quick-start-guide.md b/docs/get-started/quick-start-guide.md
index 115bd46..28e64a5 100644
--- a/docs/get-started/quick-start-guide.md
+++ b/docs/get-started/quick-start-guide.md
@@ -1,7 +1,8 @@
 ---
-title: Quick Start
+title: Quick Start Guide
 ---
 
+
 If you’re a Python developer, this Planet SDK for Python makes it easy to access Planet’s massive repository of satellite imagery and add Planet data to your data ops workflow.
 
 If you’re not a Python developer, you can use the Command Line Interface (CLI) to get Planet data, and to process and analyze that data.
@@ -12,9 +13,9 @@ Take the following steps to install the SDK and connect with the Planet Server.
 
 ## Step 1: Install Python 3.7+ and a virtual environment
 
-This is a Python package, so you’ll need to install Python (version 3.7 or greater), and setup and install a virtual environment.
+This is a Python package, so you’ll need to install Python (version 3.7 or greater), and set up and install a virtual environment.
 
-Yes. Even if you’re not writing code—and only using the "no code" CLI part of the Planet SDK for Python—you’re using Python to communicate with the Planet Labs PBC servers. It’s not too tricky, but it does require a presence of mind to complete. If you need help with Python install and setting up a virtual environment, read [Get started with venv & Planet SDK](venv-tutorial.md).
+Yes. Even if you’re not writing code—and only using the "no code" CLI part of the Planet SDK for Python—you’re using Python to communicate with the Planet Labs PBC servers. It’s not too tricky, but it does require a presence of mind to complete. If you need help with Python install and setting up a virtual environment, read [Virtual Environments and the Planet SDK for Python](venv-tutorial.md).
 
 ## Step 2: Install the Planet SDK for Python
 
@@ -38,7 +39,7 @@ You should be on some version 2 of the Planet SDK for Python.
 
 Planet SDK for Python, like the Planet APIs, requires an account for use.
 
-### Have your Planet account user name and password ready
+### Have your Planet account username and password ready
 
 To confirm your Planet account, or to get one if you don’t already have one, see [Get your Planet Account](get-your-planet-account.md).
 
@@ -60,7 +61,7 @@ Initialized
 
 ### Get your API key
 
-Now that you've logged in, you can easily retrieve your API key that is being used for requests with the following command:
+Now that you’ve logged in, you can easily retrieve your API key that is being used for requests with the following command:
 
 ```console
 planet auth value
@@ -68,76 +69,62 @@ planet auth value
 
 Many `planet` calls you make require an API key. This is a very convenient way to quickly grab your API key.
 
-## Step 5: Creating your first order
-
-[*****TBW*****]
-
-The Planet SDK for Python allows Python developers to write software that makes
-use of the following Planet APIs:
-
-* [orders](https://developers.planet.com/docs/orders/)
-* [data](https://developers.planet.com/docs/data/)
-* [subscriptions](https://developers.planet.com/docs/subscriptions/)
-* [basemaps](https://developers.planet.com/docs/basemaps/), [tasking](https://developers.planet.com/docs/tasking/) and [analytics](https://developers.planet.com/docs/analytics/) API's are not yet implemented.
-
-The client modules within the Python library are asynchronous, which greatly
-speeds up many interactions with Planet’s APIs. Support for asynchronous
-development is native to Python 3.6+ via the
-[`asyncio` module](https://docs.python.org/3/library/asyncio.html). A great
-resource for getting started with asynchronous programming in Python is
-https://project-awesome.org/timofurrer/awesome-asyncio. The Writings and Talks
-sections are particularly helpful in getting oriented.
-
-Let’s start with creating an order with the Orders API:
-
-```python
->>> import asyncio
->>> import os
->>> import planet
->>>
->>> request = {
-...   "name": "test_order",
-...   "products": [
-...     {
-...       "item_ids": [
-...         "3949357_1454705_2020-12-01_241c"
-...       ],
-...       "item_type": "PSOrthoTile",
-...       "product_bundle": "analytic"
-...     }
-...   ]
-... }
-...
->>> async def create_order(request):
-...     async with planet.Session() as ps:
-...         client = planet.OrdersClient(ps)
-...         return await client.create_order(request)
-...
->>> oid = asyncio.run(create_order(request))
+#### Your API Key as an Environment Variable
+
+You can also set the value of your API Key as an environment variable in your terminal at the command line:
+
+```console
+export PL_API_KEY=<your api key>
+```
+
+And you can see that the value was stored successfully as an environment variable with the following command:
+
+```console
+echo $PL_API_KEY
+```
+
+!!!note "The API Key environment variable is always used first in the Planet SDK"
+    If you do create a `PL_API_KEY` environment variable, the SDK will use this value. `PL_API_KEY` overrides the value that was retrieved using your Planet login with a call to `planet auth init`. The `planet auth value` call currently does not reflect that `PL_API_KEY` overrides the `auth init` value (this should be fixed in 2.0-beta.1 with [issue 643](https://github.com/planetlabs/planet-client-python/issues/643))
+
+## Step 5: Search for Planet Imagery
+
+You’ve installed the environment, the SDK, and connected with the Planet server. You’re now ready to get your first bunch of data.
 
+In this step, you search for the most recent PSScene images available to download and filter the list based on those images you actually have permissions to download.
+
+### planet data filter
+
+One of the commands you’ll use most frequently is `planet data filter`. This “convenience method” creates the JSON you need to run other commands. Run it with no arguments to see how it works by default:
+
+```console
+planet data filter
 ```
 
-Not into async? No problem. Just wrap the library and async operations together
-and call from your synchronous code.
+Look at the console output to see some default filters. `PermissionFilter` filters the output to only contain imagery that you have permission to download. You’ll also see `quality_category`, which means the output lists only images in the [`standard quality` category](https://developers.planet.com/docs/data/planetscope/#image-quality-standard-vs-test-imagery). 
+
+!!!note "The --help switch is your friend"
+    You can do a lot with this `filter` command. We recommend running `planet data filter --help` often to get a reference of how the commands work.
+
+### planet data search
 
-```python
->>> def sync_create_order(order_details):
-...     return asyncio.run(create_order(order_details))
->>>
->>> oid = sync_create_order(order_details)
+Run the filter command and save it to a file named `filter.json`:
 
+```console
+planet data filter > filter.json
+```
+
+Then use that file with the search command and save the results to another file named `recent-psscene.json`.
+
+```console
+planet data search PSScene filter.json > recent-psscene.json
 ```
- 
-When using `asyncio.run` to develop synchronous code with the async library,
-keep in mind this excerpt from the
-[asyncio.run](https://docs.python.org/3/library/asyncio-task.html#asyncio.run)
-documentation:
 
-"*This function always creates a new event loop and closes it at the end. It
-should be used as a main entry point for asyncio programs, and should ideally
-only be called once.*"
+Open `recent-psscene.json` to see the 100 most recent PSScene images you have permissions to actually download.
+
+## Next steps
 
-Do you have a use case where native synchronous support is essential? If so,
-please contribute to this
-[issue](https://github.com/planetlabs/planet-client-python/issues/251).
+Now that you have the quick setup for the Planet SDK for Python, you have a few options:
 
+* Continue to explore the [No-Code CLI Guide](../cli/cli-guide.md).
+* Start coding with the [Python SDK User Guide](../python/sdk-guide.md).
+* Check out some of the [examples in our GitHub repo](https://github.com/planetlabs/planet-client-python/tree/main/examples).
diff --git a/docs/get-started/venv-tutorial.md b/docs/get-started/venv-tutorial.md
index 3ae893c..56717c1 100644
--- a/docs/get-started/venv-tutorial.md
+++ b/docs/get-started/venv-tutorial.md
@@ -1,5 +1,5 @@
 ---
-title: Get started with venv & the Planet SDK for Python
+title: Virtual Environments and the Planet SDK for Python
 ---
 
 This tutorial is aimed at Planet SDK for Python users who have little or no experience with virtual environments, especially those who want to be early users of v2 of the Planet SDK for Python (and still use v1 while evaluating v2).
@@ -8,34 +8,34 @@ A virtual environment is a space you create to pull in the resources you need fo
 
 Using the default Python installation without a virtual environment is not recommended, because it’s so easy to get a combination of tools installed that don’t work well with each other. The great thing about installing a virtual environment is that if you get a combination of tools that don’t work well together, you can “throw away” that virtual environment and create a new one with a few lines in the terminal.
 
-So while it may feel awkward at first, spinning up virtual environments will be your go-to setup step for trying out a new idea, testing a routine, or repeating a process at scale.
+So while it may feel inconvenient at first, spinning up virtual environments will be your go-to setup step for trying out a new idea, testing a routine, or repeating a process at scale.
 
 ## Conda vs venv
 
 There are two main options in the Python world for virtual environments:
 
 * [venv](https://docs.python.org/3/library/venv.html)
-* [conda](https://conda.io)
+* [Conda](https://conda.io)
 
-Both are great options. Venv is built into python (version 3.3 and above), so if you've got python
-then it's already there. Conda does a bit 'more', as venv just works for python, while conda
-creates virtual environments for all libraries and languages (but was designed for python). We
+Both are great options. venv is built into Python (version 3.3 and above), so if you’ve got Python
+then it’s already there. Conda does a bit ‘more,’ as venv just works for Python, while Conda
+creates virtual environments for all libraries and languages (but was designed for Python). We
 recommend venv, as [GDAL](https://gdal.org/), arguably the most useful geospatial command-lines,
-can sometimes be a pain with Conda. And our releases are not (yet) targeting conda packaging.
+can sometimes be a pain with Conda. And our releases are not (yet) targeting Conda packaging.
 
-## venv pre-requisites
+## venv prerequisites
 
-The main pre-requisite for venv is that you have Python 3.3 and above. To check your python
+The main prerequisite for venv is that you have Python 3.3 and above. To check your Python
 version type:
 
 ```console
  $ python --version
 ```
 
- If you installed on Windows or Linux you most likely have python 3. Mac OS ships by default with
- python 2, so you'll need to install python 3. 
+ If you installed on Windows or Linux you most likely have Python 3. Mac OS ships by default with
+ Python 2, so you’ll need to install Python 3. 
 
-### Installing Python 3 on a Mac.
+### Installing Python 3 on a Mac
 
  Our recommended route is to install [homebrew](https://brew.sh/). To do that you just run the 
  following on your command-line:
@@ -50,71 +50,103 @@ After it download and installs you just run:
  $ brew install python
 ```
 
- That will set up a command called `python3` that you can use to call python3.
+That will set up a command called `python3` that you can use to call Python 3.
 
-## Setting your venvs
+## Setting your virtual environments
 
-It's good to read up on how venv works, we recommend 
+It’s good to read up on how venv works, we recommend 
 [this tutorial](https://www.dataquest.io/blog/a-complete-guide-to-python-virtual-environments/)
 to get a solid understanding.
 
-To be able to work with both the v1 stable and v2 pre-release we recommend setting up 2 virtual enviroments.
-A good place to put them is in a .venv directory.
+To be able to work with both the v1 stable and v2 pre-release we recommend setting up 2 virtual environments. A good place to put them is in a .venv directory. The following commands creates a `.venv` directory at your root, user home directory and then switches into that new directory.
 
 ```console
  $ mkdir ~/.venv
  $ cd ~/.venv
 ```
 
- Then you'll want to create a venv for v1 and v2. First create the
- v1 virtual environment with:
+Then you’ll want to create a venv for v1 and v2.
+
+### Install a virtual environment for version 1 of the SDK
+
+#### Install and activate version 1
+
+First create the v1 virtual environment with:
 
 ```console
   $ python3 -m venv ~/.venv/planet-v1
 ```
 
- Then you can activate on a mac or linux with:
+Then activate the virtual environment. On a Mac or Linux with, you can do so with the following command:
 
 ```console
  $ source ~/.venv/planet-v1/bin/activate
 ```
 
-and on windows with:
+Or, on Windows with:
 
 ```console
   $ planet-v1\Scripts\activate.bat
 ```
 
-Then you'll be in your new virtual environment. If you already have v1 installed
-it will likely work. But you can install it with:
+If you haven’t installed version 1, you can do so with:
 
 ```console
   $ pip install planet
 ```
 
-Run `planet --version` to check which version it is. If you don't see a v1 then you may
-need to adjust your path (ask in [discussions](https://github.com/planetlabs/planet-client-python/discussions)
-if you're having trouble, and we can add more info here).
+If you already have v1 of the Planet SDK installed, you can check to see if it’s recognized in your new virtual environment by making a command call to get the version number.
+
+Check the version again to confirm it was installed:
+
+```console
+ $ planet --version
+```
+
+You should see a number that begins with 1.
+
+!!!note "If a version 1 doesn’t appear"
+    If you don’t see a version 1 number, then you may need to adjust your path. If you have trouble getting the version for the Planet SDK v1 that you know you’ve installed, feel free to [discuss with us](https://github.com/planetlabs/planet-client-python/discussions). We can troubleshoot together and then update the steps here.
+
+#### Deactivate version 1
+
+As we mentioned, the point to creating virtual environments is to have a place to install the right packages together. This virtual environment is for version 1 of the SDK. For version 2, we’ll need to set up a separate virtual environment. To do so, we must first deactivate the version 1 virtual environment.
 
-Then you'll want to deactivate, and set up v2:
+Deactivate the virtual environment you’ve activated with a call to `deactivate`:
 
 ```console
  $ deactivate
 ```
 
- Takes you out of the virtual environment, to your base install.
+This call takes you out of the virtual environment, back to only your base install.
 
- Then set up v2, like you did v1:
+### Install a virtual environment for version 2 of the SDK
+
+#### Install and activate version 2
+
+Set up a virtual environment for version 2 with the following command line Python call:
 
 ```console
  $ python3 -m venv ~/.venv/planet-v2
 ```
 
-And activate the same way. Then install as instructed in [the readme](../README.md#installation).
+Then activate the virtual environment. On a Mac or Linux with, you can do so with the following command:
+
+```console
+ $ source ~/.venv/planet-v2/bin/activate
+```
+
+Or, on Windows with:
+
+```console
+  $ planet-v2\Scripts\activate.bat
+```
+
+Now that your `planet-v2` virtual environment is activated, it’s time to install the Planet SDK for Python! Find instructions on how to do so in the [Quick Start Guide](quick-start-guide.md).
 
 ## Working with virtual environments
 
-It's easy to switch in and out of the virtual environments, just `activate` and `deactivate`. You can 
+It’s easy to switch in and out of the virtual environments, just `activate` and `deactivate`. You can 
 install additional tools with pip on each. The core directories and files remain the same, so you can 
 use both v1 and v2 in the same directory. You can even work with them side by side, just open two 
 different terminal windows, and activate v1 in one, and v2 in the other.
@@ -125,6 +157,5 @@ You should see a different command-line prompt for each, to easily tell which on
  $ (planet-v1) user@computer ~ $
  ```
 
-Note that non-python programs (like from node, or C/C++) will be available in both environments. If you want
-to manage those in a virtual environment then Conda is the way to go, or even full on [docker](https://www.docker.com/)
-containers.
+!!!note "This is a Python-specific virtual environment"
+    Note that non-Python programs (like from node, or C/C++) will be available in both environments. If you want to manage those in a virtual environment then Conda is the way to go, or even full on [docker](https://www.docker.com/) containers.
diff --git a/planet/__version__.py b/planet/__version__.py
index 469175e..b1f5927 100644
--- a/planet/__version__.py
+++ b/planet/__version__.py
@@ -1,1 +1,1 @@
-__version__ = '2.0a6dev'
+__version__ = '2.0a7dev'
diff --git a/planet/auth.py b/planet/auth.py
index a7b5d51..5a64ef3 100644
--- a/planet/auth.py
+++ b/planet/auth.py
@@ -24,7 +24,7 @@ import typing
 import httpx
 import jwt
 
-from . import http, models
+from . import http
 from .constants import PLANET_BASE_URL, SECRET_FILE_PATH
 from .exceptions import AuthException
 from typing import Optional
@@ -177,10 +177,8 @@ class AuthClient:
         data = {'email': email, 'password': password}
 
         sess = http.AuthSession()
-        req = models.Request(url, method='POST', json=data)
-        resp = sess.request(req)
-        auth_data = self.decode_response(resp)
-        return auth_data
+        resp = sess.request(url=url, method='POST', json=data)
+        return self.decode_response(resp)
 
     @staticmethod
     def decode_response(response):
diff --git a/planet/clients/data.py b/planet/clients/data.py
index a94576b..fdba667 100644
--- a/planet/clients/data.py
+++ b/planet/clients/data.py
@@ -22,7 +22,7 @@ from typing import Any, AsyncIterator, Callable, Dict, List, Optional
 from .. import exceptions
 from ..constants import PLANET_BASE_URL
 from ..http import Session
-from ..models import Paged, Request, Response, StreamingBody
+from ..models import Paged, StreamingBody
 
 BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
 SEARCHES_PATH = '/searches'
@@ -93,17 +93,6 @@ class DataClient:
     def _item_url(self, item_type, item_id):
         return f'{self._base_url}/item-types/{item_type}/items/{item_id}'
 
-    def _request(self, url, method, data=None, params=None, json=None):
-        return Request(url, method=method, data=data, params=params, json=json)
-
-    async def _do_request(self, request: Request) -> Response:
-        """Submit a request and get response.
-
-        Parameters:
-            request: request to submit
-        """
-        return await self._session.request(request)
-
     async def search(self,
                      item_types: List[str],
                      search_filter: dict,
@@ -185,11 +174,11 @@ class DataClient:
                     f'{sort} must be one of {SEARCH_SORT}')
             params['_sort'] = sort
 
-        request = self._request(url,
-                                method='POST',
-                                json=request_json,
-                                params=params)
-        return Items(request, self._do_request, limit=limit)
+        response = await self._session.request(method='POST',
+                                               url=url,
+                                               json=request_json,
+                                               params=params)
+        return Items(response, self._session.request, limit=limit)
 
     async def create_search(self,
                             name: str,
@@ -229,15 +218,16 @@ class DataClient:
         url = self._searches_url()
 
         # TODO: validate item_types
-        request_json = {
+        request = {
             'name': name,
             'filter': search_filter,
             'item_types': item_types,
             '__daily_email_enabled': enable_email
         }
 
-        request = self._request(url, method='POST', json=request_json)
-        response = await self._do_request(request)
+        response = await self._session.request(method='POST',
+                                               url=url,
+                                               json=request)
         return response.json()
 
     async def update_search(self,
@@ -260,15 +250,16 @@ class DataClient:
         """
         url = f'{self._searches_url()}/{search_id}'
 
-        request_json = {
+        request = {
             'name': name,
             'filter': search_filter,
             'item_types': item_types,
             '__daily_email_enabled': enable_email
         }
 
-        request = self._request(url, method='PUT', json=request_json)
-        response = await self._do_request(request)
+        response = await self._session.request(method='PUT',
+                                               url=url,
+                                               json=request)
         return response.json()
 
     async def list_searches(self,
@@ -307,8 +298,9 @@ class DataClient:
                 f'{search_type} must be one of {LIST_SEARCH_TYPE}')
 
         url = f'{self._searches_url()}'
-        request = self._request(url, method='GET')
-        return Searches(request, self._do_request, limit=limit)
+
+        response = await self._session.request(method='GET', url=url)
+        return Searches(response, self._session.request, limit=limit)
 
     async def delete_search(self, search_id: str):
         """Delete an existing saved search.
@@ -321,8 +313,7 @@ class DataClient:
         """
         url = f'{self._searches_url()}/{search_id}'
 
-        request = self._request(url, method='DELETE')
-        await self._do_request(request)
+        await self._session.request(method='DELETE', url=url)
 
     async def get_search(self, search_id: str) -> dict:
         """Get a saved search by id.
@@ -337,9 +328,9 @@ class DataClient:
             planet.exceptions.APIError: On API error.
         """
         url = f'{self._searches_url()}/{search_id}'
-        req = self._request(url, method='GET')
-        resp = await self._do_request(req)
-        return resp.json()
+
+        response = await self._session.request(method='GET', url=url)
+        return response.json()
 
     async def run_search(self,
                          search_id: str,
@@ -359,8 +350,8 @@ class DataClient:
         """
         url = f'{self._searches_url()}/{search_id}/results'
 
-        request = self._request(url, method='GET')
-        return Items(request, self._do_request, limit=limit)
+        response = await self._session.request(method='GET', url=url)
+        return Items(response, self._session.request, limit=limit)
 
     async def get_stats(self,
                         item_types: List[str],
@@ -388,14 +379,15 @@ class DataClient:
 
         url = f'{self._base_url}{STATS_PATH}'
 
-        request_json = {
+        request = {
             'interval': interval,
             'filter': search_filter,
             'item_types': item_types
         }
 
-        request = self._request(url, method='POST', json=request_json)
-        response = await self._do_request(request)
+        response = await self._session.request(method='POST',
+                                               url=url,
+                                               json=request)
         return response.json()
 
     async def list_asset_types(self) -> List[dict]:
@@ -500,8 +492,8 @@ class DataClient:
             planet.exceptions.APIError: On API error.
         """
         url = f'{self._item_url(item_type_id, item_id)}/assets'
-        request = self._request(url, method='GET')
-        response = await self._do_request(request)
+
+        response = await self._session.request(method='GET', url=url)
         return response.json()
 
     async def get_asset(self,
@@ -558,9 +550,8 @@ class DataClient:
 
         # lets not try to activate an asset already activating or active
         if status == 'inactive':
-            request = self._request(url, method='GET')
             # no response is returned
-            await self._do_request(request)
+            await self._session.request(method='GET', url=url)
 
         return
 
@@ -623,8 +614,7 @@ class DataClient:
                 raise exceptions.ClientError(
                     'asset missing ["_links"]["_self"] entry.')
 
-            request = self._request(asset_url, method='GET')
-            response = await self._do_request(request)
+            response = await self._session.request(method='GET', url=asset_url)
             asset = response.json()
 
         if max_attempts and num_attempts >= max_attempts:
@@ -669,9 +659,7 @@ class DataClient:
             raise exceptions.ClientError(
                 'asset missing ["location"] entry. Is asset active?')
 
-        req = self._request(location, method='GET')
-
-        async with self._session.stream(req) as resp:
+        async with self._session.stream(method='GET', url=location) as resp:
             body = StreamingBody(resp)
             dl_path = Path(directory, filename or body.name)
             dl_path.parent.mkdir(exist_ok=True, parents=True)
diff --git a/planet/clients/orders.py b/planet/clients/orders.py
index b37787d..e405046 100644
--- a/planet/clients/orders.py
+++ b/planet/clients/orders.py
@@ -25,7 +25,7 @@ from pathlib import Path
 from .. import exceptions
 from ..constants import PLANET_BASE_URL
 from ..http import Session
-from ..models import Paged, Request, Response, StreamingBody
+from ..models import Paged, StreamingBody
 
 BASE_URL = f'{PLANET_BASE_URL}/compute/ops'
 STATS_PATH = '/stats/orders/v2'
@@ -112,17 +112,6 @@ class OrdersClient:
     def _stats_url(self):
         return f'{self._base_url}{STATS_PATH}'
 
-    def _request(self, url, method, data=None, params=None, json=None):
-        return Request(url, method=method, data=data, params=params, json=json)
-
-    async def _do_request(self, request: Request) -> Response:
-        '''Submit a request and get response.
-
-        Parameters:
-            request: request to submit
-        '''
-        return await self._session.request(request)
-
     async def create_order(self, request: dict) -> dict:
         '''Create an order request.
 
@@ -156,10 +145,10 @@ class OrdersClient:
             planet.exceptions.APIError: On API error.
         '''
         url = self._orders_url()
-
-        req = self._request(url, method='POST', json=request)
-        resp = await self._do_request(req)
-        return resp.json()
+        response = await self._session.request(method='POST',
+                                               url=url,
+                                               json=request)
+        return response.json()
 
     async def get_order(self, order_id: str) -> dict:
         '''Get order details by Order ID.
@@ -177,9 +166,8 @@ class OrdersClient:
         self._check_order_id(order_id)
         url = f'{self._orders_url()}/{order_id}'
 
-        req = self._request(url, method='GET')
-        resp = await self._do_request(req)
-        return resp.json()
+        response = await self._session.request(method='GET', url=url)
+        return response.json()
 
     async def cancel_order(self, order_id: str) -> dict:
         '''Cancel a queued order.
@@ -197,9 +185,8 @@ class OrdersClient:
         self._check_order_id(order_id)
         url = f'{self._orders_url()}/{order_id}'
 
-        req = self._request(url, method='PUT')
-        resp = await self._do_request(req)
-        return resp.json()
+        response = await self._session.request(method='PUT', url=url)
+        return response.json()
 
     async def cancel_orders(self,
                             order_ids: Optional[List[str]] = None) -> dict:
@@ -224,9 +211,11 @@ class OrdersClient:
                 self._check_order_id(oid)
             cancel_body['order_ids'] = order_ids
 
-        req = self._request(url, method='POST', json=cancel_body)
-        resp = await self._do_request(req)
-        return resp.json()
+        response = await self._session.request(method='POST',
+                                               url=url,
+                                               json=cancel_body)
+
+        return response.json()
 
     async def aggregated_order_stats(self) -> dict:
         '''Get aggregated counts of active orders.
@@ -238,9 +227,8 @@ class OrdersClient:
             planet.exceptions.APIError: On API error.
         '''
         url = self._stats_url()
-        req = self._request(url, method='GET')
-        resp = await self._do_request(req)
-        return resp.json()
+        response = await self._session.request(method='GET', url=url)
+        return response.json()
 
     async def download_asset(self,
                              location: str,
@@ -264,9 +252,7 @@ class OrdersClient:
         Raises:
             planet.exceptions.APIError: On API error.
         """
-        req = self._request(location, method='GET')
-
-        async with self._session.stream(req) as resp:
+        async with self._session.stream(method='GET', url=location) as resp:
             body = StreamingBody(resp)
             dl_path = Path(directory, filename or body.name)
             dl_path.parent.mkdir(exist_ok=True, parents=True)
@@ -505,5 +491,7 @@ class OrdersClient:
         else:
             params = None
 
-        request = self._request(url, 'GET', params=params)
-        return Orders(request, self._do_request, limit=limit)
+        response = await self._session.request(method='GET',
+                                               url=url,
+                                               params=params)
+        return Orders(response, self._session.request, limit=limit)
diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py
index a583a7c..888abfc 100644
--- a/planet/clients/subscriptions.py
+++ b/planet/clients/subscriptions.py
@@ -5,7 +5,7 @@ from typing import AsyncIterator, Optional, Set
 
 from planet.exceptions import APIError, ClientError
 from planet.http import Session
-from planet.models import Paged, Request
+from planet.models import Paged
 
 LOGGER = logging.getLogger()
 
@@ -29,7 +29,7 @@ class SubscriptionsClient:
     """
 
     def __init__(self, session: Session) -> None:
-        self.session = session
+        self._session = session
 
     async def list_subscriptions(self,
                                  status: Optional[Set[str]] = None,
@@ -63,11 +63,13 @@ class SubscriptionsClient:
 
         params = {'status': [val for val in status or {}]}
         url = 'https://api.planet.com/subscriptions/v1'
-        req = Request(url, params=params)
 
         try:
-            async for sub in _SubscriptionsPager(req,
-                                                 self.session.request,
+            response = await self._session.request(method='GET',
+                                                   url=url,
+                                                   params=params)
+            async for sub in _SubscriptionsPager(response,
+                                                 self._session.request,
                                                  limit=limit):
                 yield sub
         # Forward APIError. We don't strictly need this clause, but it
@@ -92,10 +94,11 @@ class SubscriptionsClient:
         """
 
         url = 'https://api.planet.com/subscriptions/v1'
-        req = Request(url, method='POST', json=request)
 
         try:
-            resp = await self.session.request(req)
+            resp = await self._session.request(method='POST',
+                                               url=url,
+                                               json=request)
         # Forward APIError. We don't strictly need this clause, but it
         # makes our intent clear.
         except APIError:
@@ -121,10 +124,9 @@ class SubscriptionsClient:
         """
         root_url = 'https://api.planet.com/subscriptions/v1'
         url = f'{root_url}/{subscription_id}/cancel'
-        req = Request(url, method='POST')
 
         try:
-            _ = await self.session.request(req)
+            _ = await self._session.request(method='POST', url=url)
         # Forward APIError. We don't strictly need this clause, but it
         # makes our intent clear.
         except APIError:
@@ -148,10 +150,11 @@ class SubscriptionsClient:
             ClientError: on a client error.
         """
         url = f'https://api.planet.com/subscriptions/v1/{subscription_id}'
-        req = Request(url, method='PUT', json=request)
 
         try:
-            resp = await self.session.request(req)
+            resp = await self._session.request(method='PUT',
+                                               url=url,
+                                               json=request)
         # Forward APIError. We don't strictly need this clause, but it
         # makes our intent clear.
         except APIError:
@@ -176,10 +179,9 @@ class SubscriptionsClient:
             ClientError: on a client error.
         """
         url = f'https://api.planet.com/subscriptions/v1/{subscription_id}'
-        req = Request(url, method='GET')
 
         try:
-            resp = await self.session.request(req)
+            resp = await self._session.request(method='GET', url=url)
         # Forward APIError. We don't strictly need this clause, but it
         # makes our intent clear.
         except APIError:
@@ -225,11 +227,14 @@ class SubscriptionsClient:
         params = {'status': [val for val in status or {}]}
         root_url = 'https://api.planet.com/subscriptions/v1'
         url = f'{root_url}/{subscription_id}/results'
-        req = Request(url, params=params)
 
         try:
-            async for sub in _ResultsPager(req,
-                                           self.session.request,
+            resp = await self._session.request(method='GET',
+                                               url=url,
+                                               params=params)
+
+            async for sub in _ResultsPager(resp,
+                                           self._session.request,
                                            limit=limit):
                 yield sub
         # Forward APIError. We don't strictly need this clause, but it
diff --git a/planet/http.py b/planet/http.py
index 4be3049..d554c4a 100644
--- a/planet/http.py
+++ b/planet/http.py
@@ -16,17 +16,17 @@
 from __future__ import annotations  # https://stackoverflow.com/a/33533514
 import asyncio
 from collections import Counter
+from contextlib import asynccontextmanager
 from http import HTTPStatus
 import logging
 import random
 import time
-
+from typing import AsyncGenerator, Optional
 import httpx
 
 from .auth import Auth, AuthType
 from . import exceptions, models
 from .__version__ import __version__
-from typing import Optional
 
 # NOTE: configuration of the session was performed using the data API quick
 # search endpoint. These values can be re-tested, tested with a new endpoint or
@@ -243,13 +243,16 @@ class Session(BaseSession):
 
         LOGGER.info(f'Session read timeout set to {READ_TIMEOUT}.')
         timeout = httpx.Timeout(10.0, read=READ_TIMEOUT)
+
+        headers = {
+            'User-Agent': self._get_user_agent(), 'X-Planet-App': 'python-sdk'
+        }
+
         self._client = httpx.AsyncClient(auth=auth,
+                                         headers=headers,
                                          timeout=timeout,
                                          follow_redirects=True)
 
-        self._client.headers.update({'User-Agent': self._get_user_agent()})
-        self._client.headers.update({'X-Planet-App': 'python-sdk'})
-
         async def alog_request(*args, **kwargs):
             return self._log_request(*args, **kwargs)
 
@@ -349,13 +352,17 @@ class Session(BaseSession):
         return min(calc_wait, max_retry_backoff)
 
     async def request(self,
-                      request: models.Request,
-                      stream: bool = False) -> models.Response:
-        """Submit a request with retry.
+                      method: str,
+                      url: str,
+                      json: Optional[dict] = None,
+                      params: Optional[dict] = None) -> models.Response:
+        """Build a request and submit it with retry and limiting.
 
         Parameters:
-            request: Request to submit.
-            stream: Get the body as a stream.
+            method: HTTP request method.
+            url: Location of the API endpoint.
+            json: JSON to send.
+            params: Values to send in the query string.
 
         Returns:
             Server response.
@@ -364,31 +371,47 @@ class Session(BaseSession):
             planet.exceptions.APIException: On API error.
             planet.exceptions.ClientError: When retry limit is exceeded.
         """
-        # TODO: retry will be provided in httpx v1 [1] with usage [2]
-        # 1. https://github.com/encode/httpcore/pull/221
-        # 2. https://github.com/encode/httpx/blob/
-        # 89fb0cbc69ea07b123dd7b36dc1ed9151c5d398f/docs/async.md#explicit-transport-instances # noqa
-        # TODO: if throttling is necessary, check out [1] once v1
-        # 1. https://github.com/encode/httpx/issues/984
-        return await self._retry(self._request, request, stream=stream)
+        if json:
+            headers = {'Content-Type': 'application/json'}
+        else:
+            headers = None
+
+        request = self._client.build_request(method=method,
+                                             url=url,
+                                             json=json,
+                                             params=params,
+                                             headers=headers)
+
+        http_response = await self._retry(self._send, request, stream=False)
+        return models.Response(http_response)
 
-    async def _request(self, request, stream=False):
-        """Submit a request with rate/worker limiting."""
+    async def _send(self, request, stream=False) -> httpx.Response:
+        """Send request with with rate/worker limiting."""
         async with self._limiter:
-            http_resp = await self._client.send(request.http_request,
-                                                stream=stream)
-        return models.Response(request, http_resp)
+            http_resp = await self._client.send(request, stream=stream)
 
-    def stream(self, request: models.Request) -> Stream:
+        return http_resp
+
+    @asynccontextmanager
+    async def stream(
+            self, method: str,
+            url: str) -> AsyncGenerator[models.StreamingResponse, None]:
         """Submit a request and get the response as a stream context manager.
 
         Parameters:
-            request: Request to submit
+            method: HTTP request method.
+            url: Location of the API endpoint.
 
         Returns:
-            Context manager providing the body as a stream.
+            Context manager providing the streaming response.
         """
-        return Stream(session=self, request=request)
+        request = self._client.build_request(method=method, url=url)
+        http_response = await self._retry(self._send, request, stream=True)
+        response = models.StreamingResponse(http_response)
+        try:
+            yield response
+        finally:
+            await response.aclose()
 
 
 class AuthSession(BaseSession):
@@ -404,11 +427,13 @@ class AuthSession(BaseSession):
             self._log_response, self._raise_for_status
         ]
 
-    def request(self, request):
+    def request(self, method: str, url: str, json: dict):
         """Submit a request
 
         Parameters:
-            request: Request to submit.
+            method: HTTP request method.
+            url: Location of the API endpoint.
+            json: JSON to send.
 
         Returns:
             Server response.
@@ -416,9 +441,9 @@ class AuthSession(BaseSession):
         Raises:
             planet.exceptions.APIException: On API error.
         """
-
-        http_resp = self._client.send(request.http_request)
-        return models.Response(request, http_resp)
+        request = self._client.build_request(method=method, url=url, json=json)
+        http_resp = self._client.send(request)
+        return models.Response(http_resp)
 
     @classmethod
     def _raise_for_status(cls, response):
@@ -428,26 +453,3 @@ class AuthSession(BaseSession):
             raise exceptions.APIError('Not a valid email address.')
         except exceptions.InvalidAPIKey:
             raise exceptions.APIError('Incorrect email or password.')
-
-
-class Stream:
-    '''Context manager for asynchronous response stream from Planet server.'''
-
-    def __init__(self, session: Session, request: models.Request):
-        """
-        Parameters:
-            session: Open session to Planet server.
-            request:  Request to submit.
-        """
-        self.session = session
-        self.request = request
-
-    async def __aenter__(self):
-        self.response = await self.session.request(
-            request=self.request,
-            stream=True,
-        )
-        return self.response
-
-    async def __aexit__(self, exc_type=None, exc_value=None, traceback=None):
-        await self.response.aclose()
diff --git a/planet/models.py b/planet/models.py
index 5bc79b5..ab38d8e 100644
--- a/planet/models.py
+++ b/planet/models.py
@@ -13,14 +13,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 """Manage data for requests and responses."""
-from datetime import datetime
 import logging
 import mimetypes
 from pathlib import Path
 import random
 import re
 import string
-import typing
+from typing import AsyncGenerator, Callable, List
 
 import httpx
 from tqdm.asyncio import tqdm
@@ -30,156 +29,87 @@ from .exceptions import PagingError
 LOGGER = logging.getLogger(__name__)
 
 
-class RequestException(Exception):
-    """Exceptions thrown by RequestException"""
-    pass
-
-
-class Request:
-    '''Handles a HTTP request for the Planet server.
-
-    :param url: URL of API endpoint
-    :type url: str
-    :param params: Values to send in the query string. Defaults to None.
-    :type params: dict, list of tuples, or bytes, optional
-    :param data: Object to send in the body. Defaults to None.
-    :type data: dict, list of tuples, bytes, or file-like object, optional
-    :param json: JSON to send. Defaults to None.
-    :type json: dict, optional
-    :param method: HTTP request method. Defaults to 'GET'
-    :type method: str, optional
-    :raises RequestException: When provided `body_type` is not a subclass of
-        :py:class:`planet.api.models.Body`
-    '''
-
-    def __init__(self, url, params=None, data=None, json=None, method='GET'):
-        if data or json:
-            headers = {'Content-Type': 'application/json'}
-        else:
-            headers = None
-
-        self.http_request = httpx.Request(method,
-                                          url,
-                                          params=params,
-                                          data=data,
-                                          json=json,
-                                          headers=headers)
+class Response:
+    """Handles the Planet server's response to a HTTP request."""
 
-    @property
-    def url(self):
-        return self.http_request.url
+    def __init__(self, http_response: httpx.Response):
+        """Initialize object.
 
-    @url.setter
-    def url(self, url):
-        '''Set the url.
+        Parameters:
+            http_response: Response that was received from the server.
+        """
+        self._http_response = http_response
 
-        :param url: URL of API endpoint
-        :type url: str
-        '''
-        self.http_request.url = httpx.URL(url)
+    def __repr__(self):
+        return f'<models.Response [{self.status_code}]>'
 
+    @property
+    def status_code(self) -> int:
+        """HTTP status code"""
+        return self._http_response.status_code
 
-class Response:
-    '''Handles the Planet server's response to a HTTP request.
+    def json(self) -> dict:
+        """Response json"""
+        return self._http_response.json()
 
-    :param request: Request that was submitted to the server
-    :type request: :py:Class:`Request`
-    :param http_response: Response that was received from the server
-    :type http_response: :py:Class:`requests.models.Response`
-    '''
 
-    def __init__(self, request, http_response):
-        self.request = request
-        self.http_response = http_response
-
-    def __repr__(self):
-        return f'<models.Response [{self.status_code}]>'
+class StreamingResponse(Response):
 
     @property
-    def status_code(self):
-        '''HTTP status code.
+    def headers(self) -> httpx.Headers:
+        return self._http_response.headers
 
-        :returns: status code
-        :rtype: int
-        '''
-        return self.http_response.status_code
+    @property
+    def url(self) -> str:
+        return str(self._http_response.url)
 
-    def json(self):
-        '''Get response json.
+    @property
+    def num_bytes_downloaded(self) -> int:
+        return self._http_response.num_bytes_downloaded
 
-        :returns:response json
-        :rtype: dict
-        '''
-        return self.http_response.json()
+    async def aiter_bytes(self):
+        async for c in self._http_response.aiter_bytes():
+            yield c
 
     async def aclose(self):
-        await self.http_response.aclose()
+        await self._http_response.aclose()
 
 
 class StreamingBody:
-    '''A representation of a streaming resource from the API.
+    """A representation of a streaming resource from the API."""
 
-    :param response: Response that was received from the server
-    :type response: :py:Class:`requests.models.Response`
-        '''
+    def __init__(self, response: StreamingResponse):
+        """Initialize the object.
 
-    def __init__(self, response):
-        self.response = response.http_response
-        self.url = response.request.url
+        Parameters:
+            response: Response that was received from the server.
+        """
+        self._response = response
 
     @property
-    def name(self):
-        '''The name of this resource.
+    def name(self) -> str:
+        """The name of this resource.
+
         The default is to use the content-disposition header value from the
         response. If not found, falls back to resolving the name from the url
         or generating a random name with the type from the response.
-
-        :returns: name of this resource
-        :rtype: str
-        '''
-        name = (_get_filename_from_headers(self.response.headers)
-                or _get_filename_from_url(self.url) or _get_random_filename(
-                    self.response.headers.get('content-type')))
+        """
+        name = (_get_filename_from_headers(self._response.headers)
+                or _get_filename_from_url(self._response.url)
+                or _get_random_filename(
+                    self._response.headers.get('content-type')))
         return name
 
     @property
-    def size(self):
-        '''The size of the body.
-
-        :returns: size of the body
-        :rtype: int
-        '''
-        return int(self.response.headers['Content-Length'])
-
-    @property
-    def num_bytes_downloaded(self):
-        '''The number of bytes downloaded.
-
-        :returns: number of bytes downloaded
-        :rtype: int
-        '''
-        return self.response.num_bytes_downloaded
-
-    def last_modified(self):
-        '''Read the last-modified header as a datetime, if present.
-
-        :returns: last-modified header
-        :rtype: datatime or None
-        '''
-        lm = self.response.headers.get('last-modified', None)
-        return datetime.strptime(lm, '%a, %d %b %Y %H:%M:%S GMT') if lm \
-            else None
-
-    async def aiter_bytes(self):
-        async for c in self.response.aiter_bytes():
-            yield c
+    def size(self) -> int:
+        """The size of the body."""
+        return int(self._response.headers['Content-Length'])
 
     async def write(self,
                     filename: Path,
                     overwrite: bool = True,
                     progress_bar: bool = True):
         """Write the body to a file.
-
         Parameters:
             filename: Name to assign to downloaded file.
             overwrite: Overwrite any existing files.
@@ -221,10 +151,10 @@ class StreamingBody:
                           unit='B',
                           desc=str(filename),
                           disable=not progress_bar) as progress:
-                    previous = self.num_bytes_downloaded
-                    async for chunk in self.aiter_bytes():
+                    previous = self._response.num_bytes_downloaded
+                    async for chunk in self._response.aiter_bytes():
                         fp.write(chunk)
-                        new = self.num_bytes_downloaded
+                        new = self._response.num_bytes_downloaded
                         _log.update(new)
                         progress.update(new - previous)
                         previous = new
@@ -278,31 +208,26 @@ class Paged:
     ITEMS_KEY = 'items'
 
     def __init__(self,
-                 request: Request,
-                 do_request_fcn: typing.Callable,
+                 response: Response,
+                 request_fcn: Callable,
                  limit: int = 0):
         """
         Parameters:
             request: Request to send to server for first page.
-            do_request_fcn: Function for submitting a request. Must take in
-                Request object and return Response.
+            request_fcn: Function for submitting a request and retrieving a
+            result. Must take in url and method parameters.
             limit: Maximum number of results to return. When set to 0, no
                 maximum is applied.
         """
-        self.request = request
-        self._do_request = do_request_fcn
+        self._request_fcn = request_fcn
 
-        self._pages = self._get_pages()
+        self._pages = self._get_pages(response)
 
-        self._items: typing.List[dict] = []
+        self._items: List[dict] = []
 
         self.i = 0
         self.limit = limit
 
-    @staticmethod
-    def _next_page_request(url):
-        return Request(url, 'GET')
-
     def __aiter__(self):
         return self
 
@@ -328,19 +253,15 @@ class Paged:
 
         return item
 
-    async def _get_pages(self) -> typing.AsyncGenerator:
-        LOGGER.debug('getting first page')
-        resp = await self._do_request(self.request)
-        page = resp.json()
+    async def _get_pages(self, response) -> AsyncGenerator:
+        page = response.json()
         yield page
 
         next_url = self._next_link(page)
         while (next_url):
             LOGGER.debug('getting next page')
-            request = self._next_page_request(next_url)
-            resp = await self._do_request(request)
-            page = resp.json()
-            yield page
+            response = await self._request_fcn(url=next_url, method='GET')
+            page = response.json()
 
             # If the next URL is the same as the previous URL we will
             # get the same response and be stuck in a page cycle. This
@@ -353,6 +274,8 @@ class Paged:
                 raise PagingError(
                     "Page cycle detected at {!r}".format(next_url))
 
+            yield page
+
     def _next_link(self, page):
         try:
             next_link = page[self.LINKS_KEY][self.NEXT_KEY]


Test patch:
diff --git a/tests/integration/test_orders_api.py b/tests/integration/test_orders_api.py
index 00701d1..d7ddab2 100644
--- a/tests/integration/test_orders_api.py
+++ b/tests/integration/test_orders_api.py
@@ -108,8 +108,8 @@ async def test_list_orders_basic(order_descriptions, session):
         },
         "orders": [order1, order2]
     }
-    mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response)
-    respx.get(TEST_ORDERS_URL).return_value = mock_resp1
+    mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response)
+    respx.get(TEST_ORDERS_URL).return_value = mock_resp
 
     page2_response = {"_links": {"_self": next_page_url}, "orders": [order3]}
     mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
@@ -188,15 +188,20 @@ async def test_list_orders_limit(order_descriptions,
 
 @respx.mock
 @pytest.mark.asyncio
-async def test_create_order(oid, order_description, order_request, session):
-    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
-    respx.post(TEST_ORDERS_URL).return_value = mock_resp
+async def test_create_order_basic(oid,
+                                  order_description,
+                                  order_request,
+                                  session):
+    route = respx.post(TEST_ORDERS_URL)
+    route.return_value = httpx.Response(HTTPStatus.OK, json=order_description)
 
     cl = OrdersClient(session, base_url=TEST_URL)
     order = await cl.create_order(order_request)
 
     assert order == order_description
 
+    assert json.loads(route.calls.last.request.content) == order_request
+
 
 @respx.mock
 @pytest.mark.asyncio
diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py
index a835e2c..5a25eb5 100644
--- a/tests/unit/test_http.py
+++ b/tests/unit/test_http.py
@@ -13,10 +13,11 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 import asyncio
+import json
 import logging
 from http import HTTPStatus
 import math
-from unittest.mock import Mock, patch
+from unittest.mock import patch
 
 import httpx
 import respx
@@ -30,13 +31,6 @@ TEST_URL = 'mock://fantastic.com'
 LOGGER = logging.getLogger(__name__)
 
 
-@pytest.fixture
-def mock_request():
-    r = Mock()
-    r.http_request = httpx.Request('GET', TEST_URL)
-    yield r
-
-
 @pytest.fixture
 def mock_response():
 
@@ -198,31 +192,44 @@ async def test_session_contextmanager():
 
 @respx.mock
 @pytest.mark.asyncio
-async def test_session_request(mock_request):
+@pytest.mark.parametrize('data', (None, {'boo': 'baa'}))
+async def test_session_request_success(data):
 
     async with http.Session() as ps:
-        mock_resp = httpx.Response(HTTPStatus.OK, text='bubba')
-        respx.get(TEST_URL).return_value = mock_resp
+        resp_json = {'foo': 'bar'}
+        route = respx.get(TEST_URL)
+        route.return_value = httpx.Response(HTTPStatus.OK, json=resp_json)
+
+        resp = await ps.request(method='GET', url=TEST_URL, json=data)
+        assert resp.json() == resp_json
+
+        # the proper headers are included and they have the expected values
+        received_request = route.calls.last.request
+        assert received_request.headers['x-planet-app'] == 'python-sdk'
+        assert 'planet-client-python/' in received_request.headers[
+            'user-agent']
 
-        resp = await ps.request(mock_request)
-        assert resp.http_response.text == 'bubba'
+        if data:
+            assert received_request.headers[
+                'content-type'] == 'application/json'
+            assert json.loads(received_request.content) == data
 
 
 @respx.mock
 @pytest.mark.asyncio
-async def test_session_stream(mock_request):
+async def test_session_stream():
     async with http.Session() as ps:
         mock_resp = httpx.Response(HTTPStatus.OK, text='bubba')
         respx.get(TEST_URL).return_value = mock_resp
 
-        async with ps.stream(mock_request) as resp:
-            txt = await resp.http_response.aread()
-            assert txt == b'bubba'
+        async with ps.stream(method='GET', url=TEST_URL) as resp:
+            chunks = [c async for c in resp.aiter_bytes()]
+            assert chunks[0] == b'bubba'
 
 
 @respx.mock
 @pytest.mark.asyncio
-async def test_session_request_retry(mock_request):
+async def test_session_request_retry():
     """Test the retry in the Session.request method"""
     async with http.Session() as ps:
         route = respx.get(TEST_URL)
@@ -234,7 +241,7 @@ async def test_session_request_retry(mock_request):
         # let's not actually introduce a wait into the tests
         ps.max_retry_backoff = 0
 
-        resp = await ps.request(mock_request)
+        resp = await ps.request(method='GET', url=TEST_URL)
         assert resp
         assert route.call_count == 2
 
@@ -279,13 +286,14 @@ def test__calculate_wait():
 
 @respx.mock
 @pytest.mark.asyncio
-async def test_authsession_request(mock_request):
+async def test_authsession_request():
     sess = http.AuthSession()
-    mock_resp = httpx.Response(HTTPStatus.OK, text='bubba')
+    resp_json = {'token': 'foobar'}
+    mock_resp = httpx.Response(HTTPStatus.OK, json=resp_json)
     respx.get(TEST_URL).return_value = mock_resp
 
-    resp = sess.request(mock_request)
-    assert resp.http_response.text == 'bubba'
+    resp = sess.request(method='GET', url=TEST_URL, json={'foo': 'bar'})
+    assert resp.json() == resp_json
 
 
 def test_authsession__raise_for_status(mock_response):
diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py
index c77ff2b..a95ded7 100644
--- a/tests/unit/test_models.py
+++ b/tests/unit/test_models.py
@@ -18,7 +18,6 @@ from unittest.mock import MagicMock
 import os
 from pathlib import Path
 import re
-from datetime import datetime
 
 from httpx import URL
 import pytest
@@ -29,25 +28,10 @@ from planet.exceptions import PagingError
 LOGGER = logging.getLogger(__name__)
 
 
-@pytest.fixture
-def mocked_request():
-    return models.Request('url', 'auth')
-
-
-def mock_http_response(json=None, iter_content=None, text=None):
-    m = MagicMock(name='http_response')
-    m.headers = {}
-    m.json.return_value = json or {}
-    m.aiter_content = iter_content
-    m.text = text or ''
-    return m
-
-
-def test_StreamingBody_name():
+def test_StreamingBody_name_filename():
     r = MagicMock(name='response')
-    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
-    hr = MagicMock(name='http_response')
-    hr.headers = {
+    r.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
+    r.headers = {
         'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
         'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
         'accept-ranges': 'bytes',
@@ -55,36 +39,35 @@ def test_StreamingBody_name():
         'content-length': '57350256',
         'content-disposition': 'attachment; filename="open_california.tif"'
     }
-    r.http_response = hr
     body = models.StreamingBody(r)
     assert body.name == 'open_california.tif'
 
+
+def test_StreamingBody_name_url():
     r = MagicMock(name='response')
-    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
-    hr = MagicMock(name='http_response')
-    hr.headers = {
+    r.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
+    r.headers = {
         'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
         'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
         'accept-ranges': 'bytes',
         'content-type': 'image/tiff',
         'content-length': '57350256',
     }
-    r.http_response = hr
     body = models.StreamingBody(r)
 
     assert body.name == 'example.tif'
 
+
+def test_StreamingBody_name_content():
     r = MagicMock(name='response')
-    r.request.url = URL('https://planet.com/path/to/noname/')
-    hr = MagicMock(name='http_response')
-    hr.headers = {
+    r.url = URL('https://planet.com/path/to/noname/')
+    r.headers = {
         'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
         'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
         'accept-ranges': 'bytes',
         'content-type': 'image/tiff',
         'content-length': '57350256',
     }
-    r.http_response = hr
     body = models.StreamingBody(r)
 
     assert body.name.startswith('planet-')
@@ -143,7 +126,7 @@ def test__get_random_filename(content_type, check):
 
 
 @pytest.mark.asyncio
-async def test_StreamingBody_write_img(tmpdir, mocked_request, open_test_img):
+async def test_StreamingBody_write_img(tmpdir, open_test_img):
 
     async def _aiter_bytes():
         data = open_test_img.read()
@@ -154,11 +137,9 @@ async def test_StreamingBody_write_img(tmpdir, mocked_request, open_test_img):
             yield v[i * chunksize:min((i + 1) * chunksize, len(v))]
 
     r = MagicMock(name='response')
-    hr = MagicMock(name='http_response')
-    hr.aiter_bytes = _aiter_bytes
-    hr.num_bytes_downloaded = 0
-    hr.headers['Content-Length'] = 527
-    r.http_response = hr
+    r.aiter_bytes = _aiter_bytes
+    r.num_bytes_downloaded = 0
+    r.headers['Content-Length'] = 527
     body = models.StreamingBody(r)
 
     filename = Path(tmpdir) / 'test.tif'
@@ -168,111 +149,45 @@ async def test_StreamingBody_write_img(tmpdir, mocked_request, open_test_img):
     assert os.stat(filename).st_size == 527
 
 
-def test_StreamingBody_last_modified_emptyheader():
-    '''This function tests the last_modified function for an empty header, by
-    seeing if the last_modified is None.
-    '''
-    r = MagicMock(name='response')
-    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
-    hr = MagicMock(name='http_response')
-    hr.headers = {
-        'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
-        'accept-ranges': 'bytes',
-        'content-type': 'image/tiff',
-        'content-length': '57350256',
-        'content-disposition': 'attachment; filename="open_california.tif"'
-    }
-    r.http_response = hr
-    body = models.StreamingBody(r)
-    output = body.last_modified()
-    expected = None
-    assert output == expected
-
-
-def test_StreamingBody_last_modified_completeheader():
-    '''This function tests the last_modified function for an existing header,
-    by comparing the last_modified date to
-    an expected output.
-    '''
-    r = MagicMock(name='response')
-    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
-    hr = MagicMock(name='http_response')
-    hr.headers = {
-        'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
-        'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
-        'accept-ranges': 'bytes',
-        'content-type': 'image/tiff',
-        'content-length': '57350256',
-        'content-disposition': 'attachment; filename="open_california.tif"'
-    }
-    r.http_response = hr
-    body = models.StreamingBody(r)
-    output = body.last_modified()
-    expected = datetime.strptime(hr.headers['last-modified'],
-                                 '%a, %d %b %Y %H:%M:%S GMT')
-    assert output == expected
-
-
-@pytest.fixture
-def get_pages():
-    p1 = {'_links': {'next': 'blah'}, 'items': [1, 2]}
-    p2 = {'_links': {}, 'items': [3, 4]}
-    responses = [mock_http_response(json=p1), mock_http_response(json=p2)]
-
-    async def do_get(req):
-        return responses.pop(0)
-
-    return do_get
+@pytest.mark.asyncio
+async def test_Paged_iterator():
+    resp = MagicMock(name='response')
+    resp.json = lambda: {'_links': {'next': 'blah'}, 'items': [1, 2]}
 
+    async def get_response(url, method):
+        resp = MagicMock(name='response')
+        resp.json = lambda: {'_links': {}, 'items': [3, 4]}
+        return resp
 
-@pytest.mark.asyncio
-async def test_Paged_iterator(get_pages):
-    req = MagicMock()
-    paged = models.Paged(req, get_pages)
+    paged = models.Paged(resp, get_response)
     assert [1, 2, 3, 4] == [i async for i in paged]
 
 
 @pytest.mark.asyncio
 @pytest.mark.parametrize('limit, expected', [(0, [1, 2, 3, 4]), (1, [1])])
-async def test_Paged_limit(limit, expected, get_pages):
-    req = MagicMock()
-    paged = models.Paged(req, get_pages, limit=limit)
+async def test_Paged_limit(limit, expected):
+    resp = MagicMock(name='response')
+    resp.json = lambda: {'_links': {'next': 'blah'}, 'items': [1, 2]}
+
+    async def get_response(url, method):
+        resp = MagicMock(name='response')
+        resp.json = lambda: {'_links': {}, 'items': [3, 4]}
+        return resp
+
+    paged = models.Paged(resp, get_response, limit=limit)
     assert [i async for i in paged] == expected
 
 
 @pytest.mark.asyncio
 async def test_Paged_break_page_cycle():
     """Check that we break out of a page cycle."""
+    resp = MagicMock(name='response')
+    resp.json = lambda: {'_links': {'next': 'blah'}, 'items': [1, 2]}
 
-    async def func(req):
-        return mock_http_response(json={
-            '_links': {
-                'next': 'blah'
-            }, 'items': [1, 2]
-        })
+    async def get_response(url, method):
+        return resp
 
-    req = MagicMock()
-    paged = models.Paged(req, func, limit=None)
+    paged = models.Paged(resp, get_response, limit=None)
 
     with pytest.raises(PagingError):
         [item async for item in paged]
-
-
-@pytest.mark.skip('not implemented')
-@pytest.mark.asyncio
-async def test_Paged_first_page_error():
-    """Check that we identify an error thrown by the first page upon
-    initialization."""
-
-    async def func(req):
-        return mock_http_response(404,
-                                  json={
-                                      '_links': {
-                                          'next': 'blah'
-                                      },
-                                      'items': [1, 2]
-                                  })
-
-    req = MagicMock()
-    with pytest.raises(Exception):
-        models.Paged(req, func, limit=None)