httpClient PUT doesn't seem to be including the body

I'm trying to send a single observation of a collection of tags to a Web API endpoint. The Web API expects a PUT request with a JSON body that has a few string:float entries.

I'm attempting to use an system.net.httpClient() instance to do this. Here's my example Ignition script:

import pprint
import json

test_tag = system.tag.browse("[default]KPIs/ModelTags")[5]
body = mycode.build_put_request_body_dict(test_tag)
print "Content to include in body:"
print json.dumps(body, indent=4)

url = mycode.api_url
client = system.net.httpClient()
response = client.put(url, headers={"Content-Type":"application/json"}, data=body)
print "\nResponse from API:"
print response.statusCode
print pprint.pprint(response.getJson())

And the subsequent console output:

Content to include in body:
{
    "uf_seawater_inlet_hdr_temp": 152.267133838642, 
    "reject_pressure_stg_2_psi": 167.244077783062, 
    "avg_flux_stg_1_gfd": 0.1068650075460458, 
    "permeate_tds_mgL": 161.253300205294, 
    "feed_temp_deg_F": 129.80171792201202, 
    "reject_pressure_stg_1_psi": 152.267133838642, 
    "uf_diff_pressure": 152.267133838642, 
    "perm_pressure_psi": 152.267133838642, 
    "feed_pressure_psi": 183.71871612192402, 
    "uf_buffer_outlet_flow": 153.76482823308402
}

Response from API:
422
{'detail': [{'input': None,
             'loc': [u'body'],
             'msg': u'Field required',
             'type': u'missing',
             'url': u'https://errors.pydantic.dev/2.4/v/missing'}]}

You can see that the Pydantic schema on the Web API is complaining that the body is missing. However, if I manually send a curl command from the Ignition machine with the same PUT and same body to the web API, I get the expected response.

This seems to only apply to trying to have Ignition send a PUT (or POST) with a body...all the GET requests to the API work fine.

Further info, here is the trace from the web API server when it gets the PUT from a curl command, note that it received 415 bytes in the body:

TRACE:    172.20.70.56:58233 - HTTP connection made
TRACE:    172.20.70.56:58233 - ASGI [9] Started scope={'type': 'http', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.1', 'server': ('172.20.70.55', 80), 'client': ('172.20.70.56', 58233), 'scheme': 'http', 'method': 'PUT', 'root_path': '', 'path': '/model/fouling-factor-first-pass', 'raw_path': b'/model/fouling-factor-first-pass', 'query_string': b'', 'headers': '<...>', 'state': {}}
TRACE:    172.20.70.56:58233 - ASGI [9] Receive {'type': 'http.request', 'body': '<415 bytes>', 'more_body': False}
TRACE:    172.20.70.56:58233 - ASGI [9] Send {'type': 'http.response.start', 'status': 200, 'headers': '<...>'}
INFO:     172.20.70.56:58233 - "PUT /model/fouling-factor-first-pass HTTP/1.1" 200 OK
TRACE:    172.20.70.56:58233 - ASGI [9] Send {'type': 'http.response.body', 'body': '<69 bytes>'}
TRACE:    172.20.70.56:58233 - ASGI [9] Completed
TRACE:    172.20.70.56:58233 - HTTP connection lost

Compared to the trace from a PUT request from the Ignition script, note that it receives 0 bytes in the body therefore it sends back 422 since it didn't match the schema:

TRACE:    172.20.70.56:55738 - HTTP connection made
TRACE:    172.20.70.56:55738 - ASGI [2] Started scope={'type': 'http', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.1', 'server': ('172.20.70.55', 80), 'client': ('172.20.70.56', 55738), 'scheme': 'http', 'root_path': '', 'headers': '<...>', 'state': {}, 'method': 'PUT', 'path': '/model/fouling-factor-first-pass', 'raw_path': b'/model/fouling-factor-first-pass', 'query_string': b''}
TRACE:    172.20.70.56:55738 - ASGI [2] Receive {'type': 'http.request', 'body': '<0 bytes>', 'more_body': False}
TRACE:    172.20.70.56:55738 - ASGI [2] Send {'type': 'http.response.start', 'status': 422, 'headers': '<...>'}
INFO:     172.20.70.56:55738 - "PUT /model/fouling-factor-first-pass HTTP/1.1" 422 Unprocessable Entity
TRACE:    172.20.70.56:55738 - ASGI [2] Send {'type': 'http.response.body', 'body': '<132 bytes>'}
TRACE:    172.20.70.56:55738 - ASGI [2] Completed
TRACE:    172.20.70.56:55738 - HTTP connection lost

So I'm at a loss at this point. Appreciate any ideas.

What type of object are you actually building here?

Just a plain old dict, with str keys and float values.

Hmm.

Are you sure that API response means the entire body is missing and not just one field it expects?

Might print out type(body) just to make sure. Not sure why else it wouldn't get sent.

Yep, for example if I intentionally leave out one of the pairs in a PUT from another source, the response will tell me specifically what I'm missing. Note the "input" field giving me back my (incomplete) body, compared to input: None from the Ignition request.

{
  "detail": [
    {
      "type": "missing",
      "loc": [
        "body",
        "uf_buffer_outlet_flow"
      ],
      "msg": "Field required",
      "input": {
        "uf_seawater_inlet_hdr_temp": 152.092210957528,
        "reject_pressure_stg_2_psi": 167.05183580480804,
        "avg_flux_stg_1_gfd": 0.1067422188882482,
        "permeate_tds_mgL": 161.06798586589602,
        "feed_temp_deg_F": 129.65277368660801,
        "reject_pressure_stg_1_psi": 152.092210957528,
        "uf_diff_pressure": 152.092210957528,
        "perm_pressure_psi": 152.092210957528,
        "feed_pressure_psi": 183.50742313681604
      },
      "url": "https://errors.pydantic.dev/2.4/v/missing"
    }
  ]
}
import pprint
import json

test_tag = system.tag.browse("[default]KPIs/ModelTags")[5]
body = mycode.build_put_request_body_dict(test_tag)
print type(body)
print "Content to include in body:"
print json.dumps(body, indent=4)

url = mycode.api_url
client = system.net.httpClient()
response = client.put(url, headers={"Content-Type":"application/json"}, data=body)
print "\nResponse from API:"
print response.statusCode
print pprint.pprint(response.getJson())

gives

<type 'dict'>
Content to include in body:
{
    "uf_seawater_inlet_hdr_temp": 159.07369183022502, 
    "reject_pressure_stg_2_psi": 174.72455240747502, 
    "avg_flux_stg_1_gfd": 0.1116429309068178, 
    "permeate_tds_mgL": 168.464208176575, 
    "feed_temp_deg_F": 135.59740096435002, 
    "reject_pressure_stg_1_psi": 159.07369183022502, 
    "uf_diff_pressure": 159.07369183022502, 
    "perm_pressure_psi": 159.07369183022502, 
    "feed_pressure_psi": 191.94049904245003, 
    "uf_buffer_outlet_flow": 160.63877788795003
}

Response from API:
422
{'detail': [{'input': None,
             'loc': [u'body'],
             'msg': u'Field required',
             'type': u'missing',
             'url': u'https://errors.pydantic.dev/2.4/v/missing'}]}

Consider encoding your json to bytes yourself and supplying the byte array instead.

{ Don't use jython's json library. Use system.util.jsonEncode(). }

For the bytes, I recommend sticking the following in a project library:

from java.lang import String
from java.nio.charset import Charset, StandardCharsets

_getBytesCS = String.getMethod('getBytes', Charset)

def encodeString(s, cs=StandardCharsets.UTF_8):
	return _getBytesCS.invoke(s, cs)

Then you can do:

bytes = someLibrary.encodeString(system.util.jsonEncode(body))

Same result, unfortunately.

import pprint
import json

test_tag = system.tag.browse("[default]KPIs/ModelTags")[5]
body = mycode.build_put_request_body_dict(test_tag)
print type(body)
print "Content to include in body:"
print json.dumps(body, indent=4)

bytes = utils.encodeString(system.util.jsonEncode(body))
print "Byte array version:"
print bytes

url = mycode.api_url
client = system.net.httpClient()
response = client.put(url, headers={"Content-Type":"application/json"}, data=bytes)
print "\nResponse from API:"
print response.statusCode
print pprint.pprint(response.getJson())
<type 'dict'>
Content to include in body:
{
    "uf_seawater_inlet_hdr_temp": 159.07369183022502, 
    "reject_pressure_stg_2_psi": 174.72455240747502, 
    "avg_flux_stg_1_gfd": 0.1116429309068178, 
    "permeate_tds_mgL": 168.464208176575, 
    "feed_temp_deg_F": 135.59740096435002, 
    "reject_pressure_stg_1_psi": 159.07369183022502, 
    "uf_diff_pressure": 159.07369183022502, 
    "perm_pressure_psi": 159.07369183022502, 
    "feed_pressure_psi": 191.94049904245003, 
    "uf_buffer_outlet_flow": 160.63877788795003
}
Byte array version:
array('b', [123, 34, 117, 102, 95, 115, 101, 97, 119, 97, 116, 101, 114, 95, 105, 110, 108, 101, 116, 95, 104, 100, 114, 95, 116, 101, 109, 112, 34, 58, 49, 53, 57, 46, 48, 55, 51, 54, 57, 49, 56, 51, 48, 50, 50, 53, 48, 50, 44, 34, 114, 101, 106, 101, 99, 116, 95, 112, 114, 101, 115, 115, 117, 114, 101, 95, 115, 116, 103, 95, 49, 95, 112, 115, 105, 34, 58, 49, 53, 57, 46, 48, 55, 51, 54, 57, 49, 56, 51, 48, 50, 50, 53, 48, 50, 44, 34, 117, 102, 95, 100, 105, 102, 102, 95, 112, 114, 101, 115, 115, 117, 114, 101, 34, 58, 49, 53, 57, 46, 48, 55, 51, 54, 57, 49, 56, 51, 48, 50, 50, 53, 48, 50, 44, 34, 114, 101, 106, 101, 99, 116, 95, 112, 114, 101, 115, 115, 117, 114, 101, 95, 115, 116, 103, 95, 50, 95, 112, 115, 105, 34, 58, 49, 55, 52, 46, 55, 50, 52, 53, 53, 50, 52, 48, 55, 52, 55, 53, 48, 50, 44, 34, 112, 101, 114, 109, 95, 112, 114, 101, 115, 115, 117, 114, 101, 95, 112, 115, 105, 34, 58, 49, 53, 57, 46, 48, 55, 51, 54, 57, 49, 56, 51, 48, 50, 50, 53, 48, 50, 44, 34, 102, 101, 101, 100, 95, 112, 114, 101, 115, 115, 117, 114, 101, 95, 112, 115, 105, 34, 58, 49, 57, 49, 46, 57, 52, 48, 52, 57, 57, 48, 52, 50, 52, 53, 48, 48, 51, 44, 34, 97, 118, 103, 95, 102, 108, 117, 120, 95, 115, 116, 103, 95, 49, 95, 103, 102, 100, 34, 58, 48, 46, 49, 49, 49, 54, 52, 50, 57, 51, 48, 57, 48, 54, 56, 49, 55, 56, 44, 34, 117, 102, 95, 98, 117, 102, 102, 101, 114, 95, 111, 117, 116, 108, 101, 116, 95, 102, 108, 111, 119, 34, 58, 49, 54, 48, 46, 54, 51, 56, 55, 55, 55, 56, 56, 55, 57, 53, 48, 48, 51, 44, 34, 112, 101, 114, 109, 101, 97, 116, 101, 95, 116, 100, 115, 95, 109, 103, 76, 34, 58, 49, 54, 56, 46, 52, 54, 52, 50, 48, 56, 49, 55, 54, 53, 55, 53, 44, 34, 102, 101, 101, 100, 95, 116, 101, 109, 112, 95, 100, 101, 103, 95, 70, 34, 58, 49, 51, 53, 46, 53, 57, 55, 52, 48, 48, 57, 54, 52, 51, 53, 48, 48, 50, 125])

Response from API:
422
{'detail': [{'input': None,
             'loc': [u'body'],
             'msg': u'Field required',
             'type': u'missing',
             'url': u'https://errors.pydantic.dev/2.4/v/missing'}]}

Hmmm. I'd be looking at wireshark next. Comparing the traffic difference between your working stand-alone PUT and the one in Ignition.

Well, the difference was the working PUT was specifying connection: keep-alive whereas Ignition was specifying connection: upgrade to h2c

A quick check server side and indeed Uvicorn doesn't support HTTP/2, so changing to system.net.httpClient(version="HTTP_1_1") has fixed the issue.

Thank you both!

5 Likes